diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 5d0dd3a66..7d4fbc0f4 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -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 diff --git a/Makefile b/Makefile index c2913a77c..32c36dadf 100644 --- a/Makefile +++ b/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 diff --git a/plugins/README.md b/plugins/README.md index 015e9323f..a209ef17e 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -342,6 +342,118 @@ err := manager.ReloadPlugin("my-plugin") - **Config changes**: Plugin configuration (`PluginConfig.`) 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=` - Service identifier used in generated code +- `permission=` - Manifest permission key (e.g., "subsonicapi", "scheduler") + +#### Method-level (`//nd:hostfunc`) + +Marks a method for host function wrapper generation: +- `name=` - (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 `_gen.go` with: +- Request/response structs for each method +- `RegisterHostFunctions()` - 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: diff --git a/plugins/cmd/hostgen/README.md b/plugins/cmd/hostgen/README.md new file mode 100644 index 000000000..1d47b6860 --- /dev/null +++ b/plugins/cmd/hostgen/README.md @@ -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 -output -package [-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= 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=] +MethodName(ctx context.Context, ...) (result Type, err error) +``` + +| Parameter | Description | Required | +|-----------|-------------------------------------------------------------------------|----------| +| `name` | Custom export name (default: `_` 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 `_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 diff --git a/plugins/cmd/hostgen/hostgen_suite_test.go b/plugins/cmd/hostgen/hostgen_suite_test.go new file mode 100644 index 000000000..a5ab094cd --- /dev/null +++ b/plugins/cmd/hostgen/hostgen_suite_test.go @@ -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") +} diff --git a/plugins/cmd/hostgen/integration_test.go b/plugins/cmd/hostgen/integration_test.go new file mode 100644 index 000000000..6f7ef64e3 --- /dev/null +++ b/plugins/cmd/hostgen/integration_test.go @@ -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 + } +} diff --git a/plugins/cmd/hostgen/internal/generator.go b/plugins/cmd/hostgen/internal/generator.go new file mode 100644 index 000000000..539f5af96 --- /dev/null +++ b/plugins/cmd/hostgen/internal/generator.go @@ -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}} +` diff --git a/plugins/cmd/hostgen/internal/generator_test.go b/plugins/cmd/hostgen/internal/generator_test.go new file mode 100644 index 000000000..d3934fec9 --- /dev/null +++ b/plugins/cmd/hostgen/internal/generator_test.go @@ -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) +} diff --git a/plugins/cmd/hostgen/internal/internal_suite_test.go b/plugins/cmd/hostgen/internal/internal_suite_test.go new file mode 100644 index 000000000..2e25efaef --- /dev/null +++ b/plugins/cmd/hostgen/internal/internal_suite_test.go @@ -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") +} diff --git a/plugins/cmd/hostgen/internal/parser.go b/plugins/cmd/hostgen/internal/parser.go new file mode 100644 index 000000000..346ca6990 --- /dev/null +++ b/plugins/cmd/hostgen/internal/parser.go @@ -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")) +} diff --git a/plugins/cmd/hostgen/internal/parser_test.go b/plugins/cmd/hostgen/internal/parser_test.go new file mode 100644 index 000000000..98c2cfb58 --- /dev/null +++ b/plugins/cmd/hostgen/internal/parser_test.go @@ -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")) + }) + }) +}) diff --git a/plugins/cmd/hostgen/internal/types.go b/plugins/cmd/hostgen/internal/types.go new file mode 100644 index 000000000..d0eec0470 --- /dev/null +++ b/plugins/cmd/hostgen/internal/types.go @@ -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:] +} diff --git a/plugins/cmd/hostgen/main.go b/plugins/cmd/hostgen/main.go new file mode 100644 index 000000000..80dec3005 --- /dev/null +++ b/plugins/cmd/hostgen/main.go @@ -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) + } + } +} diff --git a/plugins/cmd/hostgen/testdata/codec_expected.go b/plugins/cmd/hostgen/testdata/codec_expected.go new file mode 100644 index 000000000..a66ea0b15 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/codec_expected.go @@ -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 +} diff --git a/plugins/cmd/hostgen/testdata/codec_service.go b/plugins/cmd/hostgen/testdata/codec_service.go new file mode 100644 index 000000000..94a1b71db --- /dev/null +++ b/plugins/cmd/hostgen/testdata/codec_service.go @@ -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) +} diff --git a/plugins/cmd/hostgen/testdata/comprehensive_service.go b/plugins/cmd/hostgen/testdata/comprehensive_service.go new file mode 100644 index 000000000..3a9a1bfc4 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/comprehensive_service.go @@ -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) +} diff --git a/plugins/cmd/hostgen/testdata/counter_expected.go b/plugins/cmd/hostgen/testdata/counter_expected.go new file mode 100644 index 000000000..11fdf2090 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/counter_expected.go @@ -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}, + ) +} diff --git a/plugins/cmd/hostgen/testdata/counter_service.go b/plugins/cmd/hostgen/testdata/counter_service.go new file mode 100644 index 000000000..456599031 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/counter_service.go @@ -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) +} diff --git a/plugins/cmd/hostgen/testdata/echo_expected.go b/plugins/cmd/hostgen/testdata/echo_expected.go new file mode 100644 index 000000000..4d2927a0c --- /dev/null +++ b/plugins/cmd/hostgen/testdata/echo_expected.go @@ -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 +} diff --git a/plugins/cmd/hostgen/testdata/echo_service.go b/plugins/cmd/hostgen/testdata/echo_service.go new file mode 100644 index 000000000..42a1e9572 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/echo_service.go @@ -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) +} diff --git a/plugins/cmd/hostgen/testdata/list_expected.go b/plugins/cmd/hostgen/testdata/list_expected.go new file mode 100644 index 000000000..3cf67cb1a --- /dev/null +++ b/plugins/cmd/hostgen/testdata/list_expected.go @@ -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 +} diff --git a/plugins/cmd/hostgen/testdata/list_service.go b/plugins/cmd/hostgen/testdata/list_service.go new file mode 100644 index 000000000..ff3a42e01 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/list_service.go @@ -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) +} diff --git a/plugins/cmd/hostgen/testdata/math_expected.go b/plugins/cmd/hostgen/testdata/math_expected.go new file mode 100644 index 000000000..d4f58e035 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/math_expected.go @@ -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 +} diff --git a/plugins/cmd/hostgen/testdata/math_service.go b/plugins/cmd/hostgen/testdata/math_service.go new file mode 100644 index 000000000..66776b1da --- /dev/null +++ b/plugins/cmd/hostgen/testdata/math_service.go @@ -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) +} diff --git a/plugins/cmd/hostgen/testdata/meta_expected.go b/plugins/cmd/hostgen/testdata/meta_expected.go new file mode 100644 index 000000000..797a56b4b --- /dev/null +++ b/plugins/cmd/hostgen/testdata/meta_expected.go @@ -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 +} diff --git a/plugins/cmd/hostgen/testdata/meta_service.go b/plugins/cmd/hostgen/testdata/meta_service.go new file mode 100644 index 000000000..a7b23ecea --- /dev/null +++ b/plugins/cmd/hostgen/testdata/meta_service.go @@ -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 +} diff --git a/plugins/cmd/hostgen/testdata/ping_expected.go b/plugins/cmd/hostgen/testdata/ping_expected.go new file mode 100644 index 000000000..ff3ce8d50 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/ping_expected.go @@ -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{}, + ) +} diff --git a/plugins/cmd/hostgen/testdata/ping_service.go b/plugins/cmd/hostgen/testdata/ping_service.go new file mode 100644 index 000000000..c6bd1f489 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/ping_service.go @@ -0,0 +1,9 @@ +package testpkg + +import "context" + +//nd:hostservice name=Ping permission=ping +type PingService interface { + //nd:hostfunc + Ping(ctx context.Context) error +} diff --git a/plugins/cmd/hostgen/testdata/search_expected.go b/plugins/cmd/hostgen/testdata/search_expected.go new file mode 100644 index 000000000..c0772f513 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/search_expected.go @@ -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 +} diff --git a/plugins/cmd/hostgen/testdata/search_service.go b/plugins/cmd/hostgen/testdata/search_service.go new file mode 100644 index 000000000..03a081966 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/search_service.go @@ -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) +} diff --git a/plugins/cmd/hostgen/testdata/store_expected.go b/plugins/cmd/hostgen/testdata/store_expected.go new file mode 100644 index 000000000..426096968 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/store_expected.go @@ -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 +} diff --git a/plugins/cmd/hostgen/testdata/store_service.go b/plugins/cmd/hostgen/testdata/store_service.go new file mode 100644 index 000000000..c2ff69740 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/store_service.go @@ -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) +} diff --git a/plugins/cmd/hostgen/testdata/users_expected.go b/plugins/cmd/hostgen/testdata/users_expected.go new file mode 100644 index 000000000..f1f7e21d8 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/users_expected.go @@ -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 +} diff --git a/plugins/cmd/hostgen/testdata/users_service.go b/plugins/cmd/hostgen/testdata/users_service.go new file mode 100644 index 000000000..cb8db8c12 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/users_service.go @@ -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) +} diff --git a/plugins/host/doc.go b/plugins/host/doc.go new file mode 100644 index 000000000..514a6e9a7 --- /dev/null +++ b/plugins/host/doc.go @@ -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= - Service identifier used in generated code +// - permission= - Manifest permission key (e.g., "subsonicapi", "scheduler") +// +// Method-level annotations: +// - //nd:hostfunc - Marks a method for host function wrapper generation +// - name= - 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 _gen.go and include a header comment +// indicating they should not be edited manually. +// +//go:generate go run ../cmd/hostgen -input=. -output=. +package host diff --git a/plugins/host/subsonicapi.go b/plugins/host/subsonicapi.go new file mode 100644 index 000000000..c0ba41853 --- /dev/null +++ b/plugins/host/subsonicapi.go @@ -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) +} diff --git a/plugins/host/subsonicapi_gen.go b/plugins/host/subsonicapi_gen.go new file mode 100644 index 000000000..c5bdf22a0 --- /dev/null +++ b/plugins/host/subsonicapi_gen.go @@ -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 +}