navidrome/plugins/cmd/ndpgen/internal/pdk_parser.go
Deluan Quintão 1072e9f7eb
chore(plugins): document requiredHosts rules and deprecate pdk.NewHTTPRequest (#6129)
* fix(plugins): align the Python HTTP example with the repo's host-call pattern

Bind http_send with raw memory offsets like nowplaying-py does, drop
guards for fields the host always sends, and document how plugins
without a PDK call host services and which built-in HTTP APIs are
disabled.

* docs(plugins): document the private-address rules for HTTP requiredHosts

Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can.

* docs(plugins): document the private-address rules for requiredHosts

Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can, for both
HTTP and WebSocket. Inline the single-use HTTP isHostAllowed wrapper.

* feat(plugins): derive Default for Rust host service structs

The ndpgen client.rs template now adds Default to the derive list of host
service structs, as the capability and shared types templates already do.
Plugin authors can now set only the fields they need, for example
HTTPRequest { method, url, ..Default::default() }. The webhook-rs and
discord-rich-presence-rs examples use this form now. The golden files and
the generated nd-pdk-host crate are updated to match.

* feat(plugins): deprecate pdk.NewHTTPRequest in the Go PDK

Navidrome no longer enables extism's http_request host function, so a
request built with pdk.NewHTTPRequest always fails. ndpgen now reads a small
deprecation table and writes a Deprecated: paragraph for the listed extism
functions, in both the WASM wrapper and the native stub. Linters and IDEs
now point plugin authors to host.HTTPSend. The PDK example tests used to
teach NewHTTPRequest. They now use host.HTTPSend and host.HTTPMock.

* docs(plugins): correct requiredHosts rules for websocket and private addresses

Two statements in the plugin docs did not match the code.

The WebSocket section claimed requiredHosts behaves like HTTP. It does not:
host_httpclient.go only consults the allowlist when the list is non-empty and
otherwise falls back to allowing public addresses, while host_websocket.go
always calls isHostInAllowlist, so an absent list blocks every connection.

The HTTP section claimed a named host can never reach a private address.
checkPrivateDial scans the whole requiredHosts list, so a named host does
reach a private address when the same list also holds a covering IP or CIDR.

Reworded both, plus the matching requiredHosts descriptions in
manifest-schema.json, and regenerated manifest_gen.go.
2026-09-12 13:59:46 -04:00

448 lines
11 KiB
Go

package internal
import (
"fmt"
"go/ast"
"go/token"
"sort"
"strings"
"golang.org/x/tools/go/packages"
)
// PDKSymbols contains all exported symbols parsed from extism/go-pdk.
type PDKSymbols struct {
Types []PDKType
Consts []PDKConst
Functions []PDKFunc
}
// PDKType represents an exported type from extism/go-pdk.
type PDKType struct {
Name string
Underlying string // The underlying type (e.g., "int" for LogLevel)
IsAlias bool // True if it's a type alias (type X = Y)
Doc string // Documentation comment
Methods []PDKFunc // Methods on this type
Fields []PDKField // Struct fields (if it's a struct type)
}
// PDKField represents a struct field.
type PDKField struct {
Name string
Type string
Tag string // Struct tag (e.g., `json:"name"`)
}
// PDKConst represents an exported constant from extism/go-pdk.
type PDKConst struct {
Name string
Type string // The type name (may be empty for untyped consts)
Value string // The value expression
Doc string
}
// PDKFunc represents an exported function from extism/go-pdk.
type PDKFunc struct {
Name string
Doc string
Receiver string // Empty for package-level functions
Params []PDKParam
Returns []PDKReturn
IsVariadic bool
Deprecated string // Rendered as a "Deprecated:" paragraph when set
}
// deprecatedPDKFuncs marks extism functions that do not work inside Navidrome, with what to use instead.
var deprecatedPDKFuncs = map[string]string{
"NewHTTPRequest": "Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead.",
}
// PDKParam represents a function parameter.
type PDKParam struct {
Name string
Type string
}
// PDKReturn represents a function return value.
type PDKReturn struct {
Name string // May be empty for unnamed returns
Type string
}
// ParseExtismPDK parses the extism/go-pdk package and extracts all exported symbols.
func ParseExtismPDK() (*PDKSymbols, error) {
// Load both packages with syntax trees in one call
cfg := &packages.Config{
Mode: packages.NeedName | packages.NeedSyntax | packages.NeedFiles,
}
pkgs, err := packages.Load(cfg,
"github.com/extism/go-pdk",
"github.com/extism/go-pdk/internal/memory",
)
if err != nil {
return nil, fmt.Errorf("loading extism/go-pdk: %w", err)
}
// Find both packages
var pdkPkg, memoryPkg *packages.Package
for _, pkg := range pkgs {
if len(pkg.Errors) > 0 {
return nil, fmt.Errorf("loading %s: %v", pkg.PkgPath, pkg.Errors[0])
}
switch pkg.Name {
case "pdk":
pdkPkg = pkg
case "memory":
memoryPkg = pkg
}
}
if pdkPkg == nil {
return nil, fmt.Errorf("package github.com/extism/go-pdk not found")
}
if memoryPkg == nil {
return nil, fmt.Errorf("package github.com/extism/go-pdk/internal/memory not found")
}
symbols := &PDKSymbols{}
seenTypes := make(map[string]bool)
// Extract Memory type from internal/memory package first
extractMemorySymbols(memoryPkg.Syntax, symbols, seenTypes)
// First pass: collect types from pdk package (skip if already found in internal packages)
for _, file := range pdkPkg.Syntax {
for _, decl := range file.Decls {
if genDecl, ok := decl.(*ast.GenDecl); ok {
for _, spec := range genDecl.Specs {
if typeSpec, ok := spec.(*ast.TypeSpec); ok {
if !typeSpec.Name.IsExported() {
continue
}
// Skip if we already have this type (from internal packages)
if seenTypes[typeSpec.Name.Name] {
continue
}
seenTypes[typeSpec.Name.Name] = true
pdkType := extractType(typeSpec, genDecl.Doc)
symbols.Types = append(symbols.Types, pdkType)
}
}
}
}
}
// Build typeMap from the final slice (after all types are added)
typeMap := make(map[string]*PDKType)
for i := range symbols.Types {
typeMap[symbols.Types[i].Name] = &symbols.Types[i]
}
// Second pass: collect functions and methods from pdk package
for _, file := range pdkPkg.Syntax {
for _, decl := range file.Decls {
switch d := decl.(type) {
case *ast.GenDecl:
if d.Tok == token.CONST {
consts := extractConsts(d)
symbols.Consts = append(symbols.Consts, consts...)
}
case *ast.FuncDecl:
if !d.Name.IsExported() {
continue
}
fn := extractFunc(d)
if fn.Receiver != "" {
// It's a method, associate with type
typeName := fn.Receiver
if strings.HasPrefix(typeName, "*") {
typeName = typeName[1:]
}
if t, ok := typeMap[typeName]; ok {
t.Methods = append(t.Methods, fn)
}
} else {
fn.Deprecated = deprecatedPDKFuncs[fn.Name]
symbols.Functions = append(symbols.Functions, fn)
}
}
}
}
// Sort for consistent output
sort.Slice(symbols.Types, func(i, j int) bool {
return symbols.Types[i].Name < symbols.Types[j].Name
})
sort.Slice(symbols.Consts, func(i, j int) bool {
return symbols.Consts[i].Name < symbols.Consts[j].Name
})
sort.Slice(symbols.Functions, func(i, j int) bool {
return symbols.Functions[i].Name < symbols.Functions[j].Name
})
return symbols, nil
}
func extractType(spec *ast.TypeSpec, doc *ast.CommentGroup) PDKType {
t := PDKType{
Name: spec.Name.Name,
Doc: extractDoc(doc),
}
// Check if it's an alias (type X = Y)
t.IsAlias = spec.Assign.IsValid()
// Extract underlying type
t.Underlying = typeString(spec.Type)
// Extract struct fields if it's a struct type
if structType, ok := spec.Type.(*ast.StructType); ok {
t.Fields = extractStructFields(structType)
}
return t
}
func extractStructFields(st *ast.StructType) []PDKField {
var fields []PDKField
if st.Fields == nil {
return fields
}
for _, field := range st.Fields.List {
fieldType := typeString(field.Type)
tag := ""
if field.Tag != nil {
tag = field.Tag.Value
}
if len(field.Names) == 0 {
// Embedded field
fields = append(fields, PDKField{
Name: fieldType, // Use type name as field name for embedded
Type: fieldType,
Tag: tag,
})
} else {
for _, name := range field.Names {
// Skip unexported fields
if !name.IsExported() {
continue
}
fields = append(fields, PDKField{
Name: name.Name,
Type: fieldType,
Tag: tag,
})
}
}
}
return fields
}
func extractConsts(decl *ast.GenDecl) []PDKConst {
var consts []PDKConst
var currentType string // For iota-style const blocks
for i, spec := range decl.Specs {
valSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
// Update type if specified
if valSpec.Type != nil {
currentType = typeString(valSpec.Type)
}
for j, name := range valSpec.Names {
if !name.IsExported() {
continue
}
c := PDKConst{
Name: name.Name,
Type: currentType,
}
// Extract value
if j < len(valSpec.Values) {
c.Value = exprString(valSpec.Values[j])
} else if i == 0 && j == 0 {
// First const with no value - likely iota
c.Value = "iota"
}
// Extract doc
if valSpec.Doc != nil {
c.Doc = extractDoc(valSpec.Doc)
} else if i == 0 && decl.Doc != nil {
c.Doc = extractDoc(decl.Doc)
}
consts = append(consts, c)
}
}
return consts
}
func extractFunc(decl *ast.FuncDecl) PDKFunc {
fn := PDKFunc{
Name: decl.Name.Name,
Doc: extractDoc(decl.Doc),
}
// Extract receiver
if decl.Recv != nil && len(decl.Recv.List) > 0 {
fn.Receiver = typeString(decl.Recv.List[0].Type)
}
// Extract parameters
if decl.Type.Params != nil {
for _, field := range decl.Type.Params.List {
paramType := typeString(field.Type)
// Check for variadic
if _, ok := field.Type.(*ast.Ellipsis); ok {
fn.IsVariadic = true
}
if len(field.Names) == 0 {
// Unnamed parameter
fn.Params = append(fn.Params, PDKParam{Type: paramType})
} else {
for _, name := range field.Names {
fn.Params = append(fn.Params, PDKParam{
Name: name.Name,
Type: paramType,
})
}
}
}
}
// Extract returns
if decl.Type.Results != nil {
for _, field := range decl.Type.Results.List {
retType := typeString(field.Type)
if len(field.Names) == 0 {
// Unnamed return
fn.Returns = append(fn.Returns, PDKReturn{Type: retType})
} else {
for _, name := range field.Names {
fn.Returns = append(fn.Returns, PDKReturn{
Name: name.Name,
Type: retType,
})
}
}
}
}
return fn
}
func extractDoc(doc *ast.CommentGroup) string {
if doc == nil {
return ""
}
return strings.TrimSpace(doc.Text())
}
func typeString(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return "*" + typeString(t.X)
case *ast.SelectorExpr:
return typeString(t.X) + "." + t.Sel.Name
case *ast.ArrayType:
if t.Len == nil {
return "[]" + typeString(t.Elt)
}
return fmt.Sprintf("[%s]%s", exprString(t.Len), typeString(t.Elt))
case *ast.MapType:
return fmt.Sprintf("map[%s]%s", typeString(t.Key), typeString(t.Value))
case *ast.InterfaceType:
return "any" // Simplified
case *ast.Ellipsis:
return "..." + typeString(t.Elt)
case *ast.StructType:
return "struct{}" // Simplified for anonymous structs
case *ast.FuncType:
return "func()" // Simplified
default:
return fmt.Sprintf("%T", expr)
}
}
func exprString(expr ast.Expr) string {
switch e := expr.(type) {
case *ast.Ident:
return e.Name
case *ast.BasicLit:
return e.Value
case *ast.BinaryExpr:
return exprString(e.X) + " " + e.Op.String() + " " + exprString(e.Y)
case *ast.UnaryExpr:
return e.Op.String() + exprString(e.X)
case *ast.CallExpr:
return typeString(e.Fun) + "(...)"
default:
return fmt.Sprintf("%T", expr)
}
}
// extractMemorySymbols extracts the Memory type and its methods from already-parsed syntax trees.
// This is needed because Memory is defined in internal/memory but re-exported by the pdk package.
func extractMemorySymbols(files []*ast.File, symbols *PDKSymbols, seenTypes map[string]bool) {
// Collect the Memory type
for _, file := range files {
for _, decl := range file.Decls {
if genDecl, ok := decl.(*ast.GenDecl); ok {
for _, spec := range genDecl.Specs {
if typeSpec, ok := spec.(*ast.TypeSpec); ok {
// Only interested in Memory type
if typeSpec.Name.Name == "Memory" {
pdkType := extractType(typeSpec, genDecl.Doc)
symbols.Types = append(symbols.Types, pdkType)
seenTypes["Memory"] = true
}
}
}
}
}
}
// Build local type map for method association
localTypeMap := make(map[string]*PDKType)
for i := range symbols.Types {
localTypeMap[symbols.Types[i].Name] = &symbols.Types[i]
}
// Collect methods for Memory
for _, file := range files {
for _, decl := range file.Decls {
if funcDecl, ok := decl.(*ast.FuncDecl); ok {
if !funcDecl.Name.IsExported() {
continue
}
fn := extractFunc(funcDecl)
if fn.Receiver != "" {
typeName := fn.Receiver
if strings.HasPrefix(typeName, "*") {
typeName = typeName[1:]
}
if typeName == "Memory" {
if t, ok := localTypeMap["Memory"]; ok {
t.Methods = append(t.Methods, fn)
}
}
}
}
}
}
}