mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
feat(hostgen): add hostgen tool for generating Extism host function wrappers
- Implemented hostgen tool to generate wrappers from annotated Go interfaces. - Added command-line flags for input/output directories and package name. - Introduced parsing and code generation logic for host services. - Created test data for various service interfaces and expected generated code. - Added documentation for host services and annotations for code generation. - Implemented SubsonicAPI service with corresponding generated code.
This commit is contained in:
10
.github/workflows/pipeline.yml
vendored
10
.github/workflows/pipeline.yml
vendored
@@ -88,6 +88,16 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
- name: Verify no changes from go generate
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo 'Generated code is out of date. Run "make gen" and commit the changes'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go:
|
||||
name: Test Go code
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
4
Makefile
4
Makefile
@@ -103,6 +103,10 @@ wire: check_go_env ##@Development Update Dependency Injection
|
||||
go tool wire gen -tags=netgo ./...
|
||||
.PHONY: wire
|
||||
|
||||
gen: check_go_env ##@Development Run go generate for code generation
|
||||
go generate ./...
|
||||
.PHONY: gen
|
||||
|
||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
||||
.PHONY: snapshots
|
||||
|
||||
@@ -342,6 +342,118 @@ err := manager.ReloadPlugin("my-plugin")
|
||||
- **Config changes**: Plugin configuration (`PluginConfig.<name>`) is read at load time. Changes require a reload.
|
||||
- **Failed reloads**: If loading fails after unloading, the plugin remains unloaded. Check logs for errors.
|
||||
|
||||
## Host Services (Internal Development)
|
||||
|
||||
This section is for Navidrome developers who want to add new host services that plugins can call.
|
||||
|
||||
### Overview
|
||||
|
||||
Host services allow plugins to call back into Navidrome for functionality like Subsonic API access, scheduling, and other internal services. The `hostgen` tool generates Extism host function wrappers from annotated Go interfaces, automating the boilerplate of memory management, JSON marshalling, and error handling.
|
||||
|
||||
### Adding a New Host Service
|
||||
|
||||
1. **Create an annotated interface** in `plugins/host/`:
|
||||
|
||||
```go
|
||||
// MyService provides some functionality to plugins.
|
||||
//nd:hostservice name=MyService permission=myservice
|
||||
type MyService interface {
|
||||
// DoSomething performs an action.
|
||||
//nd:hostfunc
|
||||
DoSomething(ctx context.Context, input string) (output string, err error)
|
||||
}
|
||||
```
|
||||
|
||||
2. **Run the generator**:
|
||||
|
||||
```bash
|
||||
make gen
|
||||
# Or directly:
|
||||
go run ./plugins/cmd/hostgen -input=./plugins/host -output=./plugins/host
|
||||
```
|
||||
|
||||
3. **Implement the interface** and wire it up in `plugins/manager.go`.
|
||||
|
||||
### Annotation Format
|
||||
|
||||
#### Service-level (`//nd:hostservice`)
|
||||
|
||||
Marks an interface as a host service:
|
||||
- `name=<ServiceName>` - Service identifier used in generated code
|
||||
- `permission=<key>` - Manifest permission key (e.g., "subsonicapi", "scheduler")
|
||||
|
||||
#### Method-level (`//nd:hostfunc`)
|
||||
|
||||
Marks a method for host function wrapper generation:
|
||||
- `name=<CustomName>` - (Optional) Override the export name
|
||||
|
||||
### Method Signature Requirements
|
||||
|
||||
- First parameter must be `context.Context`
|
||||
- Last return value must be `error`
|
||||
- All parameter types must be JSON-serializable
|
||||
- Supported types: primitives, structs, slices, maps
|
||||
|
||||
### Generated Code
|
||||
|
||||
The generator creates `<servicename>_gen.go` with:
|
||||
- Request/response structs for each method
|
||||
- `Register<Service>HostFunctions()` - Returns Extism host functions to register
|
||||
- Helper functions for memory operations and error handling
|
||||
|
||||
Example generated function name: `subsonicapi_call` for `SubsonicAPIService.Call`
|
||||
|
||||
### Important: Annotation Placement
|
||||
|
||||
**The annotation line must immediately precede the type/method declaration without an empty comment line between them.**
|
||||
|
||||
✅ **Correct** (annotation directly before type):
|
||||
```go
|
||||
// MyService provides functionality.
|
||||
// More documentation here.
|
||||
//nd:hostservice name=MyService permission=myservice
|
||||
type MyService interface { ... }
|
||||
```
|
||||
|
||||
❌ **Incorrect** (empty comment line separates annotation):
|
||||
```go
|
||||
// MyService provides functionality.
|
||||
//
|
||||
//nd:hostservice name=MyService permission=myservice
|
||||
type MyService interface { ... }
|
||||
```
|
||||
|
||||
This is due to how Go's AST parser groups comments. An empty `//` line creates a new comment group, causing the annotation to be separated from the type's doc comment.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### "No host services found" when running generator
|
||||
|
||||
1. **Check annotation placement**: Ensure `//nd:hostservice` is on the line immediately before the `type` declaration (no blank `//` line between doc text and annotation).
|
||||
|
||||
2. **Check file naming**: The generator skips files ending in `_gen.go` or `_test.go`.
|
||||
|
||||
3. **Check interface syntax**: The type must be an interface, not a struct.
|
||||
|
||||
4. **Run with verbose flag**: Use `-v` to see what the generator is finding:
|
||||
```bash
|
||||
go run ./plugins/cmd/hostgen -input=./plugins/host -output=./plugins/host -v
|
||||
```
|
||||
|
||||
#### Generated code doesn't compile
|
||||
|
||||
1. **Check method signatures**: First parameter must be `context.Context`, last return must be `error`.
|
||||
|
||||
2. **Check parameter types**: All types must be JSON-serializable. Avoid channels, functions, and unexported types.
|
||||
|
||||
3. **Review raw output**: Use `-dry-run` to see the generated code without writing files.
|
||||
|
||||
#### Methods not being generated
|
||||
|
||||
1. **Check `//nd:hostfunc` annotation**: It must be in the method's doc comment, immediately before the method signature.
|
||||
|
||||
2. **Check method visibility**: Only methods with names (not embedded interfaces) are processed.
|
||||
|
||||
## Security
|
||||
|
||||
Plugins run in a secure WebAssembly sandbox with these restrictions:
|
||||
|
||||
223
plugins/cmd/hostgen/README.md
Normal file
223
plugins/cmd/hostgen/README.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# hostgen
|
||||
|
||||
A code generator for Navidrome's plugin host functions. It reads Go interface definitions with special annotations and generates Extism host function wrappers.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
hostgen -input <dir> -output <dir> -package <name> [-v] [-dry-run]
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------------|----------------------------------------------------------------|----------|
|
||||
| `-input` | Directory containing Go source files with annotated interfaces | Required |
|
||||
| `-output` | Directory where generated files will be written | Required |
|
||||
| `-package` | Package name for generated files | Required |
|
||||
| `-v` | Verbose output | `false` |
|
||||
| `-dry-run` | Parse and validate without writing files | `false` |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
go run ./plugins/cmd/hostgen \
|
||||
-input ./plugins/host \
|
||||
-output ./plugins/host \
|
||||
-package host
|
||||
```
|
||||
|
||||
Or via `go generate` (recommended):
|
||||
|
||||
```go
|
||||
//go:generate go run ../cmd/hostgen -input . -output . -package host
|
||||
package host
|
||||
```
|
||||
|
||||
## Annotations
|
||||
|
||||
### `//nd:hostservice`
|
||||
|
||||
Marks an interface as a host service that will have wrappers generated.
|
||||
|
||||
```go
|
||||
//nd:hostservice name=<ServiceName> permission=<permission>
|
||||
type MyService interface { ... }
|
||||
```
|
||||
|
||||
| Parameter | Description | Required |
|
||||
|--------------|-----------------------------------------------------------------|----------|
|
||||
| `name` | Service name used in generated type names and function prefixes | Yes |
|
||||
| `permission` | Permission required by plugins to use this service | Yes |
|
||||
|
||||
### `//nd:hostfunc`
|
||||
|
||||
Marks a method within a host service interface for export to plugins.
|
||||
|
||||
```go
|
||||
//nd:hostfunc [name=<export_name>]
|
||||
MethodName(ctx context.Context, ...) (result Type, err error)
|
||||
```
|
||||
|
||||
| Parameter | Description | Required |
|
||||
|-----------|-------------------------------------------------------------------------|----------|
|
||||
| `name` | Custom export name (default: `<servicename>_<methodname>` in lowercase) | No |
|
||||
|
||||
## Input Format
|
||||
|
||||
Host service interfaces must follow these conventions:
|
||||
|
||||
1. **First parameter must be `context.Context`** - Required for all methods
|
||||
2. **Last return value should be `error`** - For proper error handling
|
||||
3. **Annotations must be on consecutive lines** - No blank comment lines between doc and annotation
|
||||
|
||||
### Example Interface
|
||||
|
||||
```go
|
||||
package host
|
||||
|
||||
import "context"
|
||||
|
||||
// SubsonicAPIService provides access to Navidrome's Subsonic API.
|
||||
// This documentation becomes part of the generated code.
|
||||
//nd:hostservice name=SubsonicAPI permission=subsonicapi
|
||||
type SubsonicAPIService interface {
|
||||
// Call executes a Subsonic API request and returns the response.
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (response string, err error)
|
||||
}
|
||||
```
|
||||
|
||||
## Generated Output
|
||||
|
||||
For each annotated interface, hostgen generates:
|
||||
|
||||
### Request/Response Types
|
||||
|
||||
```go
|
||||
// SubsonicAPICallRequest is the request type for SubsonicAPI.Call.
|
||||
type SubsonicAPICallRequest struct {
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
// SubsonicAPICallResponse is the response type for SubsonicAPI.Call.
|
||||
type SubsonicAPICallResponse struct {
|
||||
Response string `json:"response,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### Registration Function
|
||||
|
||||
```go
|
||||
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
|
||||
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newSubsonicAPICallHostFunction(service),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Host Function Wrappers
|
||||
|
||||
Each method gets a wrapper that:
|
||||
1. Reads JSON request from plugin memory
|
||||
2. Unmarshals to the request type
|
||||
3. Calls the service method
|
||||
4. Marshals the response
|
||||
5. Writes JSON response to plugin memory
|
||||
|
||||
## Supported Types
|
||||
|
||||
hostgen supports these Go types in method signatures:
|
||||
|
||||
| Type | JSON Representation |
|
||||
|-------------------------------|------------------------------------------|
|
||||
| `string`, `int`, `bool`, etc. | Native JSON types |
|
||||
| `[]T` (slices) | JSON arrays |
|
||||
| `map[K]V` (maps) | JSON objects |
|
||||
| `*T` (pointers) | Nullable fields |
|
||||
| `interface{}` / `any` | Converts to `any` |
|
||||
| Custom structs | JSON objects (must be JSON-serializable) |
|
||||
|
||||
### Multiple Return Values
|
||||
|
||||
Methods can return multiple values (plus error):
|
||||
|
||||
```go
|
||||
//nd:hostfunc
|
||||
Search(ctx context.Context, query string) (results []string, total int, hasMore bool, err error)
|
||||
```
|
||||
|
||||
Generates:
|
||||
|
||||
```go
|
||||
type ServiceSearchResponse struct {
|
||||
Results []string `json:"results,omitempty"`
|
||||
Total int `json:"total,omitempty"`
|
||||
HasMore bool `json:"hasMore,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
## Output Files
|
||||
|
||||
Generated files are named `<servicename>_gen.go` (lowercase). Each file includes:
|
||||
|
||||
- `// Code generated by hostgen. DO NOT EDIT.` header
|
||||
- Required imports (`context`, `encoding/json`, `extism`)
|
||||
- Request/response struct types
|
||||
- Registration function
|
||||
- Host function wrappers
|
||||
- Helper functions (`writeResponse`, `writeErrorResponse`)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Annotations Not Detected
|
||||
|
||||
Ensure annotations are on consecutive lines with no blank `//` lines:
|
||||
|
||||
```go
|
||||
// ✅ Correct
|
||||
// Documentation for the service.
|
||||
//nd:hostservice name=Test permission=test
|
||||
|
||||
// ❌ Wrong - blank comment line breaks detection
|
||||
// Documentation for the service.
|
||||
//
|
||||
//nd:hostservice name=Test permission=test
|
||||
```
|
||||
|
||||
### Methods Not Exported
|
||||
|
||||
Methods without `//nd:hostfunc` annotation are skipped. Ensure the annotation is directly above the method:
|
||||
|
||||
```go
|
||||
// ✅ Correct
|
||||
// Method documentation.
|
||||
//nd:hostfunc
|
||||
MyMethod(ctx context.Context) error
|
||||
|
||||
// ❌ Wrong - annotation not directly above method
|
||||
//nd:hostfunc
|
||||
|
||||
MyMethod(ctx context.Context) error
|
||||
```
|
||||
|
||||
### Generated Files Skipped
|
||||
|
||||
Files ending in `_gen.go` are automatically skipped during parsing to avoid processing previously generated code.
|
||||
|
||||
## Development
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
go test -v ./plugins/cmd/hostgen/...
|
||||
```
|
||||
|
||||
The test suite includes:
|
||||
- CLI integration tests
|
||||
- Complex type handling (structs, slices, maps, pointers)
|
||||
- Multiple return value scenarios
|
||||
- Error cases and edge conditions
|
||||
13
plugins/cmd/hostgen/hostgen_suite_test.go
Normal file
13
plugins/cmd/hostgen/hostgen_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestHostgen(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Hostgen CLI Suite")
|
||||
}
|
||||
259
plugins/cmd/hostgen/integration_test.go
Normal file
259
plugins/cmd/hostgen/integration_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/format"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var testdataDir string
|
||||
|
||||
func readTestdata(filename string) string {
|
||||
content, err := os.ReadFile(filepath.Join(testdataDir, filename))
|
||||
Expect(err).ToNot(HaveOccurred(), "Failed to read testdata file: %s", filename)
|
||||
return string(content)
|
||||
}
|
||||
|
||||
var _ = Describe("hostgen CLI", Ordered, func() {
|
||||
var (
|
||||
testDir string
|
||||
outputDir string
|
||||
hostgenBin string
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
// Set testdata directory
|
||||
testdataDir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "hostgen", "testdata")
|
||||
|
||||
// Build the hostgen binary
|
||||
hostgenBin = filepath.Join(os.TempDir(), "hostgen-test")
|
||||
cmd := exec.Command("go", "build", "-o", hostgenBin, ".")
|
||||
cmd.Dir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "hostgen")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Failed to build hostgen: %s", output)
|
||||
DeferCleanup(func() {
|
||||
os.Remove(hostgenBin)
|
||||
})
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
testDir, err = os.MkdirTemp("", "hostgen-test-input-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
outputDir, err = os.MkdirTemp("", "hostgen-test-output-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(testDir)
|
||||
os.RemoveAll(outputDir)
|
||||
})
|
||||
|
||||
Describe("CLI flags and behavior", func() {
|
||||
BeforeEach(func() {
|
||||
serviceCode := `package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc
|
||||
DoAction(ctx context.Context, input string) (output string, err error)
|
||||
}
|
||||
`
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||
})
|
||||
|
||||
It("supports verbose mode", func() {
|
||||
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-v")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
outputStr := string(output)
|
||||
Expect(outputStr).To(ContainSubstring("Input directory:"))
|
||||
Expect(outputStr).To(ContainSubstring("Output directory:"))
|
||||
Expect(outputStr).To(ContainSubstring("Found 1 host service(s)"))
|
||||
Expect(outputStr).To(ContainSubstring("Generated"))
|
||||
})
|
||||
|
||||
It("supports dry-run mode", func() {
|
||||
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-dry-run")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
Expect(string(output)).To(ContainSubstring("RegisterTestHostFunctions"))
|
||||
Expect(filepath.Join(outputDir, "test_gen.go")).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("infers package name from output directory", func() {
|
||||
customOutput, err := os.MkdirTemp("", "mypkg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer os.RemoveAll(customOutput)
|
||||
|
||||
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", customOutput)
|
||||
_, err = cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(customOutput, "test_gen.go"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(content)).To(ContainSubstring("package mypkg"))
|
||||
})
|
||||
|
||||
It("returns error for invalid input directory", func() {
|
||||
cmd := exec.Command(hostgenBin, "-input", "/nonexistent/path")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(string(output)).To(ContainSubstring("Error parsing source files"))
|
||||
})
|
||||
|
||||
It("handles no annotated services gracefully", func() {
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte("package testpkg\n"), 0600)).To(Succeed())
|
||||
|
||||
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-v")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
Expect(string(output)).To(ContainSubstring("No host services found"))
|
||||
})
|
||||
|
||||
It("generates separate files for multiple services", func() {
|
||||
// Remove service.go created by BeforeEach
|
||||
Expect(os.Remove(filepath.Join(testDir, "service.go"))).To(Succeed())
|
||||
|
||||
service1 := `package testpkg
|
||||
import "context"
|
||||
//nd:hostservice name=ServiceA permission=a
|
||||
type ServiceA interface {
|
||||
//nd:hostfunc
|
||||
MethodA(ctx context.Context) error
|
||||
}
|
||||
`
|
||||
service2 := `package testpkg
|
||||
import "context"
|
||||
//nd:hostservice name=ServiceB permission=b
|
||||
type ServiceB interface {
|
||||
//nd:hostfunc
|
||||
MethodB(ctx context.Context) error
|
||||
}
|
||||
`
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "a.go"), []byte(service1), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "b.go"), []byte(service2), 0600)).To(Succeed())
|
||||
|
||||
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-v")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
Expect(string(output)).To(ContainSubstring("Found 2 host service(s)"))
|
||||
|
||||
Expect(filepath.Join(outputDir, "servicea_gen.go")).To(BeAnExistingFile())
|
||||
Expect(filepath.Join(outputDir, "serviceb_gen.go")).To(BeAnExistingFile())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("code generation", func() {
|
||||
DescribeTable("generates correct output",
|
||||
func(serviceFile, expectedFile string) {
|
||||
serviceCode := readTestdata(serviceFile)
|
||||
expectedCode := readTestdata(expectedFile)
|
||||
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||
|
||||
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
entries, err := os.ReadDir(outputDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entries).To(HaveLen(1), "Expected exactly one generated file")
|
||||
|
||||
actual, err := os.ReadFile(filepath.Join(outputDir, entries[0].Name()))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Format both for comparison
|
||||
formattedActual, err := format.Source(actual)
|
||||
Expect(err).ToNot(HaveOccurred(), "Generated code is not valid Go:\n%s", actual)
|
||||
|
||||
formattedExpected, err := format.Source([]byte(expectedCode))
|
||||
Expect(err).ToNot(HaveOccurred(), "Expected code is not valid Go")
|
||||
|
||||
Expect(string(formattedActual)).To(Equal(string(formattedExpected)))
|
||||
},
|
||||
|
||||
Entry("simple string params - no request type needed",
|
||||
"echo_service.go", "echo_expected.go"),
|
||||
|
||||
Entry("multiple simple params",
|
||||
"math_service.go", "math_expected.go"),
|
||||
|
||||
Entry("struct param with request type",
|
||||
"store_service.go", "store_expected.go"),
|
||||
|
||||
Entry("mixed simple and complex params",
|
||||
"list_service.go", "list_expected.go"),
|
||||
|
||||
Entry("method without error",
|
||||
"counter_service.go", "counter_expected.go"),
|
||||
|
||||
Entry("no params, error only",
|
||||
"ping_service.go", "ping_expected.go"),
|
||||
|
||||
Entry("map and interface types",
|
||||
"meta_service.go", "meta_expected.go"),
|
||||
|
||||
Entry("pointer types",
|
||||
"users_service.go", "users_expected.go"),
|
||||
|
||||
Entry("multiple returns",
|
||||
"search_service.go", "search_expected.go"),
|
||||
|
||||
Entry("bytes",
|
||||
"codec_service.go", "codec_expected.go"),
|
||||
)
|
||||
|
||||
It("generates compilable code for comprehensive service", func() {
|
||||
serviceCode := readTestdata("comprehensive_service.go")
|
||||
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||
|
||||
// Create go.mod
|
||||
goMod := "module testpkg\n\ngo 1.23\n\nrequire github.com/extism/go-sdk v1.7.1\n"
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
|
||||
|
||||
// Generate
|
||||
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", testDir, "-package", "testpkg")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
|
||||
|
||||
// Tidy dependencies
|
||||
goGetCmd := exec.Command("go", "mod", "tidy")
|
||||
goGetCmd.Dir = testDir
|
||||
goGetOutput, err := goGetCmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "go mod tidy failed: %s", goGetOutput)
|
||||
|
||||
// Build
|
||||
buildCmd := exec.Command("go", "build", ".")
|
||||
buildCmd.Dir = testDir
|
||||
buildOutput, err := buildCmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Build failed: %s", buildOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func mustGetWd(t FullGinkgoTInterface) string {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatal("could not find project root")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
359
plugins/cmd/hostgen/internal/generator.go
Normal file
359
plugins/cmd/hostgen/internal/generator.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// GenerateService generates the host function wrapper code for a service.
|
||||
func GenerateService(svc Service, pkgName string) ([]byte, error) {
|
||||
tmpl, err := template.New("service").Funcs(template.FuncMap{
|
||||
"lower": strings.ToLower,
|
||||
"title": strings.Title,
|
||||
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
|
||||
"requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
|
||||
"responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
|
||||
"valueType": GoTypeToValueType,
|
||||
"isSimple": IsSimpleType,
|
||||
"isString": IsStringType,
|
||||
"isBytes": IsBytesType,
|
||||
"needsJSON": NeedsJSON,
|
||||
"needsRequestType": func(m Method) bool { return m.NeedsRequestType() },
|
||||
"needsRespType": func(m Method) bool { return m.NeedsResponseType() },
|
||||
"hasErrFromRead": hasErrorFromRead,
|
||||
"readParam": generateReadParam,
|
||||
"writeReturn": generateWriteReturn,
|
||||
"encodeReturn": generateEncodeReturn,
|
||||
}).Parse(serviceTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
data := templateData{
|
||||
Package: pkgName,
|
||||
Service: svc,
|
||||
NeedsJSON: serviceNeedsJSON(svc),
|
||||
NeedsWriteHelper: serviceNeedsWriteHelper(svc),
|
||||
NeedsErrorHelper: serviceNeedsErrorHelper(svc),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("executing template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type templateData struct {
|
||||
Package string
|
||||
Service Service
|
||||
NeedsJSON bool
|
||||
NeedsWriteHelper bool
|
||||
NeedsErrorHelper bool
|
||||
}
|
||||
|
||||
// serviceNeedsJSON returns true if any method needs JSON encoding.
|
||||
func serviceNeedsJSON(svc Service) bool {
|
||||
for _, m := range svc.Methods {
|
||||
for _, p := range m.Params {
|
||||
if NeedsJSON(p.Type) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, r := range m.Returns {
|
||||
if NeedsJSON(r.Type) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Error responses are also JSON
|
||||
if m.HasError && m.NeedsResponseType() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// serviceNeedsWriteHelper returns true if any method needs the write helper.
|
||||
func serviceNeedsWriteHelper(svc Service) bool {
|
||||
for _, m := range svc.Methods {
|
||||
if m.NeedsResponseType() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// serviceNeedsErrorHelper returns true if any method needs error handling with JSON.
|
||||
func serviceNeedsErrorHelper(svc Service) bool {
|
||||
for _, m := range svc.Methods {
|
||||
if m.HasError && m.NeedsResponseType() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasErrorFromRead returns true if reading params declares an err variable.
|
||||
// This happens when using needsRequestType (JSON) or when any param is a PTR type.
|
||||
func hasErrorFromRead(m Method) bool {
|
||||
if m.NeedsRequestType() {
|
||||
return true
|
||||
}
|
||||
for _, p := range m.Params {
|
||||
if IsStringType(p.Type) || IsBytesType(p.Type) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// generateReadParam generates code to read a parameter from the stack.
|
||||
func generateReadParam(p Param, stackIndex int) string {
|
||||
switch {
|
||||
case IsSimpleType(p.Type):
|
||||
return generateReadSimple(p, stackIndex)
|
||||
case IsStringType(p.Type):
|
||||
return fmt.Sprintf(`%s, err := p.ReadString(stack[%d])
|
||||
if err != nil {
|
||||
return
|
||||
}`, p.Name, stackIndex)
|
||||
case IsBytesType(p.Type):
|
||||
return fmt.Sprintf(`%s, err := p.ReadBytes(stack[%d])
|
||||
if err != nil {
|
||||
return
|
||||
}`, p.Name, stackIndex)
|
||||
default:
|
||||
// Complex type - JSON
|
||||
return fmt.Sprintf(`%sBytes, err := p.ReadBytes(stack[%d])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var %s %s
|
||||
if err := json.Unmarshal(%sBytes, &%s); err != nil {
|
||||
return
|
||||
}`, p.Name, stackIndex, p.Name, p.Type, p.Name, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// generateReadSimple generates code to read a simple type from the stack.
|
||||
func generateReadSimple(p Param, stackIndex int) string {
|
||||
switch p.Type {
|
||||
case "int32":
|
||||
return fmt.Sprintf(`%s := extism.DecodeI32(stack[%d])`, p.Name, stackIndex)
|
||||
case "uint32":
|
||||
return fmt.Sprintf(`%s := extism.DecodeU32(stack[%d])`, p.Name, stackIndex)
|
||||
case "int64":
|
||||
return fmt.Sprintf(`%s := int64(stack[%d])`, p.Name, stackIndex)
|
||||
case "uint64":
|
||||
return fmt.Sprintf(`%s := stack[%d]`, p.Name, stackIndex)
|
||||
case "float32":
|
||||
return fmt.Sprintf(`%s := extism.DecodeF32(stack[%d])`, p.Name, stackIndex)
|
||||
case "float64":
|
||||
return fmt.Sprintf(`%s := extism.DecodeF64(stack[%d])`, p.Name, stackIndex)
|
||||
case "bool":
|
||||
return fmt.Sprintf(`%s := extism.DecodeI32(stack[%d]) != 0`, p.Name, stackIndex)
|
||||
default:
|
||||
return fmt.Sprintf(`// FIXME: unsupported type: %s`, p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// generateWriteReturn generates code to write a return value to the stack.
|
||||
func generateWriteReturn(p Param, stackIndex int, varName string) string {
|
||||
switch {
|
||||
case IsSimpleType(p.Type):
|
||||
return fmt.Sprintf(`stack[%d] = %s`, stackIndex, generateEncodeReturn(p, varName))
|
||||
case IsStringType(p.Type):
|
||||
return fmt.Sprintf(`if ptr, err := p.WriteString(%s); err == nil {
|
||||
stack[%d] = ptr
|
||||
}`, varName, stackIndex)
|
||||
case IsBytesType(p.Type):
|
||||
return fmt.Sprintf(`if ptr, err := p.WriteBytes(%s); err == nil {
|
||||
stack[%d] = ptr
|
||||
}`, varName, stackIndex)
|
||||
default:
|
||||
// Complex type - JSON
|
||||
return fmt.Sprintf(`if bytes, err := json.Marshal(%s); err == nil {
|
||||
if ptr, err := p.WriteBytes(bytes); err == nil {
|
||||
stack[%d] = ptr
|
||||
}
|
||||
}`, varName, stackIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// generateEncodeReturn generates the encoding expression for a simple return.
|
||||
func generateEncodeReturn(p Param, varName string) string {
|
||||
switch p.Type {
|
||||
case "int32":
|
||||
return fmt.Sprintf("extism.EncodeI32(%s)", varName)
|
||||
case "uint32":
|
||||
return fmt.Sprintf("extism.EncodeU32(%s)", varName)
|
||||
case "int64":
|
||||
return fmt.Sprintf("uint64(%s)", varName)
|
||||
case "uint64":
|
||||
return varName
|
||||
case "float32":
|
||||
return fmt.Sprintf("extism.EncodeF32(%s)", varName)
|
||||
case "float64":
|
||||
return fmt.Sprintf("extism.EncodeF64(%s)", varName)
|
||||
case "bool":
|
||||
return fmt.Sprintf("func() uint64 { if %s { return 1 }; return 0 }()", varName)
|
||||
default:
|
||||
return "0"
|
||||
}
|
||||
}
|
||||
|
||||
const serviceTemplate = `// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package {{.Package}}
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{- if .NeedsJSON}}
|
||||
"encoding/json"
|
||||
{{- end}}
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
{{- /* Generate request/response types only when needed */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if needsRequestType .}}
|
||||
|
||||
// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
|
||||
type {{requestType .}} struct {
|
||||
{{- range .Params}}
|
||||
{{title .Name}} {{.Type}} ` + "`" + `json:"{{.JSONName}}"` + "`" + `
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if needsRespType .}}
|
||||
|
||||
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
||||
type {{responseType .}} struct {
|
||||
{{- range .Returns}}
|
||||
{{title .Name}} {{.Type}} ` + "`" + `json:"{{.JSONName}},omitempty"` + "`" + `
|
||||
{{- end}}
|
||||
Error string ` + "`" + `json:"error,omitempty"` + "`" + `
|
||||
}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func Register{{.Service.Name}}HostFunctions(service {{.Service.Interface}}) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
{{- range .Service.Methods}}
|
||||
new{{$.Service.Name}}{{.Name}}HostFunction(service),
|
||||
{{- end}}
|
||||
}
|
||||
}
|
||||
{{range .Service.Methods}}
|
||||
|
||||
func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"{{exportName .}}",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
{{- if .HasParams}}
|
||||
{{- if needsRequestType .}}
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req {{requestType .}}
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
{{- else}}
|
||||
// Read parameters from stack
|
||||
{{- range $i, $p := .Params}}
|
||||
{{readParam $p $i}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
// Call the service method
|
||||
{{- $m := .}}
|
||||
{{- if .HasReturns}}
|
||||
{{- if .HasError}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, err := service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
|
||||
{{- else}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
|
||||
{{- end}}
|
||||
{{- else if .HasError}}
|
||||
err {{if hasErrFromRead .}}={{else}}:={{end}} service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
|
||||
{{- else}}
|
||||
service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
|
||||
{{- end}}
|
||||
{{- if .HasError}}
|
||||
if err != nil {
|
||||
{{- if needsRespType .}}
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||
{{- end}}
|
||||
return
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
{{- if needsRespType .}}
|
||||
// Write JSON response to plugin memory
|
||||
resp := {{responseType .}}{
|
||||
{{- range .Returns}}
|
||||
{{title .Name}}: {{lower .Name}},
|
||||
{{- end}}
|
||||
}
|
||||
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
|
||||
{{- else if .HasReturns}}
|
||||
// Write return values to stack
|
||||
{{- range $i, $r := .Returns}}
|
||||
{{writeReturn $r $i (lower $r.Name)}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
},
|
||||
{{- if needsRequestType $m}}
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
{{- else}}
|
||||
[]extism.ValueType{ {{- range $i, $p := .Params}}{{if $i}}, {{end}}{{valueType $p.Type}}{{end}}{{if not .HasParams}}{{end}} },
|
||||
{{- end}}
|
||||
{{- if needsRespType .}}
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
{{- else}}
|
||||
[]extism.ValueType{ {{- range $i, $r := .Returns}}{{if $i}}, {{end}}{{valueType $r.Type}}{{end}}{{if not .HasReturns}}{{end}} },
|
||||
{{- end}}
|
||||
)
|
||||
}
|
||||
{{end}}
|
||||
{{- if .NeedsWriteHelper}}
|
||||
|
||||
// {{.Service.Name | lower}}WriteResponse writes a JSON response to plugin memory.
|
||||
func {{.Service.Name | lower}}WriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
{{.Service.Name | lower}}WriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
{{- end}}
|
||||
{{- if .NeedsErrorHelper}}
|
||||
|
||||
// {{.Service.Name | lower}}WriteError writes an error response to plugin memory.
|
||||
func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string ` + "`" + `json:"error"` + "`" + `
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
{{- end}}
|
||||
`
|
||||
356
plugins/cmd/hostgen/internal/generator_test.go
Normal file
356
plugins/cmd/hostgen/internal/generator_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"go/format"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Generator", func() {
|
||||
Describe("GenerateService", func() {
|
||||
It("should generate valid Go code for a simple service with strings", func() {
|
||||
// String params/returns don't need JSON - they use direct memory read/write
|
||||
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 := GenerateService(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 hostgen. DO NOT EDIT."))
|
||||
|
||||
// Check for package declaration
|
||||
Expect(codeStr).To(ContainSubstring("package host"))
|
||||
|
||||
// String params don't need request type - read directly from memory
|
||||
Expect(codeStr).NotTo(ContainSubstring("type SubsonicAPICallRequest struct"))
|
||||
|
||||
// String return with error needs response type for 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 direct string read (not JSON unmarshal)
|
||||
Expect(codeStr).To(ContainSubstring("p.ReadString(stack[0])"))
|
||||
})
|
||||
|
||||
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 := GenerateService(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = format.Source(code)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
// Should not have request type for methods without params
|
||||
Expect(codeStr).NotTo(ContainSubstring("type TestNoParamsRequest struct"))
|
||||
})
|
||||
|
||||
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 := GenerateService(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 := GenerateService(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 without JSON", func() {
|
||||
// All simple params (string, int32, bool) can be passed on stack directly
|
||||
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 := GenerateService(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = format.Source(code)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
// No request type for simple params - they're read directly from stack
|
||||
Expect(codeStr).NotTo(ContainSubstring("type TestMultiParamRequest struct"))
|
||||
// Check for direct stack reads
|
||||
Expect(codeStr).To(ContainSubstring("p.ReadString(stack[0])"))
|
||||
Expect(codeStr).To(ContainSubstring("extism.DecodeI32(stack[1])"))
|
||||
Expect(codeStr).To(ContainSubstring("extism.DecodeI32(stack[2])"))
|
||||
// Check that input ValueType slice has correct entries (3 params: PTR for string, I32 for int32, I32 for bool)
|
||||
Expect(codeStr).To(ContainSubstring("extism.ValueTypePTR, extism.ValueTypeI32, extism.ValueTypeI32"))
|
||||
})
|
||||
|
||||
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 := GenerateService(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 := GenerateService(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 := GenerateService(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 not include json import when not needed", func() {
|
||||
// Service with only simple types doesn't need JSON import
|
||||
svc := Service{
|
||||
Name: "Test",
|
||||
Permission: "test",
|
||||
Interface: "TestService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "Method",
|
||||
Params: []Param{NewParam("count", "int32")},
|
||||
Returns: []Param{NewParam("result", "int64")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateService(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
Expect(codeStr).To(ContainSubstring(`"context"`))
|
||||
Expect(codeStr).NotTo(ContainSubstring(`"encoding/json"`))
|
||||
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("toJSONName", func() {
|
||||
It("should convert to camelCase", func() {
|
||||
Expect(toJSONName("InputValue")).To(Equal("inputValue"))
|
||||
Expect(toJSONName("URI")).To(Equal("uRI"))
|
||||
Expect(toJSONName("id")).To(Equal("id"))
|
||||
})
|
||||
|
||||
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("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 := GenerateService(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"`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
return os.WriteFile(path, []byte(content), 0600)
|
||||
}
|
||||
13
plugins/cmd/hostgen/internal/internal_suite_test.go
Normal file
13
plugins/cmd/hostgen/internal/internal_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestInternal(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Hostgen Internal Suite")
|
||||
}
|
||||
294
plugins/cmd/hostgen/internal/parser.go
Normal file
294
plugins/cmd/hostgen/internal/parser.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Annotation patterns
|
||||
var (
|
||||
// //nd:hostservice name=ServiceName permission=key
|
||||
hostServicePattern = regexp.MustCompile(`//nd:hostservice\s+(.*)`)
|
||||
// //nd:hostfunc [name=CustomName]
|
||||
hostFuncPattern = regexp.MustCompile(`//nd:hostfunc(?:\s+(.*))?`)
|
||||
// key=value pairs
|
||||
keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`)
|
||||
)
|
||||
|
||||
// ParseDirectory parses all Go source files in a directory and extracts host services.
|
||||
func ParseDirectory(dir string) ([]Service, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading directory: %w", err)
|
||||
}
|
||||
|
||||
var services []Service
|
||||
fset := token.NewFileSet()
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
// Skip generated files and test files
|
||||
if strings.HasSuffix(entry.Name(), "_gen.go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
parsed, err := parseFile(fset, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err)
|
||||
}
|
||||
services = append(services, parsed...)
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// parseFile parses a single Go source file and extracts host services.
|
||||
func parseFile(fset *token.FileSet, path string) ([]Service, error) {
|
||||
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var services []Service
|
||||
|
||||
for _, decl := range f.Decls {
|
||||
genDecl, ok := decl.(*ast.GenDecl)
|
||||
if !ok || genDecl.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, spec := range genDecl.Specs {
|
||||
typeSpec, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
interfaceType, ok := typeSpec.Type.(*ast.InterfaceType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for //nd:hostservice annotation in doc comment
|
||||
docText, rawDoc := getDocComment(genDecl, typeSpec)
|
||||
svcAnnotation := parseHostServiceAnnotation(rawDoc)
|
||||
if svcAnnotation == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
service := Service{
|
||||
Name: svcAnnotation["name"],
|
||||
Permission: svcAnnotation["permission"],
|
||||
Interface: typeSpec.Name.Name,
|
||||
Doc: cleanDoc(docText),
|
||||
}
|
||||
|
||||
// Parse methods
|
||||
for _, method := range interfaceType.Methods.List {
|
||||
if len(method.Names) == 0 {
|
||||
continue // Embedded interface
|
||||
}
|
||||
|
||||
funcType, ok := method.Type.(*ast.FuncType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for //nd:hostfunc annotation
|
||||
methodDocText, methodRawDoc := getMethodDocComment(method)
|
||||
methodAnnotation := parseHostFuncAnnotation(methodRawDoc)
|
||||
if methodAnnotation == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
m, err := parseMethod(method.Names[0].Name, funcType, methodAnnotation, cleanDoc(methodDocText))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing method %s.%s: %w", typeSpec.Name.Name, method.Names[0].Name, err)
|
||||
}
|
||||
service.Methods = append(service.Methods, m)
|
||||
}
|
||||
|
||||
if len(service.Methods) > 0 {
|
||||
services = append(services, service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// getDocComment extracts the doc comment for a type spec.
|
||||
// Returns both the readable doc text and the raw comment text (which includes pragma-style comments).
|
||||
func getDocComment(genDecl *ast.GenDecl, typeSpec *ast.TypeSpec) (docText, rawText string) {
|
||||
var docGroup *ast.CommentGroup
|
||||
// First check the TypeSpec's own doc (when multiple types in one block)
|
||||
if typeSpec.Doc != nil {
|
||||
docGroup = typeSpec.Doc
|
||||
} else if genDecl.Doc != nil {
|
||||
// Fall back to GenDecl doc (single type declaration)
|
||||
docGroup = genDecl.Doc
|
||||
}
|
||||
if docGroup == nil {
|
||||
return "", ""
|
||||
}
|
||||
return docGroup.Text(), commentGroupRaw(docGroup)
|
||||
}
|
||||
|
||||
// commentGroupRaw returns all comment text including pragma-style comments (//nd:...).
|
||||
// Go's ast.CommentGroup.Text() strips comments without a space after //, so we need this.
|
||||
func commentGroupRaw(cg *ast.CommentGroup) string {
|
||||
if cg == nil {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for _, c := range cg.List {
|
||||
lines = append(lines, c.Text)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// getMethodDocComment extracts the doc comment for a method.
|
||||
func getMethodDocComment(field *ast.Field) (docText, rawText string) {
|
||||
if field.Doc == nil {
|
||||
return "", ""
|
||||
}
|
||||
return field.Doc.Text(), commentGroupRaw(field.Doc)
|
||||
}
|
||||
|
||||
// parseHostServiceAnnotation extracts //nd:hostservice annotation parameters.
|
||||
func parseHostServiceAnnotation(doc string) map[string]string {
|
||||
for _, line := range strings.Split(doc, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
match := hostServicePattern.FindStringSubmatch(line)
|
||||
if match != nil {
|
||||
return parseKeyValuePairs(match[1])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseHostFuncAnnotation extracts //nd:hostfunc annotation parameters.
|
||||
func parseHostFuncAnnotation(doc string) map[string]string {
|
||||
for _, line := range strings.Split(doc, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
match := hostFuncPattern.FindStringSubmatch(line)
|
||||
if match != nil {
|
||||
params := parseKeyValuePairs(match[1])
|
||||
if params == nil {
|
||||
params = make(map[string]string)
|
||||
}
|
||||
return params
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseKeyValuePairs extracts key=value pairs from annotation text.
|
||||
func parseKeyValuePairs(text string) map[string]string {
|
||||
matches := keyValuePattern.FindAllStringSubmatch(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]string)
|
||||
for _, m := range matches {
|
||||
result[m[1]] = m[2]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseMethod parses a method signature into a Method struct.
|
||||
func parseMethod(name string, funcType *ast.FuncType, annotation map[string]string, doc string) (Method, error) {
|
||||
m := Method{
|
||||
Name: name,
|
||||
ExportName: annotation["name"],
|
||||
Doc: doc,
|
||||
}
|
||||
|
||||
// Parse parameters (skip context.Context)
|
||||
if funcType.Params != nil {
|
||||
for _, field := range funcType.Params.List {
|
||||
typeName := typeToString(field.Type)
|
||||
if typeName == "context.Context" {
|
||||
continue // Skip context parameter
|
||||
}
|
||||
|
||||
for _, name := range field.Names {
|
||||
m.Params = append(m.Params, NewParam(name.Name, typeName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse return values
|
||||
if funcType.Results != nil {
|
||||
for _, field := range funcType.Results.List {
|
||||
typeName := typeToString(field.Type)
|
||||
if typeName == "error" {
|
||||
m.HasError = true
|
||||
continue // Track error but don't include in Returns
|
||||
}
|
||||
|
||||
// Handle anonymous returns
|
||||
if len(field.Names) == 0 {
|
||||
// Generate a name based on position
|
||||
m.Returns = append(m.Returns, NewParam("result", typeName))
|
||||
} else {
|
||||
for _, name := range field.Names {
|
||||
m.Returns = append(m.Returns, NewParam(name.Name, typeName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// typeToString converts an AST type expression to a string.
|
||||
func typeToString(expr ast.Expr) string {
|
||||
switch t := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return t.Name
|
||||
case *ast.SelectorExpr:
|
||||
return typeToString(t.X) + "." + t.Sel.Name
|
||||
case *ast.StarExpr:
|
||||
return "*" + typeToString(t.X)
|
||||
case *ast.ArrayType:
|
||||
if t.Len == nil {
|
||||
return "[]" + typeToString(t.Elt)
|
||||
}
|
||||
return fmt.Sprintf("[%s]%s", typeToString(t.Len), typeToString(t.Elt))
|
||||
case *ast.MapType:
|
||||
return fmt.Sprintf("map[%s]%s", typeToString(t.Key), typeToString(t.Value))
|
||||
case *ast.BasicLit:
|
||||
return t.Value
|
||||
case *ast.InterfaceType:
|
||||
// Empty interface (interface{} or any)
|
||||
if t.Methods == nil || len(t.Methods.List) == 0 {
|
||||
return "any"
|
||||
}
|
||||
// Non-empty interfaces can't be easily represented
|
||||
return "any"
|
||||
default:
|
||||
return fmt.Sprintf("%T", expr)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanDoc removes annotation lines from documentation.
|
||||
func cleanDoc(doc string) string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(doc, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//nd:") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
292
plugins/cmd/hostgen/internal/parser_test.go
Normal file
292
plugins/cmd/hostgen/internal/parser_test.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Parser", func() {
|
||||
var tmpDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "hostgen-test-*")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
Describe("ParseDirectory", func() {
|
||||
It("should parse a simple host service interface", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
// SubsonicAPIService provides access to Navidrome's Subsonic API.
|
||||
//nd:hostservice name=SubsonicAPI permission=subsonicapi
|
||||
type SubsonicAPIService interface {
|
||||
// Call executes a Subsonic API request.
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (response string, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "service.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
|
||||
svc := services[0]
|
||||
Expect(svc.Name).To(Equal("SubsonicAPI"))
|
||||
Expect(svc.Permission).To(Equal("subsonicapi"))
|
||||
Expect(svc.Interface).To(Equal("SubsonicAPIService"))
|
||||
Expect(svc.Methods).To(HaveLen(1))
|
||||
|
||||
m := svc.Methods[0]
|
||||
Expect(m.Name).To(Equal("Call"))
|
||||
Expect(m.HasError).To(BeTrue())
|
||||
Expect(m.Params).To(HaveLen(1))
|
||||
Expect(m.Params[0].Name).To(Equal("uri"))
|
||||
Expect(m.Params[0].Type).To(Equal("string"))
|
||||
Expect(m.Returns).To(HaveLen(1))
|
||||
Expect(m.Returns[0].Name).To(Equal("response"))
|
||||
Expect(m.Returns[0].Type).To(Equal("string"))
|
||||
})
|
||||
|
||||
It("should parse multiple methods", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
// SchedulerService provides scheduling capabilities.
|
||||
//nd:hostservice name=Scheduler permission=scheduler
|
||||
type SchedulerService interface {
|
||||
//nd:hostfunc
|
||||
ScheduleRecurring(ctx context.Context, cronExpression string) (scheduleID string, err error)
|
||||
|
||||
//nd:hostfunc
|
||||
ScheduleOneTime(ctx context.Context, delaySeconds int32) (scheduleID string, err error)
|
||||
|
||||
//nd:hostfunc
|
||||
CancelSchedule(ctx context.Context, scheduleID string) (canceled bool, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "scheduler.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
|
||||
svc := services[0]
|
||||
Expect(svc.Name).To(Equal("Scheduler"))
|
||||
Expect(svc.Methods).To(HaveLen(3))
|
||||
|
||||
Expect(svc.Methods[0].Name).To(Equal("ScheduleRecurring"))
|
||||
Expect(svc.Methods[0].Params[0].Type).To(Equal("string"))
|
||||
|
||||
Expect(svc.Methods[1].Name).To(Equal("ScheduleOneTime"))
|
||||
Expect(svc.Methods[1].Params[0].Type).To(Equal("int32"))
|
||||
|
||||
Expect(svc.Methods[2].Name).To(Equal("CancelSchedule"))
|
||||
Expect(svc.Methods[2].Returns[0].Type).To(Equal("bool"))
|
||||
})
|
||||
|
||||
It("should skip methods without hostfunc annotation", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc
|
||||
Exported(ctx context.Context) error
|
||||
|
||||
// This method is not exported
|
||||
NotExported(ctx context.Context) error
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
Expect(services[0].Methods).To(HaveLen(1))
|
||||
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
|
||||
})
|
||||
|
||||
It("should handle custom export name", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc name=custom_export_name
|
||||
MyMethod(ctx context.Context) error
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services[0].Methods[0].ExportName).To(Equal("custom_export_name"))
|
||||
Expect(services[0].Methods[0].FunctionName("test")).To(Equal("custom_export_name"))
|
||||
})
|
||||
|
||||
It("should skip generated files", func() {
|
||||
regularSrc := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc
|
||||
Method(ctx context.Context) error
|
||||
}
|
||||
`
|
||||
genSrc := `// Code generated. DO NOT EDIT.
|
||||
package host
|
||||
|
||||
//nd:hostservice name=Generated permission=gen
|
||||
type GeneratedService interface {
|
||||
//nd:hostfunc
|
||||
Method() error
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(regularSrc), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
err = os.WriteFile(filepath.Join(tmpDir, "test_gen.go"), []byte(genSrc), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
Expect(services[0].Name).To(Equal("Test"))
|
||||
})
|
||||
|
||||
It("should skip interfaces without hostservice annotation", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
// Regular interface without annotation
|
||||
type RegularInterface interface {
|
||||
Method(ctx context.Context) error
|
||||
}
|
||||
|
||||
//nd:hostservice name=Annotated permission=annotated
|
||||
type AnnotatedService interface {
|
||||
//nd:hostfunc
|
||||
Method(ctx context.Context) error
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
Expect(services[0].Name).To(Equal("Annotated"))
|
||||
})
|
||||
|
||||
It("should return empty slice for directory with no host services", func() {
|
||||
src := `package host
|
||||
|
||||
type RegularInterface interface {
|
||||
Method() error
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("parseKeyValuePairs", func() {
|
||||
It("should parse key=value pairs", func() {
|
||||
result := parseKeyValuePairs("name=Test permission=test")
|
||||
Expect(result).To(HaveKeyWithValue("name", "Test"))
|
||||
Expect(result).To(HaveKeyWithValue("permission", "test"))
|
||||
})
|
||||
|
||||
It("should return nil for empty input", func() {
|
||||
result := parseKeyValuePairs("")
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("typeToString", func() {
|
||||
It("should handle basic types", func() {
|
||||
src := `package test
|
||||
type T interface {
|
||||
Method(s string, i int, b bool) ([]byte, error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "types.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Parse and verify type conversion works
|
||||
// This is implicitly tested through ParseDirectory
|
||||
})
|
||||
|
||||
It("should convert interface{} to any", func() {
|
||||
src := `package test
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc
|
||||
GetMetadata(ctx context.Context) (data map[string]interface{}, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
Expect(services[0].Methods[0].Returns[0].Type).To(Equal("map[string]any"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Method helpers", func() {
|
||||
It("should generate correct function names", func() {
|
||||
m := Method{Name: "Call"}
|
||||
Expect(m.FunctionName("subsonicapi")).To(Equal("subsonicapi_call"))
|
||||
|
||||
m.ExportName = "custom_name"
|
||||
Expect(m.FunctionName("subsonicapi")).To(Equal("custom_name"))
|
||||
})
|
||||
|
||||
It("should generate correct type names", func() {
|
||||
m := Method{Name: "Call"}
|
||||
Expect(m.RequestTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallRequest"))
|
||||
Expect(m.ResponseTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallResponse"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Service helpers", func() {
|
||||
It("should generate correct output file name", func() {
|
||||
s := Service{Name: "SubsonicAPI"}
|
||||
Expect(s.OutputFileName()).To(Equal("subsonicapi_gen.go"))
|
||||
})
|
||||
|
||||
It("should generate correct export prefix", func() {
|
||||
s := Service{Name: "SubsonicAPI"}
|
||||
Expect(s.ExportPrefix()).To(Equal("subsonicapi"))
|
||||
})
|
||||
})
|
||||
})
|
||||
203
plugins/cmd/hostgen/internal/types.go
Normal file
203
plugins/cmd/hostgen/internal/types.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Service represents a parsed host service interface.
|
||||
type Service struct {
|
||||
Name string // Service name from annotation (e.g., "SubsonicAPI")
|
||||
Permission string // Manifest permission key (e.g., "subsonicapi")
|
||||
Interface string // Go interface name (e.g., "SubsonicAPIService")
|
||||
Methods []Method // Methods marked with //nd:hostfunc
|
||||
Doc string // Documentation comment for the service
|
||||
}
|
||||
|
||||
// OutputFileName returns the generated file name for this service.
|
||||
func (s Service) OutputFileName() string {
|
||||
return strings.ToLower(s.Name) + "_gen.go"
|
||||
}
|
||||
|
||||
// ExportPrefix returns the prefix for exported host function names.
|
||||
func (s Service) ExportPrefix() string {
|
||||
return strings.ToLower(s.Name)
|
||||
}
|
||||
|
||||
// Method represents a host function method within a service.
|
||||
type Method struct {
|
||||
Name string // Go method name (e.g., "Call")
|
||||
ExportName string // Optional override for export name
|
||||
Params []Param // Method parameters (excluding context.Context)
|
||||
Returns []Param // Return values (excluding error)
|
||||
HasError bool // Whether the method returns an error
|
||||
Doc string // Documentation comment for the method
|
||||
}
|
||||
|
||||
// FunctionName returns the Extism host function export name.
|
||||
func (m Method) FunctionName(servicePrefix string) string {
|
||||
if m.ExportName != "" {
|
||||
return m.ExportName
|
||||
}
|
||||
return servicePrefix + "_" + strings.ToLower(m.Name)
|
||||
}
|
||||
|
||||
// RequestTypeName returns the generated request type name.
|
||||
func (m Method) RequestTypeName(serviceName string) string {
|
||||
return serviceName + m.Name + "Request"
|
||||
}
|
||||
|
||||
// ResponseTypeName returns the generated response type name.
|
||||
func (m Method) ResponseTypeName(serviceName string) string {
|
||||
return serviceName + m.Name + "Response"
|
||||
}
|
||||
|
||||
// HasParams returns true if the method has input parameters.
|
||||
func (m Method) HasParams() bool {
|
||||
return len(m.Params) > 0
|
||||
}
|
||||
|
||||
// HasReturns returns true if the method has return values (excluding error).
|
||||
func (m Method) HasReturns() bool {
|
||||
return len(m.Returns) > 0
|
||||
}
|
||||
|
||||
// Param represents a method parameter or return value.
|
||||
type Param struct {
|
||||
Name string // Parameter name
|
||||
Type string // Go type (e.g., "string", "int32", "[]byte")
|
||||
JSONName string // JSON field name (camelCase)
|
||||
}
|
||||
|
||||
// NewParam creates a Param with auto-generated JSON name.
|
||||
func NewParam(name, typ string) Param {
|
||||
return Param{
|
||||
Name: name,
|
||||
Type: typ,
|
||||
JSONName: toJSONName(name),
|
||||
}
|
||||
}
|
||||
|
||||
// IsSimple returns true if the type can be passed directly on the WASM stack
|
||||
// without JSON serialization (primitive numeric types).
|
||||
func (p Param) IsSimple() bool {
|
||||
return IsSimpleType(p.Type)
|
||||
}
|
||||
|
||||
// IsPTR returns true if the type should be passed via memory pointer
|
||||
// (strings, bytes, and complex types that need JSON serialization).
|
||||
func (p Param) IsPTR() bool {
|
||||
return !p.IsSimple()
|
||||
}
|
||||
|
||||
// ValueType returns the Extism ValueType constant name for this parameter.
|
||||
func (p Param) ValueType() string {
|
||||
return GoTypeToValueType(p.Type)
|
||||
}
|
||||
|
||||
// IsSimpleType returns true if a Go type can be passed directly on WASM stack.
|
||||
func IsSimpleType(typ string) bool {
|
||||
switch typ {
|
||||
case "int32", "uint32", "int64", "uint64", "float32", "float64", "bool":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsStringType returns true if the type is a string.
|
||||
func IsStringType(typ string) bool {
|
||||
return typ == "string"
|
||||
}
|
||||
|
||||
// IsBytesType returns true if the type is []byte.
|
||||
func IsBytesType(typ string) bool {
|
||||
return typ == "[]byte"
|
||||
}
|
||||
|
||||
// NeedsJSON returns true if the type requires JSON serialization.
|
||||
func NeedsJSON(typ string) bool {
|
||||
if IsSimpleType(typ) || IsStringType(typ) || IsBytesType(typ) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GoTypeToValueType returns the Extism ValueType constant for a Go type.
|
||||
func GoTypeToValueType(typ string) string {
|
||||
switch typ {
|
||||
case "int32", "uint32":
|
||||
return "extism.ValueTypeI32"
|
||||
case "int64", "uint64":
|
||||
return "extism.ValueTypeI64"
|
||||
case "float32":
|
||||
return "extism.ValueTypeF32"
|
||||
case "float64":
|
||||
return "extism.ValueTypeF64"
|
||||
case "bool":
|
||||
return "extism.ValueTypeI32" // bool as i32
|
||||
default:
|
||||
// strings, []byte, structs, maps, slices all use PTR (i64)
|
||||
return "extism.ValueTypePTR"
|
||||
}
|
||||
}
|
||||
|
||||
// AllParamsSimple returns true if all params can be passed on the stack.
|
||||
func (m Method) AllParamsSimple() bool {
|
||||
for _, p := range m.Params {
|
||||
if !p.IsSimple() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AllReturnsSimple returns true if all returns can be passed on the stack.
|
||||
func (m Method) AllReturnsSimple() bool {
|
||||
for _, r := range m.Returns {
|
||||
if !r.IsSimple() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NeedsRequestType returns true if a request struct is needed.
|
||||
// Only needed when we have complex params that require JSON.
|
||||
func (m Method) NeedsRequestType() bool {
|
||||
if !m.HasParams() {
|
||||
return false
|
||||
}
|
||||
for _, p := range m.Params {
|
||||
if NeedsJSON(p.Type) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NeedsResponseType returns true if a response struct is needed.
|
||||
// Needed when we have complex returns that require JSON, or error handling.
|
||||
func (m Method) NeedsResponseType() bool {
|
||||
// If there's an error, we need a response type to serialize it
|
||||
if m.HasError {
|
||||
// But only if there are also returns, or if returns need JSON
|
||||
if m.HasReturns() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, r := range m.Returns {
|
||||
if NeedsJSON(r.Type) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toJSONName converts a Go identifier to camelCase JSON field name.
|
||||
func toJSONName(name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
// Simple conversion: lowercase first letter
|
||||
return strings.ToLower(name[:1]) + name[1:]
|
||||
}
|
||||
116
plugins/cmd/hostgen/main.go
Normal file
116
plugins/cmd/hostgen/main.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// hostgen generates Extism host function wrappers from annotated Go interfaces.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// hostgen -input=./plugins/host -output=./plugins/host
|
||||
//
|
||||
// Flags:
|
||||
//
|
||||
// -input Input directory containing Go source files with annotated interfaces
|
||||
// -output Output directory for generated files (default: same as input)
|
||||
// -package Output package name (default: inferred from output directory)
|
||||
// -v Verbose output
|
||||
// -dry-run Preview generated code without writing files
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/cmd/hostgen/internal"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
inputDir = flag.String("input", ".", "Input directory containing Go source files")
|
||||
outputDir = flag.String("output", "", "Output directory for generated files (default: same as input)")
|
||||
pkgName = flag.String("package", "", "Output package name (default: inferred from output directory)")
|
||||
verbose = flag.Bool("v", false, "Verbose output")
|
||||
dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *outputDir == "" {
|
||||
*outputDir = *inputDir
|
||||
}
|
||||
|
||||
// Resolve absolute paths
|
||||
absInput, err := filepath.Abs(*inputDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error resolving input path: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
absOutput, err := filepath.Abs(*outputDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error resolving output path: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Infer package name if not provided
|
||||
if *pkgName == "" {
|
||||
*pkgName = filepath.Base(absOutput)
|
||||
}
|
||||
|
||||
if *verbose {
|
||||
fmt.Printf("Input directory: %s\n", absInput)
|
||||
fmt.Printf("Output directory: %s\n", absOutput)
|
||||
fmt.Printf("Package name: %s\n", *pkgName)
|
||||
}
|
||||
|
||||
// Parse source files
|
||||
services, err := internal.ParseDirectory(absInput)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing source files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
if *verbose {
|
||||
fmt.Println("No host services found")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if *verbose {
|
||||
fmt.Printf("Found %d host service(s)\n", len(services))
|
||||
for _, svc := range services {
|
||||
fmt.Printf(" - %s (%d methods)\n", svc.Name, len(svc.Methods))
|
||||
}
|
||||
}
|
||||
|
||||
// Generate code for each service
|
||||
for _, svc := range services {
|
||||
code, err := internal.GenerateService(svc, *pkgName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating code for %s: %v\n", svc.Name, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Format the generated code
|
||||
formatted, err := format.Source(code)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error formatting generated code for %s: %v\n", svc.Name, err)
|
||||
fmt.Fprintf(os.Stderr, "Raw code:\n%s\n", code)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
outputFile := filepath.Join(absOutput, svc.OutputFileName())
|
||||
|
||||
if *dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", outputFile, formatted)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputFile, formatted, 0600); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", outputFile, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *verbose {
|
||||
fmt.Printf("Generated %s\n", outputFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
plugins/cmd/hostgen/testdata/codec_expected.go
vendored
Normal file
76
plugins/cmd/hostgen/testdata/codec_expected.go
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// CodecEncodeResponse is the response type for Codec.Encode.
|
||||
type CodecEncodeResponse struct {
|
||||
Result []byte `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterCodecHostFunctions registers Codec service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterCodecHostFunctions(service CodecService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newCodecEncodeHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newCodecEncodeHostFunction(service CodecService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"codec_encode",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read parameters from stack
|
||||
data, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
result, err := service.Encode(ctx, data)
|
||||
if err != nil {
|
||||
codecWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
// Write JSON response to plugin memory
|
||||
resp := CodecEncodeResponse{
|
||||
Result: result,
|
||||
}
|
||||
codecWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// codecWriteResponse writes a JSON response to plugin memory.
|
||||
func codecWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
codecWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// codecWriteError writes an error response to plugin memory.
|
||||
func codecWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
9
plugins/cmd/hostgen/testdata/codec_service.go
vendored
Normal file
9
plugins/cmd/hostgen/testdata/codec_service.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Codec permission=codec
|
||||
type CodecService interface {
|
||||
//nd:hostfunc
|
||||
Encode(ctx context.Context, data []byte) ([]byte, error)
|
||||
}
|
||||
36
plugins/cmd/hostgen/testdata/comprehensive_service.go
vendored
Normal file
36
plugins/cmd/hostgen/testdata/comprehensive_service.go
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
type User2 struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type Filter2 struct {
|
||||
Active bool
|
||||
}
|
||||
|
||||
//nd:hostservice name=Comprehensive permission=comprehensive
|
||||
type ComprehensiveService interface {
|
||||
//nd:hostfunc
|
||||
SimpleParams(ctx context.Context, name string, count int32) (string, error)
|
||||
//nd:hostfunc
|
||||
StructParam(ctx context.Context, user User2) error
|
||||
//nd:hostfunc
|
||||
MixedParams(ctx context.Context, id string, filter Filter2) (int32, error)
|
||||
//nd:hostfunc
|
||||
NoError(ctx context.Context, name string) string
|
||||
//nd:hostfunc
|
||||
NoParams(ctx context.Context) error
|
||||
//nd:hostfunc
|
||||
NoParamsNoReturns(ctx context.Context)
|
||||
//nd:hostfunc
|
||||
PointerParams(ctx context.Context, id *string, user *User2) (*User2, error)
|
||||
//nd:hostfunc
|
||||
MapParams(ctx context.Context, data map[string]any) (interface{}, error)
|
||||
//nd:hostfunc
|
||||
MultipleReturns(ctx context.Context, query string) (results []User2, total int32, err error)
|
||||
//nd:hostfunc
|
||||
ByteSlice(ctx context.Context, data []byte) ([]byte, error)
|
||||
}
|
||||
37
plugins/cmd/hostgen/testdata/counter_expected.go
vendored
Normal file
37
plugins/cmd/hostgen/testdata/counter_expected.go
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// RegisterCounterHostFunctions registers Counter service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterCounterHostFunctions(service CounterService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newCounterCountHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newCounterCountHostFunction(service CounterService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"counter_count",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read parameters from stack
|
||||
name, err := p.ReadString(stack[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
value := service.Count(ctx, name)
|
||||
// Write return values to stack
|
||||
stack[0] = extism.EncodeI32(value)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypeI32},
|
||||
)
|
||||
}
|
||||
9
plugins/cmd/hostgen/testdata/counter_service.go
vendored
Normal file
9
plugins/cmd/hostgen/testdata/counter_service.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Counter permission=counter
|
||||
type CounterService interface {
|
||||
//nd:hostfunc
|
||||
Count(ctx context.Context, name string) (value int32)
|
||||
}
|
||||
76
plugins/cmd/hostgen/testdata/echo_expected.go
vendored
Normal file
76
plugins/cmd/hostgen/testdata/echo_expected.go
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// EchoEchoResponse is the response type for Echo.Echo.
|
||||
type EchoEchoResponse struct {
|
||||
Reply string `json:"reply,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterEchoHostFunctions registers Echo service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterEchoHostFunctions(service EchoService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newEchoEchoHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newEchoEchoHostFunction(service EchoService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"echo_echo",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read parameters from stack
|
||||
message, err := p.ReadString(stack[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
reply, err := service.Echo(ctx, message)
|
||||
if err != nil {
|
||||
echoWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
// Write JSON response to plugin memory
|
||||
resp := EchoEchoResponse{
|
||||
Reply: reply,
|
||||
}
|
||||
echoWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// echoWriteResponse writes a JSON response to plugin memory.
|
||||
func echoWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
echoWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// echoWriteError writes an error response to plugin memory.
|
||||
func echoWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
9
plugins/cmd/hostgen/testdata/echo_service.go
vendored
Normal file
9
plugins/cmd/hostgen/testdata/echo_service.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Echo permission=echo
|
||||
type EchoService interface {
|
||||
//nd:hostfunc
|
||||
Echo(ctx context.Context, message string) (reply string, err error)
|
||||
}
|
||||
88
plugins/cmd/hostgen/testdata/list_expected.go
vendored
Normal file
88
plugins/cmd/hostgen/testdata/list_expected.go
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// ListItemsRequest is the request type for List.Items.
|
||||
type ListItemsRequest struct {
|
||||
Name string `json:"name"`
|
||||
Filter Filter `json:"filter"`
|
||||
}
|
||||
|
||||
// ListItemsResponse is the response type for List.Items.
|
||||
type ListItemsResponse struct {
|
||||
Count int32 `json:"count,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterListHostFunctions registers List service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterListHostFunctions(service ListService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newListItemsHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newListItemsHostFunction(service ListService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"list_items",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
listWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req ListItemsRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
listWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
count, err := service.Items(ctx, req.Name, req.Filter)
|
||||
if err != nil {
|
||||
listWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
// Write JSON response to plugin memory
|
||||
resp := ListItemsResponse{
|
||||
Count: count,
|
||||
}
|
||||
listWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// listWriteResponse writes a JSON response to plugin memory.
|
||||
func listWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
listWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// listWriteError writes an error response to plugin memory.
|
||||
func listWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
13
plugins/cmd/hostgen/testdata/list_service.go
vendored
Normal file
13
plugins/cmd/hostgen/testdata/list_service.go
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
type Filter struct {
|
||||
Active bool
|
||||
}
|
||||
|
||||
//nd:hostservice name=List permission=list
|
||||
type ListService interface {
|
||||
//nd:hostfunc
|
||||
Items(ctx context.Context, name string, filter Filter) (count int32, err error)
|
||||
}
|
||||
74
plugins/cmd/hostgen/testdata/math_expected.go
vendored
Normal file
74
plugins/cmd/hostgen/testdata/math_expected.go
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// MathAddResponse is the response type for Math.Add.
|
||||
type MathAddResponse struct {
|
||||
Result int32 `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterMathHostFunctions registers Math service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterMathHostFunctions(service MathService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newMathAddHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newMathAddHostFunction(service MathService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"math_add",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read parameters from stack
|
||||
a := extism.DecodeI32(stack[0])
|
||||
b := extism.DecodeI32(stack[1])
|
||||
|
||||
// Call the service method
|
||||
result, err := service.Add(ctx, a, b)
|
||||
if err != nil {
|
||||
mathWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
// Write JSON response to plugin memory
|
||||
resp := MathAddResponse{
|
||||
Result: result,
|
||||
}
|
||||
mathWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypeI32, extism.ValueTypeI32},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// mathWriteResponse writes a JSON response to plugin memory.
|
||||
func mathWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
mathWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// mathWriteError writes an error response to plugin memory.
|
||||
func mathWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
9
plugins/cmd/hostgen/testdata/math_service.go
vendored
Normal file
9
plugins/cmd/hostgen/testdata/math_service.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Math permission=math
|
||||
type MathService interface {
|
||||
//nd:hostfunc
|
||||
Add(ctx context.Context, a int32, b int32) (result int32, err error)
|
||||
}
|
||||
109
plugins/cmd/hostgen/testdata/meta_expected.go
vendored
Normal file
109
plugins/cmd/hostgen/testdata/meta_expected.go
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// MetaGetResponse is the response type for Meta.Get.
|
||||
type MetaGetResponse struct {
|
||||
Value any `json:"value,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// MetaSetRequest is the request type for Meta.Set.
|
||||
type MetaSetRequest struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// RegisterMetaHostFunctions registers Meta service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterMetaHostFunctions(service MetaService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newMetaGetHostFunction(service),
|
||||
newMetaSetHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newMetaGetHostFunction(service MetaService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"meta_get",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read parameters from stack
|
||||
key, err := p.ReadString(stack[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
value, err := service.Get(ctx, key)
|
||||
if err != nil {
|
||||
metaWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
// Write JSON response to plugin memory
|
||||
resp := MetaGetResponse{
|
||||
Value: value,
|
||||
}
|
||||
metaWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
func newMetaSetHostFunction(service MetaService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"meta_set",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
metaWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req MetaSetRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
metaWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
err = service.Set(ctx, req.Data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{},
|
||||
)
|
||||
}
|
||||
|
||||
// metaWriteResponse writes a JSON response to plugin memory.
|
||||
func metaWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
metaWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// metaWriteError writes an error response to plugin memory.
|
||||
func metaWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
11
plugins/cmd/hostgen/testdata/meta_service.go
vendored
Normal file
11
plugins/cmd/hostgen/testdata/meta_service.go
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Meta permission=meta
|
||||
type MetaService interface {
|
||||
//nd:hostfunc
|
||||
Get(ctx context.Context, key string) (value interface{}, err error)
|
||||
//nd:hostfunc
|
||||
Set(ctx context.Context, data map[string]any) error
|
||||
}
|
||||
33
plugins/cmd/hostgen/testdata/ping_expected.go
vendored
Normal file
33
plugins/cmd/hostgen/testdata/ping_expected.go
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// RegisterPingHostFunctions registers Ping service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterPingHostFunctions(service PingService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newPingPingHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newPingPingHostFunction(service PingService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"ping_ping",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
|
||||
// Call the service method
|
||||
err := service.Ping(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
},
|
||||
[]extism.ValueType{},
|
||||
[]extism.ValueType{},
|
||||
)
|
||||
}
|
||||
9
plugins/cmd/hostgen/testdata/ping_service.go
vendored
Normal file
9
plugins/cmd/hostgen/testdata/ping_service.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Ping permission=ping
|
||||
type PingService interface {
|
||||
//nd:hostfunc
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
78
plugins/cmd/hostgen/testdata/search_expected.go
vendored
Normal file
78
plugins/cmd/hostgen/testdata/search_expected.go
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// SearchFindResponse is the response type for Search.Find.
|
||||
type SearchFindResponse struct {
|
||||
Results []Result `json:"results,omitempty"`
|
||||
Total int32 `json:"total,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterSearchHostFunctions registers Search service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterSearchHostFunctions(service SearchService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newSearchFindHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newSearchFindHostFunction(service SearchService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"search_find",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read parameters from stack
|
||||
query, err := p.ReadString(stack[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
results, total, err := service.Find(ctx, query)
|
||||
if err != nil {
|
||||
searchWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
// Write JSON response to plugin memory
|
||||
resp := SearchFindResponse{
|
||||
Results: results,
|
||||
Total: total,
|
||||
}
|
||||
searchWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// searchWriteResponse writes a JSON response to plugin memory.
|
||||
func searchWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
searchWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// searchWriteError writes an error response to plugin memory.
|
||||
func searchWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
13
plugins/cmd/hostgen/testdata/search_service.go
vendored
Normal file
13
plugins/cmd/hostgen/testdata/search_service.go
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
type Result struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
//nd:hostservice name=Search permission=search
|
||||
type SearchService interface {
|
||||
//nd:hostfunc
|
||||
Find(ctx context.Context, query string) (results []Result, total int32, err error)
|
||||
}
|
||||
87
plugins/cmd/hostgen/testdata/store_expected.go
vendored
Normal file
87
plugins/cmd/hostgen/testdata/store_expected.go
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// StoreSaveRequest is the request type for Store.Save.
|
||||
type StoreSaveRequest struct {
|
||||
Item Item `json:"item"`
|
||||
}
|
||||
|
||||
// StoreSaveResponse is the response type for Store.Save.
|
||||
type StoreSaveResponse struct {
|
||||
Id string `json:"id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterStoreHostFunctions registers Store service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterStoreHostFunctions(service StoreService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newStoreSaveHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newStoreSaveHostFunction(service StoreService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"store_save",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
storeWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req StoreSaveRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
storeWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
id, err := service.Save(ctx, req.Item)
|
||||
if err != nil {
|
||||
storeWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
// Write JSON response to plugin memory
|
||||
resp := StoreSaveResponse{
|
||||
Id: id,
|
||||
}
|
||||
storeWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// storeWriteResponse writes a JSON response to plugin memory.
|
||||
func storeWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
storeWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// storeWriteError writes an error response to plugin memory.
|
||||
func storeWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
14
plugins/cmd/hostgen/testdata/store_service.go
vendored
Normal file
14
plugins/cmd/hostgen/testdata/store_service.go
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
type Item struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
//nd:hostservice name=Store permission=store
|
||||
type StoreService interface {
|
||||
//nd:hostfunc
|
||||
Save(ctx context.Context, item Item) (id string, err error)
|
||||
}
|
||||
88
plugins/cmd/hostgen/testdata/users_expected.go
vendored
Normal file
88
plugins/cmd/hostgen/testdata/users_expected.go
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// UsersGetRequest is the request type for Users.Get.
|
||||
type UsersGetRequest struct {
|
||||
Id *string `json:"id"`
|
||||
Filter *User `json:"filter"`
|
||||
}
|
||||
|
||||
// UsersGetResponse is the response type for Users.Get.
|
||||
type UsersGetResponse struct {
|
||||
Result *User `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterUsersHostFunctions registers Users service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterUsersHostFunctions(service UsersService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newUsersGetHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newUsersGetHostFunction(service UsersService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"users_get",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
usersWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req UsersGetRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
usersWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
result, err := service.Get(ctx, req.Id, req.Filter)
|
||||
if err != nil {
|
||||
usersWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
// Write JSON response to plugin memory
|
||||
resp := UsersGetResponse{
|
||||
Result: result,
|
||||
}
|
||||
usersWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// usersWriteResponse writes a JSON response to plugin memory.
|
||||
func usersWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
usersWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// usersWriteError writes an error response to plugin memory.
|
||||
func usersWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
14
plugins/cmd/hostgen/testdata/users_service.go
vendored
Normal file
14
plugins/cmd/hostgen/testdata/users_service.go
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
//nd:hostservice name=Users permission=users
|
||||
type UsersService interface {
|
||||
//nd:hostfunc
|
||||
Get(ctx context.Context, id *string, filter *User) (*User, error)
|
||||
}
|
||||
41
plugins/host/doc.go
Normal file
41
plugins/host/doc.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Package host provides host services that can be called by plugins via Extism host functions.
|
||||
//
|
||||
// Host services allow plugins to access Navidrome functionality like the Subsonic API,
|
||||
// scheduler, and other internal services. Services are defined as Go interfaces with
|
||||
// special annotations that enable automatic code generation of Extism host function wrappers.
|
||||
//
|
||||
// # Annotation Format
|
||||
//
|
||||
// Host services use Go doc comment annotations to mark interfaces and methods for code generation:
|
||||
//
|
||||
// // MyService provides some functionality.
|
||||
// //nd:hostservice name=MyService permission=myservice
|
||||
// type MyService interface {
|
||||
// // DoSomething performs an action.
|
||||
// //nd:hostfunc
|
||||
// DoSomething(ctx context.Context, input string) (output string, err error)
|
||||
// }
|
||||
//
|
||||
// Service-level annotations:
|
||||
// - //nd:hostservice - Marks an interface as a host service
|
||||
// - name=<ServiceName> - Service identifier used in generated code
|
||||
// - permission=<key> - Manifest permission key (e.g., "subsonicapi", "scheduler")
|
||||
//
|
||||
// Method-level annotations:
|
||||
// - //nd:hostfunc - Marks a method for host function wrapper generation
|
||||
// - name=<CustomName> - Optional: override the export name
|
||||
//
|
||||
// # Generated Code
|
||||
//
|
||||
// The hostgen tool reads annotated interfaces and generates Extism host function wrappers
|
||||
// that handle:
|
||||
// - JSON serialization/deserialization of request/response types
|
||||
// - Memory operations (ReadBytes, WriteBytes, Alloc)
|
||||
// - Error handling and propagation
|
||||
// - Service registration functions
|
||||
//
|
||||
// Generated files follow the pattern <servicename>_gen.go and include a header comment
|
||||
// indicating they should not be edited manually.
|
||||
//
|
||||
//go:generate go run ../cmd/hostgen -input=. -output=.
|
||||
package host
|
||||
18
plugins/host/subsonicapi.go
Normal file
18
plugins/host/subsonicapi.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package host
|
||||
|
||||
import "context"
|
||||
|
||||
// SubsonicAPIService provides access to Navidrome's Subsonic API from plugins.
|
||||
//
|
||||
// This service allows plugins to make Subsonic API requests on behalf of the plugin's user,
|
||||
// enabling access to library data, user preferences, and other Subsonic-compatible operations.
|
||||
//
|
||||
//nd:hostservice name=SubsonicAPI permission=subsonicapi
|
||||
type SubsonicAPIService interface {
|
||||
// Call executes a Subsonic API request and returns the JSON response.
|
||||
//
|
||||
// The uri parameter should be the Subsonic API path without the server prefix,
|
||||
// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (response []byte, err error)
|
||||
}
|
||||
76
plugins/host/subsonicapi_gen.go
Normal file
76
plugins/host/subsonicapi_gen.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// SubsonicAPICallResponse is the response type for SubsonicAPI.Call.
|
||||
type SubsonicAPICallResponse struct {
|
||||
Response []byte `json:"response,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newSubsonicAPICallHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newSubsonicAPICallHostFunction(service SubsonicAPIService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"subsonicapi_call",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read parameters from stack
|
||||
uri, err := p.ReadString(stack[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
response, err := service.Call(ctx, uri)
|
||||
if err != nil {
|
||||
subsonicapiWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
// Write JSON response to plugin memory
|
||||
resp := SubsonicAPICallResponse{
|
||||
Response: response,
|
||||
}
|
||||
subsonicapiWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// subsonicapiWriteResponse writes a JSON response to plugin memory.
|
||||
func subsonicapiWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
subsonicapiWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// subsonicapiWriteError writes an error response to plugin memory.
|
||||
func subsonicapiWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
Reference in New Issue
Block a user