diff --git a/go.mod b/go.mod index 8b5fdaadd..c5b4191d0 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/djherbis/stream v1.4.0 github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 + github.com/extism/go-pdk v1.1.3 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 github.com/go-chi/chi/v5 v5.2.3 diff --git a/go.sum b/go.sum index d138806ef..8af463fbf 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= diff --git a/plugins/cmd/hostgen/README.md b/plugins/cmd/hostgen/README.md index 1d47b6860..240deda5d 100644 --- a/plugins/cmd/hostgen/README.md +++ b/plugins/cmd/hostgen/README.md @@ -5,18 +5,22 @@ A code generator for Navidrome's plugin host functions. It reads Go interface de ## Usage ```bash -hostgen -input -output -package [-v] [-dry-run] +hostgen -input -output -package [-v] [-dry-run] [-host-only] [-plugin-only] ``` ### 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` | +| 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` | +| `-host-only` | Generate only host-side wrapper code | `false` | +| `-plugin-only` | Generate only plugin/client-side wrapper code | `false` | + +By default, both host and plugin code are generated. Use `-host-only` or `-plugin-only` to generate only one type. ### Example @@ -162,7 +166,9 @@ type ServiceSearchResponse struct { ## Output Files -Generated files are named `_gen.go` (lowercase). Each file includes: +### Host Code (Navidrome-side) + +Generated files are named `_gen.go` (lowercase) and placed in the output directory. Each file includes: - `// Code generated by hostgen. DO NOT EDIT.` header - Required imports (`context`, `encoding/json`, `extism`) @@ -171,6 +177,25 @@ Generated files are named `_gen.go` (lowercase). Each file includes - Host function wrappers - Helper functions (`writeResponse`, `writeErrorResponse`) +### Plugin/Client Code (TinyGo WASM) + +Generated files are named `nd_host_.go` (lowercase) and placed in the `go/` subdirectory of the output directory. These files are intended for use in Navidrome plugins built with TinyGo. Each file includes: + +- `// Code generated by hostgen. DO NOT EDIT.` header +- Required imports (`encoding/json`, `errors`, `github.com/extism/go-pdk`) +- `//go:wasmimport` declarations for each host function +- Response struct types +- Wrapper functions that handle memory allocation and JSON parsing + +### Example Output Structure + +``` +output/ +├── subsonicapi_gen.go # Host-side code (for Navidrome) +└── go/ + └── nd_host_subsonicapi.go # Plugin-side code (for TinyGo plugins) +``` + ## Troubleshooting ### Annotations Not Detected diff --git a/plugins/cmd/hostgen/integration_test.go b/plugins/cmd/hostgen/integration_test.go index 6f7ef64e3..6385ee48f 100644 --- a/plugins/cmd/hostgen/integration_test.go +++ b/plugins/cmd/hostgen/integration_test.go @@ -150,69 +150,146 @@ type ServiceB interface { Expect(filepath.Join(outputDir, "servicea_gen.go")).To(BeAnExistingFile()) Expect(filepath.Join(outputDir, "serviceb_gen.go")).To(BeAnExistingFile()) }) + + It("generates only host code with -host-only flag", func() { + cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-host-only") + output, err := cmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) + + Expect(filepath.Join(outputDir, "test_gen.go")).To(BeAnExistingFile()) + Expect(filepath.Join(outputDir, "go")).ToNot(BeADirectory()) + }) + + It("generates only client code with -plugin-only flag", func() { + cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "main", "-plugin-only") + output, err := cmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) + + // Host code should not exist in output root + entries, err := os.ReadDir(outputDir) + Expect(err).ToNot(HaveOccurred()) + + var genFiles []string + for _, e := range entries { + if e.Name() != "go" { + genFiles = append(genFiles, e.Name()) + } + } + Expect(genFiles).To(BeEmpty(), "Expected no host code files, found: %v", genFiles) + + // Client code should exist in go/ subdirectory + Expect(filepath.Join(outputDir, "go")).To(BeADirectory()) + Expect(filepath.Join(outputDir, "go", "nd_host_test.go")).To(BeAnExistingFile()) + }) + + It("generates both host and client code by default", func() { + cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg") + output, err := cmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) + + // Host code in output root + Expect(filepath.Join(outputDir, "test_gen.go")).To(BeAnExistingFile()) + + // Client code in go/ subdirectory + Expect(filepath.Join(outputDir, "go")).To(BeADirectory()) + Expect(filepath.Join(outputDir, "go", "nd_host_test.go")).To(BeAnExistingFile()) + }) + + It("rejects using both -host-only and -plugin-only together", func() { + cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-host-only", "-plugin-only") + output, err := cmd.CombinedOutput() + Expect(err).To(HaveOccurred()) + Expect(string(output)).To(ContainSubstring("-host-only and -plugin-only cannot be used together")) + }) }) Describe("code generation", func() { - DescribeTable("generates correct output", - func(serviceFile, expectedFile string) { + DescribeTable("generates correct host and client output", + func(serviceFile, hostExpectedFile, clientExpectedFile string) { serviceCode := readTestdata(serviceFile) - expectedCode := readTestdata(expectedFile) + hostExpected := readTestdata(hostExpectedFile) + clientExpected := readTestdata(clientExpectedFile) Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) + // Generate both host and client code in one run cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) + // Verify host code 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())) + var hostFiles []string + for _, e := range entries { + if e.Name() != "go" && !e.IsDir() { + hostFiles = append(hostFiles, e.Name()) + } + } + Expect(hostFiles).To(HaveLen(1), "Expected exactly one host file, got: %v", hostFiles) + + hostActual, err := os.ReadFile(filepath.Join(outputDir, hostFiles[0])) 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) + formattedHostActual, err := format.Source(hostActual) + Expect(err).ToNot(HaveOccurred(), "Generated host code is not valid Go:\n%s", hostActual) - formattedExpected, err := format.Source([]byte(expectedCode)) - Expect(err).ToNot(HaveOccurred(), "Expected code is not valid Go") + formattedHostExpected, err := format.Source([]byte(hostExpected)) + Expect(err).ToNot(HaveOccurred(), "Expected host code is not valid Go") - Expect(string(formattedActual)).To(Equal(string(formattedExpected))) + Expect(string(formattedHostActual)).To(Equal(string(formattedHostExpected)), "Host code mismatch") + + // Verify client code + goDir := filepath.Join(outputDir, "go") + clientEntries, err := os.ReadDir(goDir) + Expect(err).ToNot(HaveOccurred()) + Expect(clientEntries).To(HaveLen(1), "Expected exactly one client file") + + clientActual, err := os.ReadFile(filepath.Join(goDir, clientEntries[0].Name())) + Expect(err).ToNot(HaveOccurred()) + + formattedClientActual, err := format.Source(clientActual) + Expect(err).ToNot(HaveOccurred(), "Generated client code is not valid Go:\n%s", clientActual) + + formattedClientExpected, err := format.Source([]byte(clientExpected)) + Expect(err).ToNot(HaveOccurred(), "Expected client code is not valid Go") + + Expect(string(formattedClientActual)).To(Equal(string(formattedClientExpected)), "Client code mismatch") }, - Entry("simple string params - no request type needed", - "echo_service.go", "echo_expected.go"), + Entry("simple string params", + "echo_service.go", "echo_expected.go", "echo_client_expected.go"), - Entry("multiple simple params", - "math_service.go", "math_expected.go"), + Entry("multiple simple params (int32)", + "math_service.go", "math_expected.go", "math_client_expected.go"), Entry("struct param with request type", - "store_service.go", "store_expected.go"), + "store_service.go", "store_expected.go", "store_client_expected.go"), Entry("mixed simple and complex params", - "list_service.go", "list_expected.go"), + "list_service.go", "list_expected.go", "list_client_expected.go"), Entry("method without error", - "counter_service.go", "counter_expected.go"), + "counter_service.go", "counter_expected.go", "counter_client_expected.go"), Entry("no params, error only", - "ping_service.go", "ping_expected.go"), + "ping_service.go", "ping_expected.go", "ping_client_expected.go"), Entry("map and interface types", - "meta_service.go", "meta_expected.go"), + "meta_service.go", "meta_expected.go", "meta_client_expected.go"), Entry("pointer types", - "users_service.go", "users_expected.go"), + "users_service.go", "users_expected.go", "users_client_expected.go"), Entry("multiple returns", - "search_service.go", "search_expected.go"), + "search_service.go", "search_expected.go", "search_client_expected.go"), Entry("bytes", - "codec_service.go", "codec_expected.go"), + "codec_service.go", "codec_expected.go", "codec_client_expected.go"), ) - It("generates compilable code for comprehensive service", func() { + It("generates compilable host code for comprehensive service", func() { serviceCode := readTestdata("comprehensive_service.go") Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) @@ -221,8 +298,8 @@ type ServiceB interface { 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") + // Generate host code only + cmd := exec.Command(hostgenBin, "-input", testDir, "-output", testDir, "-package", "testpkg", "-host-only") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output) @@ -238,6 +315,89 @@ type ServiceB interface { buildOutput, err := buildCmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Build failed: %s", buildOutput) }) + + It("generates compilable client code for comprehensive service", func() { + serviceCode := readTestdata("comprehensive_service.go") + + Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) + + // Generate client code only to a separate client directory + clientDir := filepath.Join(outputDir, "client") + cmd := exec.Command(hostgenBin, "-input", testDir, "-output", clientDir, "-package", "main", "-plugin-only") + output, err := cmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output) + + // Read generated client code + goDir := filepath.Join(clientDir, "go") + entries, err := os.ReadDir(goDir) + Expect(err).ToNot(HaveOccurred()) + Expect(entries).To(HaveLen(1), "Expected exactly one generated client file") + + content, err := os.ReadFile(filepath.Join(goDir, entries[0].Name())) + Expect(err).ToNot(HaveOccurred()) + + // Verify key expected content first + contentStr := string(content) + // Should have wasmimport declarations for all methods + Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_simpleparams")) + Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_structparam")) + Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noerror")) + Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparams")) + Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparamsnoreturns")) + + // Should have response types for methods with complex returns + Expect(contentStr).To(ContainSubstring("type ComprehensiveSimpleParamsResponse struct")) + Expect(contentStr).To(ContainSubstring("type ComprehensiveMultipleReturnsResponse struct")) + + // Should have wrapper functions + Expect(contentStr).To(ContainSubstring("func ComprehensiveSimpleParams(")) + Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParams()")) + Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParamsNoReturns()")) + + // Move generated file to clientDir root for compilation + Expect(os.Rename(filepath.Join(goDir, entries[0].Name()), filepath.Join(clientDir, "nd_host.go"))).To(Succeed()) + + // Create go.mod for client code + goMod := "module main\n\ngo 1.23\n\nrequire github.com/extism/go-pdk v1.1.1\n" + Expect(os.WriteFile(filepath.Join(clientDir, "go.mod"), []byte(goMod), 0600)).To(Succeed()) + + // Add a simple main function for the plugin + mainGo := `package main + +func main() {} +` + Expect(os.WriteFile(filepath.Join(clientDir, "main.go"), []byte(mainGo), 0600)).To(Succeed()) + + // Add type definitions needed by the generated code + typesGo := `package main + +type User2 struct { + ID string + Name string +} + +type Filter2 struct { + Active bool +} +` + Expect(os.WriteFile(filepath.Join(clientDir, "types.go"), []byte(typesGo), 0600)).To(Succeed()) + + // Tidy dependencies + goTidyCmd := exec.Command("go", "mod", "tidy") + goTidyCmd.Dir = clientDir + goTidyOutput, err := goTidyCmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "go mod tidy failed: %s", goTidyOutput) + + // Build as WASM plugin - this validates the client code compiles correctly + buildCmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", "plugin.wasm", ".") + buildCmd.Dir = clientDir + buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") + buildOutput, err := buildCmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "WASM build failed: %s", buildOutput) + + // Verify .wasm file was created + Expect(filepath.Join(clientDir, "plugin.wasm")).To(BeAnExistingFile()) + }) }) }) diff --git a/plugins/cmd/hostgen/internal/generator.go b/plugins/cmd/hostgen/internal/generator.go index 8e7eeb06e..ee9533cda 100644 --- a/plugins/cmd/hostgen/internal/generator.go +++ b/plugins/cmd/hostgen/internal/generator.go @@ -2,14 +2,18 @@ package internal import ( "bytes" + "embed" "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{ +//go:embed templates/*.tmpl +var templatesFS embed.FS + +// hostFuncMap returns the template functions for host code generation. +func hostFuncMap(svc Service) template.FuncMap { + return template.FuncMap{ "lower": strings.ToLower, "title": strings.Title, "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, @@ -27,7 +31,39 @@ func GenerateService(svc Service, pkgName string) ([]byte, error) { "readParam": generateReadParam, "writeReturn": generateWriteReturn, "encodeReturn": generateEncodeReturn, - }).Parse(serviceTemplate) + } +} + +// clientFuncMap returns the template functions for client code generation. +func clientFuncMap(svc Service) template.FuncMap { + return template.FuncMap{ + "lower": strings.ToLower, + "title": strings.Title, + "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, + "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) }, + "isSimple": IsSimpleType, + "isString": IsStringType, + "isBytes": IsBytesType, + "needsJSON": NeedsJSON, + "needsRespType": func(m Method) bool { return m.NeedsResponseType() }, + "isErrorOnly": func(m Method) bool { return m.IsErrorOnly() }, + "wasmParamType": wasmParamType, + "wasmReturnType": wasmReturnType, + "wrapperReturnType": func(m Method, svcName string) string { return wrapperReturnType(m, svcName) }, + "clientCallArg": clientCallArg, + "decodeResult": decodeResult, + "formatDoc": formatDoc, + } +} + +// GenerateHost generates the host function wrapper code for a service. +func GenerateHost(svc Service, pkgName string) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl") + if err != nil { + return nil, fmt.Errorf("reading host template: %w", err) + } + + tmpl, err := template.New("host").Funcs(hostFuncMap(svc)).Parse(string(tmplContent)) if err != nil { return nil, fmt.Errorf("parsing template: %w", err) } @@ -48,10 +84,43 @@ func GenerateService(svc Service, pkgName string) ([]byte, error) { return buf.Bytes(), nil } +// GenerateService generates the host function wrapper code for a service. +// Deprecated: Use GenerateHost instead. +func GenerateService(svc Service, pkgName string) ([]byte, error) { + return GenerateHost(svc, pkgName) +} + +// GenerateClientGo generates client wrapper code for plugins to call host functions. +func GenerateClientGo(svc Service) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/client_go.go.tmpl") + if err != nil { + return nil, fmt.Errorf("reading client template: %w", err) + } + + tmpl, err := template.New("client").Funcs(clientFuncMap(svc)).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + + data := templateData{ + Service: svc, + NeedsJSON: serviceClientNeedsJSON(svc), + NeedsErrors: serviceClientNeedsErrors(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 + NeedsErrors bool // Client: needs "errors" import NeedsWriteHelper bool NeedsErrorHelper bool } @@ -87,6 +156,35 @@ func serviceNeedsWriteHelper(svc Service) bool { return false } +// serviceClientNeedsJSON returns true if any method needs JSON encoding in client code. +// This is true if any method has a response type (complex returns) or if any param/return needs JSON. +func serviceClientNeedsJSON(svc Service) bool { + for _, m := range svc.Methods { + // Response types use JSON for serialization + if m.NeedsResponseType() { + return true + } + // Parameters that need JSON marshaling + for _, p := range m.Params { + if NeedsJSON(p.Type) { + return true + } + } + } + return false +} + +// serviceClientNeedsErrors returns true if any method needs the errors package in client code. +// This is true if any method returns an error. +func serviceClientNeedsErrors(svc Service) bool { + for _, m := range svc.Methods { + if m.HasError { + 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 { @@ -206,165 +304,94 @@ func generateEncodeReturn(p Param, varName string) string { } } -const serviceTemplate = `// Code generated by hostgen. DO NOT EDIT. +// Client-side helper functions for template -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}} +// wasmParamType returns the WASM parameter type for a Go parameter. +func wasmParamType(p Param) string { + if IsSimpleType(p.Type) { + return p.Type + } + // All pointer types (string, []byte, complex) use uint64 offset + return "uint64" } -{{- 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"` + "`" + ` +// wasmReturnType returns the WASM return type declaration for a method. +func wasmReturnType(m Method) string { + // Methods with JSON responses or error-only return uint64 (pointer) + if m.NeedsResponseType() || m.IsErrorOnly() { + return "uint64" + } + // Simple return types + if len(m.Returns) == 1 && IsSimpleType(m.Returns[0].Type) { + return m.Returns[0].Type + } + // No returns or multiple returns - use uint64 for pointer + if len(m.Returns) == 0 { + return "" + } + return "uint64" } -{{- 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}} +// wrapperReturnType returns the Go return type for the wrapper function. +func wrapperReturnType(m Method, svcName string) string { + if m.NeedsResponseType() { + return fmt.Sprintf("(*%s%sResponse, error)", svcName, m.Name) + } + if m.IsErrorOnly() { + return "error" + } + if len(m.Returns) == 1 { + return m.Returns[0].Type + } + return "" +} + +// clientCallArg returns the argument expression for calling the host function. +func clientCallArg(p Param) string { + if IsSimpleType(p.Type) { + return p.Name + } + // Pointer types use .Offset() + return p.Name + "Mem.Offset()" +} + +// decodeResult generates code to decode a simple return value. +func decodeResult(p Param, varName string) string { + switch p.Type { + case "int32": + return fmt.Sprintf("int32(%s)", varName) + case "uint32": + return fmt.Sprintf("uint32(%s)", varName) + case "int64": + return fmt.Sprintf("int64(%s)", varName) + case "uint64": + return varName + case "float32": + return fmt.Sprintf("math.Float32frombits(uint32(%s))", varName) + case "float64": + return fmt.Sprintf("math.Float64frombits(%s)", varName) + case "bool": + return fmt.Sprintf("%s != 0", varName) + case "string": + // pdk.FindMemory returns a value type, ReadBytes has pointer receiver + return fmt.Sprintf("func() string { m := pdk.FindMemory(%s); return string(m.ReadBytes()) }()", varName) + case "[]byte": + return fmt.Sprintf("func() []byte { m := pdk.FindMemory(%s); return m.ReadBytes() }()", varName) + default: + return varName } } -{{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 isErrorOnly .}} - // Write error string to plugin memory - if ptr, err := p.WriteString(err.Error()); err == nil { - stack[0] = ptr - } -{{- else if needsRespType .}} - {{$.Service.Name | lower}}WriteError(p, stack, err) -{{- end}} - return - } -{{- end}} - -{{- if isErrorOnly .}} - // Write empty string to indicate success - if ptr, err := p.WriteString(""); err == nil { - stack[0] = ptr - } -{{- else 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 or (needsRespType .) (isErrorOnly .)}} - []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 +// formatDoc formats a documentation string for Go comments. +// It prefixes each line with "// " and trims trailing whitespace. +func formatDoc(doc string) string { + if doc == "" { + return "" } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return + lines := strings.Split(strings.TrimSpace(doc), "\n") + var result []string + for _, line := range lines { + result = append(result, "// "+strings.TrimRight(line, " \t")) } - stack[0] = respPtr + return strings.Join(result, "\n") } -{{- 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/templates/client_go.go.tmpl b/plugins/cmd/hostgen/internal/templates/client_go.go.tmpl new file mode 100644 index 000000000..8a6d3f7db --- /dev/null +++ b/plugins/cmd/hostgen/internal/templates/client_go.go.tmpl @@ -0,0 +1,139 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the {{.Service.Name}} host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( +{{- if .NeedsJSON}} + "encoding/json" +{{- end}} +{{- if .NeedsErrors}} + "errors" +{{- end}} + + "github.com/extism/go-pdk" +) + +{{- /* Generate wasmimport declarations for each method */ -}} +{{range .Service.Methods}} + +// {{exportName .}} is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user {{exportName .}} +func {{exportName .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{wasmParamType $p}}{{end}}) {{wasmReturnType .}} +{{- end}} + +{{- /* Generate response types for methods that need them */ -}} +{{range .Service.Methods}} +{{- 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}} + +{{- /* Generate wrapper functions */ -}} +{{range .Service.Methods}} + +// {{$.Service.Name}}{{.Name}} calls the {{exportName .}} host function. +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) {{wrapperReturnType . $.Service.Name}} { +{{- if needsRespType .}} +{{- /* Complex response - use JSON */}} +{{- range .Params}} +{{- if isString .Type}} + {{.Name}}Mem := pdk.AllocateString({{.Name}}) + defer {{.Name}}Mem.Free() +{{- else if isBytes .Type}} + {{.Name}}Mem := pdk.AllocateBytes({{.Name}}) + defer {{.Name}}Mem.Free() +{{- else if needsJSON .Type}} + {{.Name}}Bytes, err := json.Marshal({{.Name}}) + if err != nil { + return nil, err + } + {{.Name}}Mem := pdk.AllocateBytes({{.Name}}Bytes) + defer {{.Name}}Mem.Free() +{{- end}} +{{- end}} + + // Call the host function + responsePtr := {{exportName .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{clientCallArg $p}}{{end}}) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response {{responseType .}} + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +{{- else if isErrorOnly .}} +{{- /* Error-only response - string result */}} +{{- range .Params}} +{{- if isString .Type}} + {{.Name}}Mem := pdk.AllocateString({{.Name}}) + defer {{.Name}}Mem.Free() +{{- else if isBytes .Type}} + {{.Name}}Mem := pdk.AllocateBytes({{.Name}}) + defer {{.Name}}Mem.Free() +{{- else if needsJSON .Type}} + {{.Name}}Bytes, err := json.Marshal({{.Name}}) + if err != nil { + return err + } + {{.Name}}Mem := pdk.AllocateBytes({{.Name}}Bytes) + defer {{.Name}}Mem.Free() +{{- end}} +{{- end}} + + // Call the host function + responsePtr := {{exportName .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{clientCallArg $p}}{{end}}) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +{{- else}} +{{- /* Simple return types - direct stack values */}} +{{- range .Params}} +{{- if isString .Type}} + {{.Name}}Mem := pdk.AllocateString({{.Name}}) + defer {{.Name}}Mem.Free() +{{- else if isBytes .Type}} + {{.Name}}Mem := pdk.AllocateBytes({{.Name}}) + defer {{.Name}}Mem.Free() +{{- end}} +{{- end}} + + // Call the host function +{{- if .HasReturns}} + result := {{exportName .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{clientCallArg $p}}{{end}}) + return {{decodeResult (index .Returns 0) "result"}} +{{- else}} + {{exportName .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{clientCallArg $p}}{{end}}) +{{- end}} +{{- end}} +} +{{- end}} diff --git a/plugins/cmd/hostgen/internal/templates/host.go.tmpl b/plugins/cmd/hostgen/internal/templates/host.go.tmpl new file mode 100644 index 000000000..5e952c629 --- /dev/null +++ b/plugins/cmd/hostgen/internal/templates/host.go.tmpl @@ -0,0 +1,161 @@ +// 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 isErrorOnly .}} + // Write error string to plugin memory + if ptr, err := p.WriteString(err.Error()); err == nil { + stack[0] = ptr + } +{{- else if needsRespType .}} + {{$.Service.Name | lower}}WriteError(p, stack, err) +{{- end}} + return + } +{{- end}} + +{{- if isErrorOnly .}} + // Write empty string to indicate success + if ptr, err := p.WriteString(""); err == nil { + stack[0] = ptr + } +{{- else 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 or (needsRespType .) (isErrorOnly .)}} + []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/main.go b/plugins/cmd/hostgen/main.go index 80dec3005..4b3d600cf 100644 --- a/plugins/cmd/hostgen/main.go +++ b/plugins/cmd/hostgen/main.go @@ -6,11 +6,13 @@ // // 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 +// -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) +// -host-only Generate only host-side code (default: false) +// -plugin-only Generate only plugin/client-side code (default: false) +// -v Verbose output +// -dry-run Preview generated code without writing files package main import ( @@ -19,20 +21,29 @@ import ( "go/format" "os" "path/filepath" + "strings" "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") + 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)") + hostOnly = flag.Bool("host-only", false, "Generate only host-side code") + pluginOnly = flag.Bool("plugin-only", false, "Generate only plugin/client-side code") + verbose = flag.Bool("v", false, "Verbose output") + dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files") ) flag.Parse() + // Validate conflicting flags + if *hostOnly && *pluginOnly { + fmt.Fprintf(os.Stderr, "Error: -host-only and -plugin-only cannot be used together\n") + os.Exit(1) + } + if *outputDir == "" { *outputDir = *inputDir } @@ -54,10 +65,16 @@ func main() { *pkgName = filepath.Base(absOutput) } + // Determine what to generate + generateHost := !*pluginOnly + generateClient := !*hostOnly + if *verbose { fmt.Printf("Input directory: %s\n", absInput) fmt.Printf("Output directory: %s\n", absOutput) fmt.Printf("Package name: %s\n", *pkgName) + fmt.Printf("Generate host code: %v\n", generateHost) + fmt.Printf("Generate client code: %v\n", generateClient) } // Parse source files @@ -83,34 +100,85 @@ func main() { // 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) + // Generate host-side code + if generateHost { + if err := generateHostCode(svc, *pkgName, absOutput, *dryRun, *verbose); err != nil { + fmt.Fprintf(os.Stderr, "Error generating host 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) + // Generate client-side code + if generateClient { + if err := generateClientCode(svc, absOutput, *dryRun, *verbose); err != nil { + fmt.Fprintf(os.Stderr, "Error generating client code for %s: %v\n", svc.Name, err) + os.Exit(1) + } } } } + +// generateHostCode generates host-side code for a service. +func generateHostCode(svc internal.Service, pkgName, outputDir string, dryRun, verbose bool) error { + code, err := internal.GenerateHost(svc, pkgName) + if err != nil { + return fmt.Errorf("generating code: %w", err) + } + + formatted, err := format.Source(code) + if err != nil { + return fmt.Errorf("formatting code: %w\nRaw code:\n%s", err, code) + } + + outputFile := filepath.Join(outputDir, svc.OutputFileName()) + + if dryRun { + fmt.Printf("=== %s ===\n%s\n", outputFile, formatted) + return nil + } + + if err := os.WriteFile(outputFile, formatted, 0600); err != nil { + return fmt.Errorf("writing file: %w", err) + } + + if verbose { + fmt.Printf("Generated host code: %s\n", outputFile) + } + return nil +} + +// generateClientCode generates client-side code for a service. +func generateClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error { + code, err := internal.GenerateClientGo(svc) + if err != nil { + return fmt.Errorf("generating code: %w", err) + } + + formatted, err := format.Source(code) + if err != nil { + return fmt.Errorf("formatting code: %w\nRaw code:\n%s", err, code) + } + + // Client code goes in go/ subdirectory + clientDir := filepath.Join(outputDir, "go") + clientFile := filepath.Join(clientDir, "nd_host_"+strings.ToLower(svc.Name)+".go") + + if dryRun { + fmt.Printf("=== %s ===\n%s\n", clientFile, formatted) + return nil + } + + // Create go/ subdirectory if needed + if err := os.MkdirAll(clientDir, 0755); err != nil { + return fmt.Errorf("creating client directory: %w", err) + } + + if err := os.WriteFile(clientFile, formatted, 0600); err != nil { + return fmt.Errorf("writing file: %w", err) + } + + if verbose { + fmt.Printf("Generated client code: %s\n", clientFile) + } + return nil +} diff --git a/plugins/cmd/hostgen/testdata/codec_client_expected.go b/plugins/cmd/hostgen/testdata/codec_client_expected.go new file mode 100644 index 000000000..796a1acdd --- /dev/null +++ b/plugins/cmd/hostgen/testdata/codec_client_expected.go @@ -0,0 +1,49 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Codec host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// codec_encode is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user codec_encode +func codec_encode(uint64) uint64 + +// CodecEncodeResponse is the response type for Codec.Encode. +type CodecEncodeResponse struct { + Result []byte `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// CodecEncode calls the codec_encode host function. +func CodecEncode(data []byte) (*CodecEncodeResponse, error) { + dataMem := pdk.AllocateBytes(data) + defer dataMem.Free() + + // Call the host function + responsePtr := codec_encode(dataMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CodecEncodeResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} diff --git a/plugins/cmd/hostgen/testdata/counter_client_expected.go b/plugins/cmd/hostgen/testdata/counter_client_expected.go new file mode 100644 index 000000000..8aa15f560 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/counter_client_expected.go @@ -0,0 +1,25 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Counter host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "github.com/extism/go-pdk" +) + +// counter_count is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user counter_count +func counter_count(uint64) int32 + +// CounterCount calls the counter_count host function. +func CounterCount(name string) int32 { + nameMem := pdk.AllocateString(name) + defer nameMem.Free() + + // Call the host function + result := counter_count(nameMem.Offset()) + return int32(result) +} diff --git a/plugins/cmd/hostgen/testdata/echo_client_expected.go b/plugins/cmd/hostgen/testdata/echo_client_expected.go new file mode 100644 index 000000000..1ba45b4b4 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/echo_client_expected.go @@ -0,0 +1,49 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Echo host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// echo_echo is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user echo_echo +func echo_echo(uint64) uint64 + +// EchoEchoResponse is the response type for Echo.Echo. +type EchoEchoResponse struct { + Reply string `json:"reply,omitempty"` + Error string `json:"error,omitempty"` +} + +// EchoEcho calls the echo_echo host function. +func EchoEcho(message string) (*EchoEchoResponse, error) { + messageMem := pdk.AllocateString(message) + defer messageMem.Free() + + // Call the host function + responsePtr := echo_echo(messageMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response EchoEchoResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} diff --git a/plugins/cmd/hostgen/testdata/list_client_expected.go b/plugins/cmd/hostgen/testdata/list_client_expected.go new file mode 100644 index 000000000..9dd7d4ad0 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/list_client_expected.go @@ -0,0 +1,55 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the List host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// list_items is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user list_items +func list_items(uint64, uint64) uint64 + +// ListItemsResponse is the response type for List.Items. +type ListItemsResponse struct { + Count int32 `json:"count,omitempty"` + Error string `json:"error,omitempty"` +} + +// ListItems calls the list_items host function. +func ListItems(name string, filter Filter) (*ListItemsResponse, error) { + nameMem := pdk.AllocateString(name) + defer nameMem.Free() + filterBytes, err := json.Marshal(filter) + if err != nil { + return nil, err + } + filterMem := pdk.AllocateBytes(filterBytes) + defer filterMem.Free() + + // Call the host function + responsePtr := list_items(nameMem.Offset(), filterMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ListItemsResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} diff --git a/plugins/cmd/hostgen/testdata/math_client_expected.go b/plugins/cmd/hostgen/testdata/math_client_expected.go new file mode 100644 index 000000000..68ae5a560 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/math_client_expected.go @@ -0,0 +1,47 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Math host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// math_add is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user math_add +func math_add(int32, int32) uint64 + +// MathAddResponse is the response type for Math.Add. +type MathAddResponse struct { + Result int32 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// MathAdd calls the math_add host function. +func MathAdd(a int32, b int32) (*MathAddResponse, error) { + + // Call the host function + responsePtr := math_add(a, b) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response MathAddResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} diff --git a/plugins/cmd/hostgen/testdata/meta_client_expected.go b/plugins/cmd/hostgen/testdata/meta_client_expected.go new file mode 100644 index 000000000..077ce80d9 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/meta_client_expected.go @@ -0,0 +1,77 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Meta host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// meta_get is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user meta_get +func meta_get(uint64) uint64 + +// meta_set is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user meta_set +func meta_set(uint64) uint64 + +// MetaGetResponse is the response type for Meta.Get. +type MetaGetResponse struct { + Value any `json:"value,omitempty"` + Error string `json:"error,omitempty"` +} + +// MetaGet calls the meta_get host function. +func MetaGet(key string) (*MetaGetResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := meta_get(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response MetaGetResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// MetaSet calls the meta_set host function. +func MetaSet(data map[string]any) error { + dataBytes, err := json.Marshal(data) + if err != nil { + return err + } + dataMem := pdk.AllocateBytes(dataBytes) + defer dataMem.Free() + + // Call the host function + responsePtr := meta_set(dataMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} diff --git a/plugins/cmd/hostgen/testdata/ping_client_expected.go b/plugins/cmd/hostgen/testdata/ping_client_expected.go new file mode 100644 index 000000000..1215827e8 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/ping_client_expected.go @@ -0,0 +1,34 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Ping host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "errors" + + "github.com/extism/go-pdk" +) + +// ping_ping is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user ping_ping +func ping_ping() uint64 + +// PingPing calls the ping_ping host function. +func PingPing() error { + + // Call the host function + responsePtr := ping_ping() + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} diff --git a/plugins/cmd/hostgen/testdata/search_client_expected.go b/plugins/cmd/hostgen/testdata/search_client_expected.go new file mode 100644 index 000000000..7f2db4130 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/search_client_expected.go @@ -0,0 +1,50 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Search host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// search_find is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user search_find +func search_find(uint64) uint64 + +// 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"` +} + +// SearchFind calls the search_find host function. +func SearchFind(query string) (*SearchFindResponse, error) { + queryMem := pdk.AllocateString(query) + defer queryMem.Free() + + // Call the host function + responsePtr := search_find(queryMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response SearchFindResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} diff --git a/plugins/cmd/hostgen/testdata/store_client_expected.go b/plugins/cmd/hostgen/testdata/store_client_expected.go new file mode 100644 index 000000000..31e392585 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/store_client_expected.go @@ -0,0 +1,53 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Store host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// store_save is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user store_save +func store_save(uint64) uint64 + +// StoreSaveResponse is the response type for Store.Save. +type StoreSaveResponse struct { + Id string `json:"id,omitempty"` + Error string `json:"error,omitempty"` +} + +// StoreSave calls the store_save host function. +func StoreSave(item Item) (*StoreSaveResponse, error) { + itemBytes, err := json.Marshal(item) + if err != nil { + return nil, err + } + itemMem := pdk.AllocateBytes(itemBytes) + defer itemMem.Free() + + // Call the host function + responsePtr := store_save(itemMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response StoreSaveResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} diff --git a/plugins/cmd/hostgen/testdata/users_client_expected.go b/plugins/cmd/hostgen/testdata/users_client_expected.go new file mode 100644 index 000000000..768cee508 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/users_client_expected.go @@ -0,0 +1,59 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Users host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// users_get is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user users_get +func users_get(uint64, uint64) uint64 + +// UsersGetResponse is the response type for Users.Get. +type UsersGetResponse struct { + Result *User `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// UsersGet calls the users_get host function. +func UsersGet(id *string, filter *User) (*UsersGetResponse, error) { + idBytes, err := json.Marshal(id) + if err != nil { + return nil, err + } + idMem := pdk.AllocateBytes(idBytes) + defer idMem.Free() + filterBytes, err := json.Marshal(filter) + if err != nil { + return nil, err + } + filterMem := pdk.AllocateBytes(filterBytes) + defer filterMem.Free() + + // Call the host function + responsePtr := users_get(idMem.Offset(), filterMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response UsersGetResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} diff --git a/plugins/host/go/nd_host_subsonicapi.go b/plugins/host/go/nd_host_subsonicapi.go new file mode 100644 index 000000000..092b4d4cd --- /dev/null +++ b/plugins/host/go/nd_host_subsonicapi.go @@ -0,0 +1,53 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the SubsonicAPI host service. +// It is intended for use in Navidrome plugins built with TinyGo. + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// subsonicapi_call is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user subsonicapi_call +func subsonicapi_call(uint64) uint64 + +// SubsonicAPICallResponse is the response type for SubsonicAPI.Call. +type SubsonicAPICallResponse struct { + ResponseJSON string `json:"responseJSON,omitempty"` + Error string `json:"error,omitempty"` +} + +// SubsonicAPICall calls the subsonicapi_call host function. +// 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. +func SubsonicAPICall(uri string) (*SubsonicAPICallResponse, error) { + uriMem := pdk.AllocateString(uri) + defer uriMem.Free() + + // Call the host function + responsePtr := subsonicapi_call(uriMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response SubsonicAPICallResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile index 9c7baaa1b..ee14054fb 100644 --- a/plugins/testdata/Makefile +++ b/plugins/testdata/Makefile @@ -11,7 +11,7 @@ all: $(PLUGINS:%=%.wasm) clean: rm -f $(PLUGINS:%=%.wasm) -%.wasm: %/main.go %/go.mod +%.wasm: %/*.go %/go.mod ifdef TINYGO cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ . else diff --git a/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go b/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go index f3f8cbcef..092b4d4cd 100644 --- a/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go +++ b/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go @@ -1,3 +1,8 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the SubsonicAPI host service. +// It is intended for use in Navidrome plugins built with TinyGo. + package main import ( @@ -7,26 +12,28 @@ import ( "github.com/extism/go-pdk" ) -// subsonicapiCall is the host function provided by Navidrome to call the Subsonic API. -// It takes a URI string and returns a JSON response. +// subsonicapi_call is the host function provided by Navidrome. // //go:wasmimport extism:host/user subsonicapi_call -func subsonicapiCall(uri uint64) uint64 +func subsonicapi_call(uint64) uint64 -// SubsonicAPICallResponse matches the host response format. +// SubsonicAPICallResponse is the response type for SubsonicAPI.Call. type SubsonicAPICallResponse struct { ResponseJSON string `json:"responseJSON,omitempty"` Error string `json:"error,omitempty"` } -// SubsonicAPICall is a wrapper around the host subsonicapi_call function. +// SubsonicAPICall calls the subsonicapi_call host function. +// 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. func SubsonicAPICall(uri string) (*SubsonicAPICallResponse, error) { - // Allocate memory for the URI string - mem := pdk.AllocateString(uri) - defer mem.Free() + uriMem := pdk.AllocateString(uri) + defer uriMem.Free() // Call the host function - responsePtr := subsonicapiCall(mem.Offset()) + responsePtr := subsonicapi_call(uriMem.Offset()) // Read the response from memory responseMem := pdk.FindMemory(responsePtr)