mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-31 19:08:06 -05:00
Compare commits
170 Commits
v0.59.0
...
new-plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b0ea10f4e | ||
|
|
aa207fb521 | ||
|
|
e2f64880e6 | ||
|
|
69ded80806 | ||
|
|
477cec93a9 | ||
|
|
7b0db4f8d6 | ||
|
|
ad9cda9d57 | ||
|
|
e3bfcff8c4 | ||
|
|
6698e94a9c | ||
|
|
6fff476e93 | ||
|
|
e6b0af63ce | ||
|
|
d6b412acde | ||
|
|
8d586f7425 | ||
|
|
451475a7af | ||
|
|
dc5100e56a | ||
|
|
4678da1e5b | ||
|
|
1fc1a667f1 | ||
|
|
59085145f5 | ||
|
|
e6e2582abf | ||
|
|
06d75476f6 | ||
|
|
4f260c058d | ||
|
|
67ab3dc81a | ||
|
|
ae41164c1f | ||
|
|
4ea54fe176 | ||
|
|
ca36c5df13 | ||
|
|
7cad08ad09 | ||
|
|
11d99ef673 | ||
|
|
6ee64ceeec | ||
|
|
f221c01bd9 | ||
|
|
f1b85e6a19 | ||
|
|
d43724c571 | ||
|
|
301b2c850c | ||
|
|
547363eab7 | ||
|
|
8a453cb22c | ||
|
|
2e716ed780 | ||
|
|
6d3c29912b | ||
|
|
6fa9ef0dfe | ||
|
|
ebba3a2c46 | ||
|
|
7b36fcbaa1 | ||
|
|
cd3ee136f4 | ||
|
|
3692a274b4 | ||
|
|
13ca6149a9 | ||
|
|
10e5f44617 | ||
|
|
7fd996b600 | ||
|
|
68e97a49ee | ||
|
|
8dad4f4a9c | ||
|
|
2ec972cdc8 | ||
|
|
d5e88c1117 | ||
|
|
2c6eef168c | ||
|
|
2b2bc5dcb2 | ||
|
|
4e392f7b07 | ||
|
|
e52b757cd4 | ||
|
|
78445163bb | ||
|
|
c7d37c4e8c | ||
|
|
9b9920402e | ||
|
|
93482e814e | ||
|
|
b592b1f2fa | ||
|
|
095ab6becf | ||
|
|
422c1617bc | ||
|
|
1ad824c724 | ||
|
|
aa83d3af6c | ||
|
|
505b3c529f | ||
|
|
5201f8a5eb | ||
|
|
17d0e48a01 | ||
|
|
e769ddf76c | ||
|
|
bad9e1fb5e | ||
|
|
6321dc1622 | ||
|
|
7c6c49c7a1 | ||
|
|
3605d5bf08 | ||
|
|
064e73f958 | ||
|
|
dd6d18de0a | ||
|
|
78f5ffce99 | ||
|
|
0cd44d2960 | ||
|
|
2cfa902123 | ||
|
|
2cc29793a6 | ||
|
|
690785120a | ||
|
|
9c626183d0 | ||
|
|
52c3985508 | ||
|
|
f66c888e09 | ||
|
|
8e9737ab95 | ||
|
|
a05fddbf7d | ||
|
|
18723c6aa8 | ||
|
|
97a10e8728 | ||
|
|
6107063517 | ||
|
|
980df67445 | ||
|
|
9fbcf6ceb3 | ||
|
|
cbd74a3a96 | ||
|
|
08b952ef50 | ||
|
|
37f3b838d2 | ||
|
|
3b9d426c5c | ||
|
|
38d80a07de | ||
|
|
83eaad7292 | ||
|
|
870cd49307 | ||
|
|
513c969c40 | ||
|
|
dd238e74fb | ||
|
|
06e6c09882 | ||
|
|
cab656dbe5 | ||
|
|
b9fceac12c | ||
|
|
f0d6fd4bc8 | ||
|
|
66c396413c | ||
|
|
a78bbca741 | ||
|
|
e60efde4d4 | ||
|
|
e200b70ea6 | ||
|
|
8bfb14814e | ||
|
|
20c7e6c915 | ||
|
|
9da40af6fb | ||
|
|
d1225b7828 | ||
|
|
57aebf5ee9 | ||
|
|
6d4b708a28 | ||
|
|
36927729a4 | ||
|
|
e951a82265 | ||
|
|
b2e1c216a0 | ||
|
|
1a7ba7f293 | ||
|
|
7a9a63b219 | ||
|
|
a770783c6c | ||
|
|
5e2e37bca7 | ||
|
|
b94a214c91 | ||
|
|
005fc684ed | ||
|
|
3a6cdb3ed3 | ||
|
|
f4c6461c0a | ||
|
|
b84089cea4 | ||
|
|
44c69de525 | ||
|
|
c059db4c9c | ||
|
|
ba27a8ceef | ||
|
|
a0a5168f5f | ||
|
|
097774f9c2 | ||
|
|
62612391da | ||
|
|
de90e191bb | ||
|
|
9481ba3662 | ||
|
|
1733129537 | ||
|
|
415eac5399 | ||
|
|
905cd613f3 | ||
|
|
876ecb29c8 | ||
|
|
5ddc763bb4 | ||
|
|
6ac3ce3511 | ||
|
|
fed00e1838 | ||
|
|
f0f191266c | ||
|
|
39be1878cb | ||
|
|
42d48300bb | ||
|
|
40ce71294e | ||
|
|
8cd3785ac4 | ||
|
|
41bc04214f | ||
|
|
66bd5f7a55 | ||
|
|
373f5fb3d9 | ||
|
|
22561abadc | ||
|
|
b3ec005fa2 | ||
|
|
c8887eac6b | ||
|
|
735c0d9103 | ||
|
|
fc9817552d | ||
|
|
0c1b65d3e6 | ||
|
|
47b448c64f | ||
|
|
834fa494e4 | ||
|
|
5d34640065 | ||
|
|
9ed309ac81 | ||
|
|
8c80be56da | ||
|
|
cde5992c46 | ||
|
|
017676c457 | ||
|
|
2d7b716834 | ||
|
|
c7ac0e4414 | ||
|
|
c9409d306a | ||
|
|
ebbe62bbbd | ||
|
|
42c85a18e2 | ||
|
|
7ccf44b8ed | ||
|
|
603cccde11 | ||
|
|
6ed6524752 | ||
|
|
a081569ed4 | ||
|
|
e923c02c6a | ||
|
|
51ca2dee65 | ||
|
|
6b961bd99d | ||
|
|
396eee48c6 |
30
.github/workflows/pipeline.yml
vendored
30
.github/workflows/pipeline.yml
vendored
@@ -88,6 +88,16 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
- name: Verify no changes from go generate
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo 'Generated code is out of date. Run "make gen" and commit the changes'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go:
|
||||
name: Test Go code
|
||||
runs-on: ubuntu-latest
|
||||
@@ -217,7 +227,7 @@ jobs:
|
||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: Upload Binaries
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: navidrome-${{ env.PLATFORM }}
|
||||
path: ./output
|
||||
@@ -248,7 +258,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM }}
|
||||
@@ -270,7 +280,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -304,7 +314,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -356,7 +366,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-windows*
|
||||
@@ -375,7 +385,7 @@ jobs:
|
||||
du -h binaries/msi/*.msi
|
||||
|
||||
- name: Upload MSI files
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: navidrome-windows-installers
|
||||
path: binaries/msi/*.msi
|
||||
@@ -393,7 +403,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-*
|
||||
@@ -419,7 +429,7 @@ jobs:
|
||||
rm ./dist/*.tar.gz ./dist/*.zip
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: packages
|
||||
path: dist/navidrome_0*
|
||||
@@ -442,13 +452,13 @@ jobs:
|
||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||
steps:
|
||||
- name: Download all-packages artifact
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: ./dist
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: navidrome_linux_${{ matrix.item }}
|
||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@v6
|
||||
with:
|
||||
process-only: 'issues, prs'
|
||||
issue-inactive-days: 120
|
||||
|
||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
git status --porcelain
|
||||
git diff
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,6 +17,7 @@ master.zip
|
||||
testDB
|
||||
cache/*
|
||||
*.swp
|
||||
coverage.out
|
||||
dist
|
||||
music
|
||||
*.db*
|
||||
@@ -25,6 +26,7 @@ docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
binaries
|
||||
navidrome-*
|
||||
/ndpgen
|
||||
AGENTS.md
|
||||
.github/prompts
|
||||
.github/instructions
|
||||
@@ -32,4 +34,5 @@ AGENTS.md
|
||||
*.exe
|
||||
*.test
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
10
Dockerfile
10
Dockerfile
@@ -2,10 +2,10 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
||||
|
||||
########################################################################################################################
|
||||
### Build xx (original image: tonistiigi/xx)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
|
||||
|
||||
# v1.5.0
|
||||
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
|
||||
# v1.9.0
|
||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||
|
||||
RUN apk add -U --no-cache git
|
||||
RUN git clone https://github.com/tonistiigi/xx && \
|
||||
@@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
||||
|
||||
########################################################################################################################
|
||||
### Get TagLib
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
|
||||
ARG TARGETPLATFORM
|
||||
ARG CROSS_TAGLIB_VERSION=2.1.1-1
|
||||
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||
@@ -122,7 +122,7 @@ COPY --from=build /out /
|
||||
|
||||
########################################################################################################################
|
||||
### Build Final Image
|
||||
FROM public.ecr.aws/docker/library/alpine:3.19 AS final
|
||||
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||
|
||||
|
||||
33
Makefile
33
Makefile
@@ -16,7 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
||||
GOLANGCI_LINT_VERSION ?= v2.6.2
|
||||
GOLANGCI_LINT_VERSION ?= v2.7.2
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
@@ -50,7 +50,7 @@ test: ##@Development Run Go tests. Use PKG variable to specify packages to test,
|
||||
go test -tags netgo $(PKG)
|
||||
.PHONY: test
|
||||
|
||||
testall: test-race test-i18n test-js ##@Development Run Go and JS tests
|
||||
testall: test test-i18n test-js ##@Development Run Go and JS tests
|
||||
.PHONY: testall
|
||||
|
||||
test-race: ##@Development Run Go tests with race detector
|
||||
@@ -85,7 +85,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
|
||||
PATH=$$PATH:./bin golangci-lint run --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
@@ -103,6 +103,15 @@ 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 ./...
|
||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host
|
||||
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust
|
||||
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust
|
||||
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities
|
||||
go mod tidy -C plugins/pdk/go
|
||||
.PHONY: gen
|
||||
|
||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
||||
.PHONY: snapshots
|
||||
@@ -266,24 +275,6 @@ deprecated:
|
||||
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
||||
.PHONY: deprecated
|
||||
|
||||
# Generate Go code from plugins/api/api.proto
|
||||
plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files
|
||||
go generate ./plugins/...
|
||||
.PHONY: plugin-gen
|
||||
|
||||
plugin-examples: check_go_env ##@Development Build all example plugins
|
||||
$(MAKE) -C plugins/examples clean all
|
||||
.PHONY: plugin-examples
|
||||
|
||||
plugin-clean: check_go_env ##@Development Clean all plugins
|
||||
$(MAKE) -C plugins/examples clean
|
||||
$(MAKE) -C plugins/testdata clean
|
||||
.PHONY: plugin-clean
|
||||
|
||||
plugin-tests: check_go_env ##@Development Build all test plugins
|
||||
$(MAKE) -C plugins/testdata clean all
|
||||
.PHONY: plugin-tests
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
HELP_FUN = \
|
||||
|
||||
716
cmd/plugin.go
716
cmd/plugin.go
@@ -1,716 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
pluginPackageExtension = ".ndp"
|
||||
pluginDirPermissions = 0700
|
||||
pluginFilePermissions = 0600
|
||||
)
|
||||
|
||||
func init() {
|
||||
pluginCmd := &cobra.Command{
|
||||
Use: "plugin",
|
||||
Short: "Manage Navidrome plugins",
|
||||
Long: "Commands for managing Navidrome plugins",
|
||||
}
|
||||
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List installed plugins",
|
||||
Long: "List all installed plugins with their metadata",
|
||||
Run: pluginList,
|
||||
}
|
||||
|
||||
infoCmd := &cobra.Command{
|
||||
Use: "info [pluginPackage|pluginName]",
|
||||
Short: "Show details of a plugin",
|
||||
Long: "Show detailed information about a plugin package (.ndp file) or an installed plugin",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: pluginInfo,
|
||||
}
|
||||
|
||||
installCmd := &cobra.Command{
|
||||
Use: "install [pluginPackage]",
|
||||
Short: "Install a plugin from a .ndp file",
|
||||
Long: "Install a Navidrome Plugin Package (.ndp) file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: pluginInstall,
|
||||
}
|
||||
|
||||
removeCmd := &cobra.Command{
|
||||
Use: "remove [pluginName]",
|
||||
Short: "Remove an installed plugin",
|
||||
Long: "Remove a plugin by name",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: pluginRemove,
|
||||
}
|
||||
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update [pluginPackage]",
|
||||
Short: "Update an existing plugin",
|
||||
Long: "Update an installed plugin with a new version from a .ndp file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: pluginUpdate,
|
||||
}
|
||||
|
||||
refreshCmd := &cobra.Command{
|
||||
Use: "refresh [pluginName]",
|
||||
Short: "Reload a plugin without restarting Navidrome",
|
||||
Long: "Reload and recompile a plugin without needing to restart Navidrome",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: pluginRefresh,
|
||||
}
|
||||
|
||||
devCmd := &cobra.Command{
|
||||
Use: "dev [folder_path]",
|
||||
Short: "Create symlink to development folder",
|
||||
Long: "Create a symlink from a plugin development folder to the plugins directory for easier development",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: pluginDev,
|
||||
}
|
||||
|
||||
pluginCmd.AddCommand(listCmd, infoCmd, installCmd, removeCmd, updateCmd, refreshCmd, devCmd)
|
||||
rootCmd.AddCommand(pluginCmd)
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
|
||||
func validatePluginPackageFile(path string) error {
|
||||
if !utils.FileExists(path) {
|
||||
return fmt.Errorf("plugin package not found: %s", path)
|
||||
}
|
||||
if filepath.Ext(path) != pluginPackageExtension {
|
||||
return fmt.Errorf("not a valid plugin package: %s (expected %s extension)", path, pluginPackageExtension)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginDirectory(pluginsDir, pluginName string) (string, error) {
|
||||
pluginDir := filepath.Join(pluginsDir, pluginName)
|
||||
if !utils.FileExists(pluginDir) {
|
||||
return "", fmt.Errorf("plugin not found: %s (path: %s)", pluginName, pluginDir)
|
||||
}
|
||||
return pluginDir, nil
|
||||
}
|
||||
|
||||
func resolvePluginPath(pluginDir string) (resolvedPath string, isSymlink bool, err error) {
|
||||
// Check if it's a directory or a symlink
|
||||
lstat, err := os.Lstat(pluginDir)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("failed to stat plugin: %w", err)
|
||||
}
|
||||
|
||||
isSymlink = lstat.Mode()&os.ModeSymlink != 0
|
||||
|
||||
if isSymlink {
|
||||
// Resolve the symlink target
|
||||
targetDir, err := os.Readlink(pluginDir)
|
||||
if err != nil {
|
||||
return "", true, fmt.Errorf("failed to resolve symlink: %w", err)
|
||||
}
|
||||
|
||||
// If target is a relative path, make it absolute
|
||||
if !filepath.IsAbs(targetDir) {
|
||||
targetDir = filepath.Join(filepath.Dir(pluginDir), targetDir)
|
||||
}
|
||||
|
||||
// Verify the target exists and is a directory
|
||||
targetInfo, err := os.Stat(targetDir)
|
||||
if err != nil {
|
||||
return "", true, fmt.Errorf("failed to access symlink target %s: %w", targetDir, err)
|
||||
}
|
||||
|
||||
if !targetInfo.IsDir() {
|
||||
return "", true, fmt.Errorf("symlink target is not a directory: %s", targetDir)
|
||||
}
|
||||
|
||||
return targetDir, true, nil
|
||||
} else if !lstat.IsDir() {
|
||||
return "", false, fmt.Errorf("not a valid plugin directory: %s", pluginDir)
|
||||
}
|
||||
|
||||
return pluginDir, false, nil
|
||||
}
|
||||
|
||||
// Package handling helpers
|
||||
|
||||
func loadAndValidatePackage(ndpPath string) (*plugins.PluginPackage, error) {
|
||||
if err := validatePluginPackageFile(ndpPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pkg, err := plugins.LoadPackage(ndpPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load plugin package: %w", err)
|
||||
}
|
||||
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func extractAndSetupPlugin(ndpPath, targetDir string) error {
|
||||
if err := plugins.ExtractPackage(ndpPath, targetDir); err != nil {
|
||||
return fmt.Errorf("failed to extract plugin package: %w", err)
|
||||
}
|
||||
|
||||
ensurePluginDirPermissions(targetDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Display helpers
|
||||
|
||||
func displayPluginTableRow(w *tabwriter.Writer, discovery plugins.PluginDiscoveryEntry) {
|
||||
if discovery.Error != nil {
|
||||
// Handle global errors (like directory read failure)
|
||||
if discovery.ID == "" {
|
||||
log.Error("Failed to read plugins directory", "folder", conf.Server.Plugins.Folder, discovery.Error)
|
||||
return
|
||||
}
|
||||
// Handle individual plugin errors - show them in the table
|
||||
fmt.Fprintf(w, "%s\tERROR\tERROR\tERROR\tERROR\t%v\n", discovery.ID, discovery.Error)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark symlinks with an indicator
|
||||
nameDisplay := discovery.Manifest.Name
|
||||
if discovery.IsSymlink {
|
||||
nameDisplay = nameDisplay + " (dev)"
|
||||
}
|
||||
|
||||
// Convert capabilities to strings
|
||||
capabilities := slice.Map(discovery.Manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string {
|
||||
return string(cap)
|
||||
})
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
discovery.ID,
|
||||
nameDisplay,
|
||||
cmp.Or(discovery.Manifest.Author, "-"),
|
||||
cmp.Or(discovery.Manifest.Version, "-"),
|
||||
strings.Join(capabilities, ", "),
|
||||
cmp.Or(discovery.Manifest.Description, "-"))
|
||||
}
|
||||
|
||||
func displayTypedPermissions(permissions schema.PluginManifestPermissions, indent string) {
|
||||
if permissions.Http != nil {
|
||||
fmt.Printf("%shttp:\n", indent)
|
||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Http.Reason)
|
||||
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Http.AllowLocalNetwork)
|
||||
fmt.Printf("%s Allowed URLs:\n", indent)
|
||||
for urlPattern, methodEnums := range permissions.Http.AllowedUrls {
|
||||
methods := make([]string, len(methodEnums))
|
||||
for i, methodEnum := range methodEnums {
|
||||
methods[i] = string(methodEnum)
|
||||
}
|
||||
fmt.Printf("%s %s: [%s]\n", indent, urlPattern, strings.Join(methods, ", "))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if permissions.Config != nil {
|
||||
fmt.Printf("%sconfig:\n", indent)
|
||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Config.Reason)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if permissions.Scheduler != nil {
|
||||
fmt.Printf("%sscheduler:\n", indent)
|
||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Scheduler.Reason)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if permissions.Websocket != nil {
|
||||
fmt.Printf("%swebsocket:\n", indent)
|
||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Websocket.Reason)
|
||||
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Websocket.AllowLocalNetwork)
|
||||
fmt.Printf("%s Allowed URLs: [%s]\n", indent, strings.Join(permissions.Websocket.AllowedUrls, ", "))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if permissions.Cache != nil {
|
||||
fmt.Printf("%scache:\n", indent)
|
||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Cache.Reason)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if permissions.Artwork != nil {
|
||||
fmt.Printf("%sartwork:\n", indent)
|
||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Artwork.Reason)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if permissions.Subsonicapi != nil {
|
||||
allowedUsers := "All Users"
|
||||
if len(permissions.Subsonicapi.AllowedUsernames) > 0 {
|
||||
allowedUsers = strings.Join(permissions.Subsonicapi.AllowedUsernames, ", ")
|
||||
}
|
||||
fmt.Printf("%ssubsonicapi:\n", indent)
|
||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Subsonicapi.Reason)
|
||||
fmt.Printf("%s Allow Admins: %t\n", indent, permissions.Subsonicapi.AllowAdmins)
|
||||
fmt.Printf("%s Allowed Usernames: [%s]\n", indent, allowedUsers)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func displayPluginDetails(manifest *schema.PluginManifest, fileInfo *pluginFileInfo, permInfo *pluginPermissionInfo) {
|
||||
fmt.Println("\nPlugin Information:")
|
||||
fmt.Printf(" Name: %s\n", manifest.Name)
|
||||
fmt.Printf(" Author: %s\n", manifest.Author)
|
||||
fmt.Printf(" Version: %s\n", manifest.Version)
|
||||
fmt.Printf(" Description: %s\n", manifest.Description)
|
||||
|
||||
fmt.Print(" Capabilities: ")
|
||||
capabilities := make([]string, len(manifest.Capabilities))
|
||||
for i, cap := range manifest.Capabilities {
|
||||
capabilities[i] = string(cap)
|
||||
}
|
||||
fmt.Print(strings.Join(capabilities, ", "))
|
||||
fmt.Println()
|
||||
|
||||
// Display manifest permissions using the typed permissions
|
||||
fmt.Println(" Required Permissions:")
|
||||
displayTypedPermissions(manifest.Permissions, " ")
|
||||
|
||||
// Print file information if available
|
||||
if fileInfo != nil {
|
||||
fmt.Println("Package Information:")
|
||||
fmt.Printf(" File: %s\n", fileInfo.path)
|
||||
fmt.Printf(" Size: %d bytes (%.2f KB)\n", fileInfo.size, float64(fileInfo.size)/1024)
|
||||
fmt.Printf(" SHA-256: %s\n", fileInfo.hash)
|
||||
fmt.Printf(" Modified: %s\n", fileInfo.modTime.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Print file permissions information if available
|
||||
if permInfo != nil {
|
||||
fmt.Println("File Permissions:")
|
||||
fmt.Printf(" Plugin Directory: %s (%s)\n", permInfo.dirPath, permInfo.dirMode)
|
||||
if permInfo.isSymlink {
|
||||
fmt.Printf(" Symlink Target: %s (%s)\n", permInfo.targetPath, permInfo.targetMode)
|
||||
}
|
||||
fmt.Printf(" Manifest File: %s\n", permInfo.manifestMode)
|
||||
if permInfo.wasmMode != "" {
|
||||
fmt.Printf(" WASM File: %s\n", permInfo.wasmMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type pluginFileInfo struct {
|
||||
path string
|
||||
size int64
|
||||
hash string
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
type pluginPermissionInfo struct {
|
||||
dirPath string
|
||||
dirMode string
|
||||
isSymlink bool
|
||||
targetPath string
|
||||
targetMode string
|
||||
manifestMode string
|
||||
wasmMode string
|
||||
}
|
||||
|
||||
func getFileInfo(path string) *pluginFileInfo {
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Error("Failed to get file information", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &pluginFileInfo{
|
||||
path: path,
|
||||
size: fileInfo.Size(),
|
||||
hash: calculateSHA256(path),
|
||||
modTime: fileInfo.ModTime(),
|
||||
}
|
||||
}
|
||||
|
||||
func getPermissionInfo(pluginDir string) *pluginPermissionInfo {
|
||||
// Get plugin directory permissions
|
||||
dirInfo, err := os.Lstat(pluginDir)
|
||||
if err != nil {
|
||||
log.Error("Failed to get plugin directory permissions", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
permInfo := &pluginPermissionInfo{
|
||||
dirPath: pluginDir,
|
||||
dirMode: dirInfo.Mode().String(),
|
||||
}
|
||||
|
||||
// Check if it's a symlink
|
||||
if dirInfo.Mode()&os.ModeSymlink != 0 {
|
||||
permInfo.isSymlink = true
|
||||
|
||||
// Get target path and permissions
|
||||
targetPath, err := os.Readlink(pluginDir)
|
||||
if err == nil {
|
||||
if !filepath.IsAbs(targetPath) {
|
||||
targetPath = filepath.Join(filepath.Dir(pluginDir), targetPath)
|
||||
}
|
||||
permInfo.targetPath = targetPath
|
||||
|
||||
if targetInfo, err := os.Stat(targetPath); err == nil {
|
||||
permInfo.targetMode = targetInfo.Mode().String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get manifest file permissions
|
||||
manifestPath := filepath.Join(pluginDir, "manifest.json")
|
||||
if manifestInfo, err := os.Stat(manifestPath); err == nil {
|
||||
permInfo.manifestMode = manifestInfo.Mode().String()
|
||||
}
|
||||
|
||||
// Get WASM file permissions (look for .wasm files)
|
||||
entries, err := os.ReadDir(pluginDir)
|
||||
if err == nil {
|
||||
for _, entry := range entries {
|
||||
if filepath.Ext(entry.Name()) == ".wasm" {
|
||||
wasmPath := filepath.Join(pluginDir, entry.Name())
|
||||
if wasmInfo, err := os.Stat(wasmPath); err == nil {
|
||||
permInfo.wasmMode = wasmInfo.Mode().String()
|
||||
break // Just show the first WASM file found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return permInfo
|
||||
}
|
||||
|
||||
// Command implementations
|
||||
|
||||
func pluginList(cmd *cobra.Command, args []string) {
|
||||
discoveries := plugins.DiscoverPlugins(conf.Server.Plugins.Folder)
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tNAME\tAUTHOR\tVERSION\tCAPABILITIES\tDESCRIPTION")
|
||||
|
||||
for _, discovery := range discoveries {
|
||||
displayPluginTableRow(w, discovery)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func pluginInfo(cmd *cobra.Command, args []string) {
|
||||
path := args[0]
|
||||
pluginsDir := conf.Server.Plugins.Folder
|
||||
|
||||
var manifest *schema.PluginManifest
|
||||
var fileInfo *pluginFileInfo
|
||||
var permInfo *pluginPermissionInfo
|
||||
|
||||
if filepath.Ext(path) == pluginPackageExtension {
|
||||
// It's a package file
|
||||
pkg, err := loadAndValidatePackage(path)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load plugin package", err)
|
||||
}
|
||||
manifest = pkg.Manifest
|
||||
fileInfo = getFileInfo(path)
|
||||
// No permission info for package files
|
||||
} else {
|
||||
// It's a plugin name
|
||||
pluginDir, err := validatePluginDirectory(pluginsDir, path)
|
||||
if err != nil {
|
||||
log.Fatal("Plugin validation failed", err)
|
||||
}
|
||||
|
||||
manifest, err = plugins.LoadManifest(pluginDir)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load plugin manifest", err)
|
||||
}
|
||||
|
||||
// Get permission info for installed plugins
|
||||
permInfo = getPermissionInfo(pluginDir)
|
||||
}
|
||||
|
||||
displayPluginDetails(manifest, fileInfo, permInfo)
|
||||
}
|
||||
|
||||
func pluginInstall(cmd *cobra.Command, args []string) {
|
||||
ndpPath := args[0]
|
||||
pluginsDir := conf.Server.Plugins.Folder
|
||||
|
||||
pkg, err := loadAndValidatePackage(ndpPath)
|
||||
if err != nil {
|
||||
log.Fatal("Package validation failed", err)
|
||||
}
|
||||
|
||||
// Create target directory based on plugin name
|
||||
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
|
||||
|
||||
// Check if plugin already exists
|
||||
if utils.FileExists(targetDir) {
|
||||
log.Fatal("Plugin already installed", "name", pkg.Manifest.Name, "path", targetDir,
|
||||
"use", "navidrome plugin update")
|
||||
}
|
||||
|
||||
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
|
||||
log.Fatal("Plugin installation failed", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Plugin '%s' v%s installed successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
|
||||
}
|
||||
|
||||
func pluginRemove(cmd *cobra.Command, args []string) {
|
||||
pluginName := args[0]
|
||||
pluginsDir := conf.Server.Plugins.Folder
|
||||
|
||||
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
|
||||
if err != nil {
|
||||
log.Fatal("Plugin validation failed", err)
|
||||
}
|
||||
|
||||
_, isSymlink, err := resolvePluginPath(pluginDir)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to resolve plugin path", err)
|
||||
}
|
||||
|
||||
if isSymlink {
|
||||
// For symlinked plugins (dev mode), just remove the symlink
|
||||
if err := os.Remove(pluginDir); err != nil {
|
||||
log.Fatal("Failed to remove plugin symlink", "name", pluginName, err)
|
||||
}
|
||||
fmt.Printf("Development plugin symlink '%s' removed successfully (target directory preserved)\n", pluginName)
|
||||
} else {
|
||||
// For regular plugins, remove the entire directory
|
||||
if err := os.RemoveAll(pluginDir); err != nil {
|
||||
log.Fatal("Failed to remove plugin directory", "name", pluginName, err)
|
||||
}
|
||||
fmt.Printf("Plugin '%s' removed successfully\n", pluginName)
|
||||
}
|
||||
}
|
||||
|
||||
func pluginUpdate(cmd *cobra.Command, args []string) {
|
||||
ndpPath := args[0]
|
||||
pluginsDir := conf.Server.Plugins.Folder
|
||||
|
||||
pkg, err := loadAndValidatePackage(ndpPath)
|
||||
if err != nil {
|
||||
log.Fatal("Package validation failed", err)
|
||||
}
|
||||
|
||||
// Check if plugin exists
|
||||
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
|
||||
if !utils.FileExists(targetDir) {
|
||||
log.Fatal("Plugin not found", "name", pkg.Manifest.Name, "path", targetDir,
|
||||
"use", "navidrome plugin install")
|
||||
}
|
||||
|
||||
// Create a backup of the existing plugin
|
||||
backupDir := targetDir + ".bak." + time.Now().Format("20060102150405")
|
||||
if err := os.Rename(targetDir, backupDir); err != nil {
|
||||
log.Fatal("Failed to backup existing plugin", err)
|
||||
}
|
||||
|
||||
// Extract the new package
|
||||
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
|
||||
// Restore backup if extraction failed
|
||||
os.RemoveAll(targetDir)
|
||||
_ = os.Rename(backupDir, targetDir) // Ignore error as we're already in a fatal path
|
||||
log.Fatal("Plugin update failed", err)
|
||||
}
|
||||
|
||||
// Remove the backup
|
||||
os.RemoveAll(backupDir)
|
||||
|
||||
fmt.Printf("Plugin '%s' updated to v%s successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
|
||||
}
|
||||
|
||||
func pluginRefresh(cmd *cobra.Command, args []string) {
|
||||
pluginName := args[0]
|
||||
pluginsDir := conf.Server.Plugins.Folder
|
||||
|
||||
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
|
||||
if err != nil {
|
||||
log.Fatal("Plugin validation failed", err)
|
||||
}
|
||||
|
||||
resolvedPath, isSymlink, err := resolvePluginPath(pluginDir)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to resolve plugin path", err)
|
||||
}
|
||||
|
||||
if isSymlink {
|
||||
log.Debug("Processing symlinked plugin", "name", pluginName, "link", pluginDir, "target", resolvedPath)
|
||||
}
|
||||
|
||||
fmt.Printf("Refreshing plugin '%s'...\n", pluginName)
|
||||
|
||||
// Get the plugin manager and refresh
|
||||
mgr := GetPluginManager(cmd.Context())
|
||||
log.Debug("Scanning plugins directory", "path", pluginsDir)
|
||||
mgr.ScanPlugins()
|
||||
|
||||
log.Info("Waiting for plugin compilation to complete", "name", pluginName)
|
||||
|
||||
// Wait for compilation to complete
|
||||
if err := mgr.EnsureCompiled(pluginName); err != nil {
|
||||
log.Fatal("Failed to compile refreshed plugin", "name", pluginName, err)
|
||||
}
|
||||
|
||||
log.Info("Plugin compilation completed successfully", "name", pluginName)
|
||||
fmt.Printf("Plugin '%s' refreshed successfully\n", pluginName)
|
||||
}
|
||||
|
||||
func pluginDev(cmd *cobra.Command, args []string) {
|
||||
sourcePath, err := filepath.Abs(args[0])
|
||||
if err != nil {
|
||||
log.Fatal("Invalid path", "path", args[0], err)
|
||||
}
|
||||
pluginsDir := conf.Server.Plugins.Folder
|
||||
|
||||
// Validate source directory and manifest
|
||||
if err := validateDevSource(sourcePath); err != nil {
|
||||
log.Fatal("Source validation failed", err)
|
||||
}
|
||||
|
||||
// Load manifest to get plugin name
|
||||
manifest, err := plugins.LoadManifest(sourcePath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load plugin manifest", "path", filepath.Join(sourcePath, "manifest.json"), err)
|
||||
}
|
||||
|
||||
pluginName := cmp.Or(manifest.Name, filepath.Base(sourcePath))
|
||||
targetPath := filepath.Join(pluginsDir, pluginName)
|
||||
|
||||
// Handle existing target
|
||||
if err := handleExistingTarget(targetPath, sourcePath); err != nil {
|
||||
log.Fatal("Failed to handle existing target", err)
|
||||
}
|
||||
|
||||
// Create target directory if needed
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
log.Fatal("Failed to create plugins directory", "path", filepath.Dir(targetPath), err)
|
||||
}
|
||||
|
||||
// Create the symlink
|
||||
if err := os.Symlink(sourcePath, targetPath); err != nil {
|
||||
log.Fatal("Failed to create symlink", "source", sourcePath, "target", targetPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Development symlink created: '%s' -> '%s'\n", targetPath, sourcePath)
|
||||
fmt.Println("Plugin can be refreshed with: navidrome plugin refresh", pluginName)
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
func validateDevSource(sourcePath string) error {
|
||||
sourceInfo, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source folder not found: %s (%w)", sourcePath, err)
|
||||
}
|
||||
if !sourceInfo.IsDir() {
|
||||
return fmt.Errorf("source path is not a directory: %s", sourcePath)
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(sourcePath, "manifest.json")
|
||||
if !utils.FileExists(manifestPath) {
|
||||
return fmt.Errorf("source folder missing manifest.json: %s", sourcePath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleExistingTarget(targetPath, sourcePath string) error {
|
||||
if !utils.FileExists(targetPath) {
|
||||
return nil // Nothing to handle
|
||||
}
|
||||
|
||||
// Check if it's already a symlink to our source
|
||||
existingLink, err := os.Readlink(targetPath)
|
||||
if err == nil && existingLink == sourcePath {
|
||||
fmt.Printf("Symlink already exists and points to the correct source\n")
|
||||
return fmt.Errorf("symlink already exists") // This will cause early return in caller
|
||||
}
|
||||
|
||||
// Handle case where target exists but is not a symlink to our source
|
||||
fmt.Printf("Target path '%s' already exists.\n", targetPath)
|
||||
fmt.Print("Do you want to replace it? (y/N): ")
|
||||
var response string
|
||||
_, err = fmt.Scanln(&response)
|
||||
if err != nil || strings.ToLower(response) != "y" {
|
||||
if err != nil {
|
||||
log.Debug("Error reading input, assuming 'no'", err)
|
||||
}
|
||||
return fmt.Errorf("operation canceled")
|
||||
}
|
||||
|
||||
// Remove existing target
|
||||
if err := os.RemoveAll(targetPath); err != nil {
|
||||
return fmt.Errorf("failed to remove existing target %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePluginDirPermissions(dir string) {
|
||||
if err := os.Chmod(dir, pluginDirPermissions); err != nil {
|
||||
log.Error("Failed to set plugin directory permissions", "dir", dir, err)
|
||||
}
|
||||
|
||||
// Apply permissions to all files in the directory
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Error("Failed to read plugin directory", "dir", dir, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Error("Failed to stat file", "path", path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
mode := os.FileMode(pluginFilePermissions) // Files
|
||||
if info.IsDir() {
|
||||
mode = os.FileMode(pluginDirPermissions) // Directories
|
||||
ensurePluginDirPermissions(path) // Recursive
|
||||
}
|
||||
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
log.Error("Failed to set file permissions", "path", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func calculateSHA256(filePath string) string {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
log.Error("Failed to open file for hashing", err)
|
||||
return "N/A"
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
log.Error("Failed to calculate hash", err)
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var _ = Describe("Plugin CLI Commands", func() {
|
||||
var tempDir string
|
||||
var cmd *cobra.Command
|
||||
var stdOut *os.File
|
||||
var origStdout *os.File
|
||||
var outReader *os.File
|
||||
|
||||
// Helper to create a test plugin with the given name and details
|
||||
createTestPlugin := func(name, author, version string, capabilities []string) string {
|
||||
pluginDir := filepath.Join(tempDir, name)
|
||||
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
||||
|
||||
// Create a properly formatted capabilities JSON array
|
||||
capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"`
|
||||
|
||||
manifest := `{
|
||||
"name": "` + name + `",
|
||||
"author": "` + author + `",
|
||||
"version": "` + version + `",
|
||||
"description": "Plugin for testing",
|
||||
"website": "https://test.navidrome.org/` + name + `",
|
||||
"capabilities": [` + capabilitiesJSON + `],
|
||||
"permissions": {}
|
||||
}`
|
||||
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||
|
||||
// Create a dummy WASM file
|
||||
wasmContent := []byte("dummy wasm content for testing")
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
|
||||
|
||||
return pluginDir
|
||||
}
|
||||
|
||||
// Helper to execute a command and return captured output
|
||||
captureOutput := func(reader io.Reader) string {
|
||||
stdOut.Close()
|
||||
outputBytes, err := io.ReadAll(reader)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return string(outputBytes)
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
|
||||
// Setup config
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tempDir
|
||||
|
||||
// Create a command for testing
|
||||
cmd = &cobra.Command{Use: "test"}
|
||||
|
||||
// Setup stdout capture
|
||||
origStdout = os.Stdout
|
||||
var err error
|
||||
outReader, stdOut, err = os.Pipe()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
os.Stdout = stdOut
|
||||
|
||||
DeferCleanup(func() {
|
||||
os.Stdout = origStdout
|
||||
})
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.Stdout = origStdout
|
||||
if stdOut != nil {
|
||||
stdOut.Close()
|
||||
}
|
||||
if outReader != nil {
|
||||
outReader.Close()
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Plugin list command", func() {
|
||||
It("should list installed plugins", func() {
|
||||
// Create test plugins
|
||||
createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
||||
createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"})
|
||||
|
||||
// Execute command
|
||||
pluginList(cmd, []string{})
|
||||
|
||||
// Verify output
|
||||
output := captureOutput(outReader)
|
||||
|
||||
Expect(output).To(ContainSubstring("plugin1"))
|
||||
Expect(output).To(ContainSubstring("Test Author"))
|
||||
Expect(output).To(ContainSubstring("1.0.0"))
|
||||
Expect(output).To(ContainSubstring("MetadataAgent"))
|
||||
|
||||
Expect(output).To(ContainSubstring("plugin2"))
|
||||
Expect(output).To(ContainSubstring("Another Author"))
|
||||
Expect(output).To(ContainSubstring("2.1.0"))
|
||||
Expect(output).To(ContainSubstring("Scrobbler"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin info command", func() {
|
||||
It("should display information about an installed plugin", func() {
|
||||
// Create test plugin with multiple capabilities
|
||||
createTestPlugin("test-plugin", "Test Author", "1.0.0",
|
||||
[]string{"MetadataAgent", "Scrobbler"})
|
||||
|
||||
// Execute command
|
||||
pluginInfo(cmd, []string{"test-plugin"})
|
||||
|
||||
// Verify output
|
||||
output := captureOutput(outReader)
|
||||
|
||||
Expect(output).To(ContainSubstring("Name: test-plugin"))
|
||||
Expect(output).To(ContainSubstring("Author: Test Author"))
|
||||
Expect(output).To(ContainSubstring("Version: 1.0.0"))
|
||||
Expect(output).To(ContainSubstring("Description: Plugin for testing"))
|
||||
Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin remove command", func() {
|
||||
It("should remove a regular plugin directory", func() {
|
||||
// Create test plugin
|
||||
pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0",
|
||||
[]string{"MetadataAgent"})
|
||||
|
||||
// Execute command
|
||||
pluginRemove(cmd, []string{"regular-plugin"})
|
||||
|
||||
// Verify output
|
||||
output := captureOutput(outReader)
|
||||
Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully"))
|
||||
|
||||
// Verify directory is actually removed
|
||||
_, err := os.Stat(pluginDir)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should remove only the symlink for a development plugin", func() {
|
||||
// Create a real source directory
|
||||
sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source")
|
||||
Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed())
|
||||
|
||||
manifest := `{
|
||||
"name": "dev-plugin",
|
||||
"author": "Dev Author",
|
||||
"version": "0.1.0",
|
||||
"description": "Development plugin for testing",
|
||||
"website": "https://test.navidrome.org/dev-plugin",
|
||||
"capabilities": ["Scrobbler"],
|
||||
"permissions": {}
|
||||
}`
|
||||
Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||
|
||||
// Create a dummy WASM file
|
||||
wasmContent := []byte("dummy wasm content for testing")
|
||||
Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
|
||||
|
||||
// Create a symlink in the plugins directory
|
||||
symlinkPath := filepath.Join(tempDir, "dev-plugin")
|
||||
Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed())
|
||||
|
||||
// Execute command
|
||||
pluginRemove(cmd, []string{"dev-plugin"})
|
||||
|
||||
// Verify output
|
||||
output := captureOutput(outReader)
|
||||
Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully"))
|
||||
Expect(output).To(ContainSubstring("target directory preserved"))
|
||||
|
||||
// Verify the symlink is removed but source directory exists
|
||||
_, err := os.Lstat(symlinkPath)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
||||
_, err = os.Stat(sourceDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -330,16 +330,13 @@ func startPlaybackServer(ctx context.Context) func() error {
|
||||
// startPluginManager starts the plugin manager, if configured.
|
||||
func startPluginManager(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
manager := GetPluginManager(ctx)
|
||||
if !conf.Server.Plugins.Enabled {
|
||||
log.Debug("Plugins are DISABLED")
|
||||
log.Debug("Plugin system is DISABLED")
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Starting plugin manager")
|
||||
// Get the manager instance and scan for plugins
|
||||
manager := GetPluginManager(ctx)
|
||||
manager.ScanPlugins()
|
||||
|
||||
return nil
|
||||
return manager.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
cmd/scan.go
46
cmd/scan.go
@@ -1,9 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@@ -19,12 +22,14 @@ var (
|
||||
fullScan bool
|
||||
subprocess bool
|
||||
targets []string
|
||||
targetFile string
|
||||
)
|
||||
|
||||
func init() {
|
||||
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
||||
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
|
||||
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
|
||||
scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
|
||||
rootCmd.AddCommand(scanCmd)
|
||||
}
|
||||
|
||||
@@ -71,10 +76,17 @@ func runScanner(ctx context.Context) {
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := core.NewPlaylists(ds)
|
||||
|
||||
// Parse targets if provided
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
if len(targets) > 0 {
|
||||
var err error
|
||||
var err error
|
||||
|
||||
if targetFile != "" {
|
||||
scanTargets, err = readTargetsFromFile(targetFile)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to read targets from file", err)
|
||||
}
|
||||
log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
|
||||
} else if len(targets) > 0 {
|
||||
scanTargets, err = model.ParseTargets(targets)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to parse targets", err)
|
||||
@@ -94,3 +106,31 @@ func runScanner(ctx context.Context) {
|
||||
trackScanInteractively(ctx, progress)
|
||||
}
|
||||
}
|
||||
|
||||
// readTargetsFromFile reads scan targets from a file, one per line.
|
||||
// Each line should be in the format "libraryID:folderPath".
|
||||
// Empty lines and lines starting with # are ignored.
|
||||
func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open target file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var targetStrings []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
// Skip empty lines and comments
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
targetStrings = append(targetStrings, line)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read target file: %w", err)
|
||||
}
|
||||
|
||||
return model.ParseTargets(targetStrings)
|
||||
}
|
||||
|
||||
89
cmd/scan_test.go
Normal file
89
cmd/scan_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("readTargetsFromFile", func() {
|
||||
var tempDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tempDir, err = os.MkdirTemp("", "navidrome-test-")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tempDir)
|
||||
})
|
||||
|
||||
It("reads valid targets from file", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(3))
|
||||
Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"}))
|
||||
Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"}))
|
||||
Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"}))
|
||||
})
|
||||
|
||||
It("skips empty lines", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "1:Music/Rock\n\n2:Music/Jazz\n\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("trims whitespace", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := " 1:Music/Rock \n\t2:Music/Jazz\t\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
|
||||
Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
|
||||
})
|
||||
|
||||
It("returns error for non-existent file", func() {
|
||||
_, err := readTargetsFromFile("/nonexistent/file.txt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to open target file"))
|
||||
})
|
||||
|
||||
It("returns error for invalid target format", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "invalid-format\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = readTargetsFromFile(filePath)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("handles mixed valid and empty lines", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
@@ -47,9 +47,7 @@ func CreateServer() *server.Server {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
insights := metrics.GetInstance(dataStore, manager)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
serverServer := server.New(dataStore, broker, insights)
|
||||
return serverServer
|
||||
}
|
||||
@@ -59,21 +57,21 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
insights := metrics.GetInstance(dataStore, manager)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
manager := plugins.GetManager(dataStore, broker)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance, manager)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -82,8 +80,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
broker := events.GetBroker()
|
||||
manager := plugins.GetManager(dataStore, broker)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
@@ -93,8 +91,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
@@ -107,8 +105,8 @@ func CreatePublicRouter() *public.Router {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
broker := events.GetBroker()
|
||||
manager := plugins.GetManager(dataStore, broker)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
@@ -137,9 +135,7 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||
func CreateInsights() metrics.Insights {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
insights := metrics.GetInstance(dataStore, manager)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
return insights
|
||||
}
|
||||
|
||||
@@ -155,14 +151,14 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
broker := events.GetBroker()
|
||||
manager := plugins.GetManager(dataStore, broker)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
@@ -172,14 +168,14 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
broker := events.GetBroker()
|
||||
manager := plugins.GetManager(dataStore, broker)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
@@ -192,19 +188,19 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
func getPluginManager() plugins.Manager {
|
||||
func getPluginManager() *plugins.Manager {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
broker := events.GetBroker()
|
||||
manager := plugins.GetManager(dataStore, broker)
|
||||
return manager
|
||||
}
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||
return manager
|
||||
|
||||
@@ -39,12 +39,12 @@ var allProviders = wire.NewSet(
|
||||
events.GetBroker,
|
||||
scanner.New,
|
||||
scanner.GetWatcher,
|
||||
plugins.GetManager,
|
||||
metrics.GetPrometheusInstance,
|
||||
db.Db,
|
||||
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
|
||||
wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)),
|
||||
plugins.GetManager,
|
||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
||||
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
||||
)
|
||||
|
||||
@@ -120,13 +120,13 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
))
|
||||
}
|
||||
|
||||
func getPluginManager() plugins.Manager {
|
||||
func getPluginManager() *plugins.Manager {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||
return manager
|
||||
|
||||
@@ -89,8 +89,7 @@ type configOptions struct {
|
||||
PasswordEncryptionKey string
|
||||
ExtAuth extAuthOptions
|
||||
Plugins pluginsOptions
|
||||
PluginConfig map[string]map[string]string
|
||||
HTTPSecurityHeaders secureOptions `json:",omitzero"`
|
||||
HTTPHeaders httpHeaderOptions `json:",omitzero"`
|
||||
Prometheus prometheusOptions `json:",omitzero"`
|
||||
Scanner scannerOptions `json:",omitzero"`
|
||||
Jukebox jukeboxOptions `json:",omitzero"`
|
||||
@@ -188,8 +187,8 @@ type listenBrainzOptions struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type secureOptions struct {
|
||||
CustomFrameOptionsValue string
|
||||
type httpHeaderOptions struct {
|
||||
FrameOptions string
|
||||
}
|
||||
|
||||
type prometheusOptions struct {
|
||||
@@ -226,9 +225,11 @@ type inspectOptions struct {
|
||||
}
|
||||
|
||||
type pluginsOptions struct {
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
type extAuthOptions struct {
|
||||
@@ -257,6 +258,7 @@ func Load(noConfigDump bool) {
|
||||
// Map deprecated options to their new names for backwards compatibility
|
||||
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
@@ -344,6 +346,8 @@ func Load(noConfigDump bool) {
|
||||
// Log configuration source
|
||||
if Server.ConfigFile != "" {
|
||||
log.Info("Loaded configuration", "file", Server.ConfigFile)
|
||||
} else if hasNDEnvVars() {
|
||||
log.Info("No configuration file found. Loaded configuration only from environment variables")
|
||||
} else {
|
||||
log.Warn("No configuration file found. Using default values. To specify a config file, use the --configfile flag or set the ND_CONFIGFILE environment variable.")
|
||||
}
|
||||
@@ -365,10 +369,12 @@ func Load(noConfigDump bool) {
|
||||
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
|
||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
||||
}
|
||||
logDeprecatedOptions("Scanner.GenreSeparators")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
logDeprecatedOptions("ReverseProxyWhitelist", "ReverseProxyUserHeader")
|
||||
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
@@ -376,16 +382,22 @@ func Load(noConfigDump bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func logDeprecatedOptions(options ...string) {
|
||||
for _, option := range options {
|
||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
||||
if os.Getenv(envVar) != "" {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", envVar))
|
||||
}
|
||||
if viper.InConfig(option) {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", option))
|
||||
func logDeprecatedOptions(oldName, newName string) {
|
||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
|
||||
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
|
||||
logWarning := func(oldName, newName string) {
|
||||
if newName != "" {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
|
||||
} else {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", oldName))
|
||||
}
|
||||
}
|
||||
if os.Getenv(envVar) != "" {
|
||||
logWarning(envVar, newEnvVar)
|
||||
}
|
||||
if viper.InConfig(oldName) {
|
||||
logWarning(oldName, newName)
|
||||
}
|
||||
}
|
||||
|
||||
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
|
||||
@@ -502,6 +514,16 @@ func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
}
|
||||
|
||||
// hasNDEnvVars checks if any ND_ prefixed environment variables are set (excluding ND_CONFIGFILE)
|
||||
func hasNDEnvVars() bool {
|
||||
for _, env := range os.Environ() {
|
||||
if strings.HasPrefix(env, "ND_") && !strings.HasPrefix(env, "ND_CONFIGFILE=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setViperDefaults() {
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("cachefolder", "")
|
||||
@@ -586,7 +608,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
|
||||
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
@@ -600,7 +622,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||
viper.SetDefault("backup.path", "")
|
||||
viper.SetDefault("backup.schedule", "")
|
||||
viper.SetDefault("backup.count", 0)
|
||||
@@ -612,7 +634,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("plugins.folder", "")
|
||||
viper.SetDefault("plugins.enabled", false)
|
||||
viper.SetDefault("plugins.cachesize", "100MB")
|
||||
viper.SetDefault("plugins.cachesize", "200MB")
|
||||
viper.SetDefault("plugins.autoreload", false)
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
|
||||
@@ -150,6 +150,8 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
var HTTPUserAgent = "Navidrome" + "/" + Version
|
||||
|
||||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
// TODO This will be dynamic when using disambiguation
|
||||
|
||||
@@ -64,6 +64,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
||||
if a.pluginLoader != nil {
|
||||
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
|
||||
}
|
||||
log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
|
||||
|
||||
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
||||
|
||||
@@ -354,6 +355,9 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string)
|
||||
continue
|
||||
}
|
||||
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent GetAlbumImages failed", "agent", ag.AgentName(), "album", name, "artist", artist, "mbid", mbid, err)
|
||||
}
|
||||
if len(images) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
|
||||
"mbid", mbid, "elapsed", time.Since(start))
|
||||
|
||||
@@ -43,6 +43,7 @@ func newClient(hc httpDoer, language string) *client {
|
||||
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
||||
params := url.Values{}
|
||||
params.Add("q", name)
|
||||
params.Add("order", "RANKING")
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package deezer
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -82,10 +83,20 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name)
|
||||
for i := range artists {
|
||||
log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link)
|
||||
if i > 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If the first one has the same name, that's the one
|
||||
if !strings.EqualFold(artists[0].Name, name) {
|
||||
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
|
||||
return &artists[0], err
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ func fromAlbumExternalSource(ctx context.Context, al model.Album, provider exter
|
||||
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
|
||||
hc := http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||
req.Header.Set("User-Agent", consts.HTTPUserAgent)
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
@@ -23,7 +23,8 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
@@ -37,18 +38,12 @@ var (
|
||||
)
|
||||
|
||||
type insightsCollector struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
lastRun atomic.Int64
|
||||
lastStatus atomic.Bool
|
||||
ds model.DataStore
|
||||
lastRun atomic.Int64
|
||||
lastStatus atomic.Bool
|
||||
}
|
||||
|
||||
// PluginLoader defines an interface for loading plugins
|
||||
type PluginLoader interface {
|
||||
PluginList() map[string]schema.PluginManifest
|
||||
}
|
||||
|
||||
func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
|
||||
func GetInstance(ds model.DataStore) Insights {
|
||||
return singleton.GetInstance(func() *insightsCollector {
|
||||
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
|
||||
if err != nil {
|
||||
@@ -60,7 +55,7 @@ func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
|
||||
}
|
||||
}
|
||||
insightsID = id
|
||||
return &insightsCollector{ds: ds, pluginLoader: pluginLoader}
|
||||
return &insightsCollector{ds: ds}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -319,12 +314,16 @@ func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error)
|
||||
|
||||
// collectPlugins collects information about installed plugins
|
||||
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
|
||||
plugins := make(map[string]insights.PluginInfo)
|
||||
for id, manifest := range c.pluginLoader.PluginList() {
|
||||
plugins[id] = insights.PluginInfo{
|
||||
Name: manifest.Name,
|
||||
Version: manifest.Version,
|
||||
// TODO Fix import/inject cycles
|
||||
manager := plugins.GetManager(c.ds, events.GetBroker())
|
||||
info := manager.GetPluginInfo()
|
||||
|
||||
result := make(map[string]insights.PluginInfo, len(info))
|
||||
for name, p := range info {
|
||||
result[name] = insights.PluginInfo{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
}
|
||||
}
|
||||
return plugins
|
||||
return result
|
||||
}
|
||||
|
||||
81
core/publicurl/publicurl.go
Normal file
81
core/publicurl/publicurl.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package publicurl
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// ImageURL generates a public URL for artwork images.
|
||||
// It creates a signed token for the artwork ID and builds a complete public URL.
|
||||
func ImageURL(req *http.Request, artID model.ArtworkID, size int) string {
|
||||
token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()})
|
||||
uri := path.Join(consts.URLPathPublicImages, token)
|
||||
params := url.Values{}
|
||||
if size > 0 {
|
||||
params.Add("size", strconv.Itoa(size))
|
||||
}
|
||||
return PublicURL(req, uri, params)
|
||||
}
|
||||
|
||||
// PublicURL builds a full URL for public-facing resources.
|
||||
// It uses ShareURL from config if available, otherwise falls back to extracting
|
||||
// the scheme and host from the provided http.Request.
|
||||
// If req is nil and ShareURL is not set, it defaults to http://localhost.
|
||||
func PublicURL(req *http.Request, u string, params url.Values) string {
|
||||
if conf.Server.ShareURL == "" {
|
||||
return AbsoluteURL(req, u, params)
|
||||
}
|
||||
shareUrl, err := url.Parse(conf.Server.ShareURL)
|
||||
if err != nil {
|
||||
return AbsoluteURL(req, u, params)
|
||||
}
|
||||
buildUrl, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return AbsoluteURL(req, u, params)
|
||||
}
|
||||
buildUrl.Scheme = shareUrl.Scheme
|
||||
buildUrl.Host = shareUrl.Host
|
||||
if len(params) > 0 {
|
||||
buildUrl.RawQuery = params.Encode()
|
||||
}
|
||||
return buildUrl.String()
|
||||
}
|
||||
|
||||
// AbsoluteURL builds an absolute URL from a relative path.
|
||||
// It uses BaseHost/BaseScheme from config if available, otherwise extracts
|
||||
// the scheme and host from the http.Request.
|
||||
// If req is nil and BaseHost is not set, it defaults to http://localhost.
|
||||
func AbsoluteURL(req *http.Request, u string, params url.Values) string {
|
||||
buildUrl, err := url.Parse(u)
|
||||
if err != nil {
|
||||
log.Error(req.Context(), "Failed to parse URL path", "url", u, err)
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(u, "/") {
|
||||
buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path)
|
||||
if conf.Server.BaseHost != "" {
|
||||
buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http")
|
||||
buildUrl.Host = conf.Server.BaseHost
|
||||
} else if req != nil {
|
||||
buildUrl.Scheme = req.URL.Scheme
|
||||
buildUrl.Host = req.Host
|
||||
} else {
|
||||
buildUrl.Scheme = "http"
|
||||
buildUrl.Host = "localhost"
|
||||
}
|
||||
}
|
||||
if len(params) > 0 {
|
||||
buildUrl.RawQuery = params.Encode()
|
||||
}
|
||||
return buildUrl.String()
|
||||
}
|
||||
174
core/publicurl/publicurl_test.go
Normal file
174
core/publicurl/publicurl_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package publicurl_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/publicurl"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPublicURL(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Public URL Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("Public URL Utilities", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
Describe("PublicURL", func() {
|
||||
When("ShareURL is set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = "https://share.example.com"
|
||||
})
|
||||
|
||||
It("uses ShareURL as the base", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.PublicURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
|
||||
})
|
||||
|
||||
It("includes query parameters", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
params := url.Values{"size": []string{"300"}, "format": []string{"png"}}
|
||||
result := publicurl.PublicURL(r, "/image/123", params)
|
||||
Expect(result).To(ContainSubstring("https://share.example.com/image/123"))
|
||||
Expect(result).To(ContainSubstring("size=300"))
|
||||
Expect(result).To(ContainSubstring("format=png"))
|
||||
})
|
||||
|
||||
It("works without a request", func() {
|
||||
result := publicurl.PublicURL(nil, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
|
||||
})
|
||||
})
|
||||
|
||||
When("ShareURL is not set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = ""
|
||||
})
|
||||
|
||||
It("falls back to AbsoluteURL with request", func() {
|
||||
r, _ := http.NewRequest("GET", "https://myserver.com/test", nil)
|
||||
r.Host = "myserver.com"
|
||||
result := publicurl.PublicURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://myserver.com/path/to/resource"))
|
||||
})
|
||||
|
||||
It("falls back to localhost without request", func() {
|
||||
result := publicurl.PublicURL(nil, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("http://localhost/path/to/resource"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AbsoluteURL", func() {
|
||||
When("BaseHost is set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.BaseHost = "configured.example.com"
|
||||
conf.Server.BaseScheme = "https"
|
||||
conf.Server.BasePath = ""
|
||||
})
|
||||
|
||||
It("uses BaseHost and BaseScheme", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://configured.example.com/path/to/resource"))
|
||||
})
|
||||
|
||||
It("defaults to http scheme if BaseScheme is empty", func() {
|
||||
conf.Server.BaseScheme = ""
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("http://configured.example.com/path/to/resource"))
|
||||
})
|
||||
})
|
||||
|
||||
When("BaseHost is not set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.BaseHost = ""
|
||||
conf.Server.BasePath = ""
|
||||
})
|
||||
|
||||
It("extracts host from request", func() {
|
||||
r, _ := http.NewRequest("GET", "https://request.example.com/test", nil)
|
||||
r.Host = "request.example.com"
|
||||
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://request.example.com/path/to/resource"))
|
||||
})
|
||||
|
||||
It("falls back to localhost without request", func() {
|
||||
result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("http://localhost/path/to/resource"))
|
||||
})
|
||||
})
|
||||
|
||||
When("BasePath is set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.BasePath = "/navidrome"
|
||||
conf.Server.BaseHost = "example.com"
|
||||
conf.Server.BaseScheme = "https"
|
||||
})
|
||||
|
||||
It("prepends BasePath to the URL", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://example.com/navidrome/path/to/resource"))
|
||||
})
|
||||
})
|
||||
|
||||
It("passes through absolute URLs unchanged", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil)
|
||||
Expect(result).To(Equal("https://other.example.com/path"))
|
||||
})
|
||||
|
||||
It("includes query parameters", func() {
|
||||
conf.Server.BaseHost = "example.com"
|
||||
conf.Server.BaseScheme = "https"
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
params := url.Values{"key": []string{"value"}}
|
||||
result := publicurl.AbsoluteURL(r, "/path", params)
|
||||
Expect(result).To(Equal("https://example.com/path?key=value"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ImageURL", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = "https://share.example.com"
|
||||
// Initialize JWT auth for token generation
|
||||
auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
|
||||
})
|
||||
|
||||
It("generates a URL with the artwork token", func() {
|
||||
artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil)
|
||||
result := publicurl.ImageURL(nil, artID, 0)
|
||||
Expect(result).To(HavePrefix("https://share.example.com/share/img/"))
|
||||
})
|
||||
|
||||
It("includes size parameter when provided", func() {
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil)
|
||||
result := publicurl.ImageURL(nil, artID, 300)
|
||||
Expect(result).To(ContainSubstring("size=300"))
|
||||
})
|
||||
|
||||
It("omits size parameter when zero", func() {
|
||||
artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil)
|
||||
result := publicurl.ImageURL(nil, artID, 0)
|
||||
Expect(result).ToNot(ContainSubstring("size="))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@ type Submission struct {
|
||||
}
|
||||
|
||||
type nowPlayingEntry struct {
|
||||
ctx context.Context
|
||||
userId string
|
||||
track *model.MediaFile
|
||||
position int
|
||||
@@ -220,15 +221,17 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
|
||||
}
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if player.ScrobbleEnabled {
|
||||
p.enqueueNowPlaying(playerId, user.ID, mf, position)
|
||||
p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *playTracker) enqueueNowPlaying(playerId string, userId string, track *model.MediaFile, position int) {
|
||||
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
|
||||
p.npMu.Lock()
|
||||
defer p.npMu.Unlock()
|
||||
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
|
||||
p.npQueue[playerId] = nowPlayingEntry{
|
||||
ctx: ctx,
|
||||
userId: userId,
|
||||
track: track,
|
||||
position: position,
|
||||
@@ -267,7 +270,7 @@ func (p *playTracker) nowPlayingWorker() {
|
||||
|
||||
// Process entries without holding lock
|
||||
for _, entry := range entries {
|
||||
p.dispatchNowPlaying(context.Background(), entry.userId, entry.track, entry.position)
|
||||
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,17 @@ var _ = Describe("PlayTracker", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(eventBroker.getEvents()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("passes user to scrobbler via context (fix for issue #4787)", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
// Verify the username was passed through async dispatch via context
|
||||
Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetNowPlaying", func() {
|
||||
@@ -428,6 +439,7 @@ type fakeScrobbler struct {
|
||||
nowPlayingCalled atomic.Bool
|
||||
ScrobbleCalled atomic.Bool
|
||||
userID atomic.Pointer[string]
|
||||
username atomic.Pointer[string]
|
||||
track atomic.Pointer[model.MediaFile]
|
||||
position atomic.Int32
|
||||
LastScrobble atomic.Pointer[Scrobble]
|
||||
@@ -453,6 +465,13 @@ func (f *fakeScrobbler) GetPosition() int {
|
||||
return int(f.position.Load())
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) GetUsername() string {
|
||||
if p := f.username.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
return f.Error == nil && f.Authorized
|
||||
}
|
||||
@@ -463,6 +482,16 @@ func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *mo
|
||||
return f.Error
|
||||
}
|
||||
f.userID.Store(&userId)
|
||||
// Capture username from context (this is what plugin scrobblers do)
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
if u, ok := request.UserFrom(ctx); ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
if username != "" {
|
||||
f.username.Store(&username)
|
||||
}
|
||||
f.track.Store(track)
|
||||
f.position.Store(int32(position))
|
||||
return nil
|
||||
|
||||
15
db/migrations/20251227192712_create_plugin_table.sql
Normal file
15
db/migrations/20251227192712_create_plugin_table.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS plugin (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
manifest TEXT NOT NULL,
|
||||
config TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
sha256 TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS plugin;
|
||||
43
go.mod
43
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-sdk v1.7.1
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/cors v1.2.2
|
||||
@@ -36,16 +37,15 @@ require (
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
github.com/kardianos/service v1.2.4
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/knqyf263/go-plugin v0.9.0
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/maruel/natural v1.2.1
|
||||
github.com/maruel/natural v1.3.0
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.27.2
|
||||
github.com/onsi/gomega v1.38.2
|
||||
github.com/onsi/ginkgo/v2 v2.27.3
|
||||
github.com/onsi/gomega v1.38.3
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
@@ -54,22 +54,20 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tetratelabs/wazero v1.10.1
|
||||
github.com/tetratelabs/wazero v1.11.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||
golang.org/x/image v0.33.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/term v0.37.0
|
||||
golang.org/x/text v0.31.0
|
||||
golang.org/x/image v0.34.0
|
||||
golang.org/x/net v0.48.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sys v0.39.0
|
||||
golang.org/x/term v0.38.0
|
||||
golang.org/x/text v0.32.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -84,17 +82,20 @@ require (
|
||||
github.com/creack/pty v1.1.11 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect
|
||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
@@ -125,14 +126,18 @@ require (
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
86
go.sum
86
go.sum
@@ -55,6 +55,10 @@ github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
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-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=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
@@ -87,6 +91,8 @@ github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdM
|
||||
github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
@@ -99,8 +105,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f h1:HU1RgM6NALf/KW9HEY6zry3ADbDKcmpQ+hJedoNGQYQ=
|
||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -118,6 +124,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b h1:ogbOPx86mIhFy764gGkqnkFC8m5PJA7sPzlk9ppLVQA=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
||||
@@ -134,8 +142,6 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knqyf263/go-plugin v0.9.0 h1:CQs2+lOPIlkZVtcb835ZYDEoyyWJWLbSTWeCs0EwTwI=
|
||||
github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -162,8 +168,8 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/maruel/natural v1.2.1 h1:G/y4pwtTA07lbQsMefvsmEO0VN0NfqpxprxXDM4R/4o=
|
||||
github.com/maruel/natural v1.2.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -186,10 +192,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
||||
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@@ -244,8 +250,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -265,8 +271,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
|
||||
github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
|
||||
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
|
||||
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
|
||||
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
@@ -284,6 +292,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -298,20 +308,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -323,8 +333,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -332,8 +342,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -350,11 +360,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo=
|
||||
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
|
||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA=
|
||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -363,8 +373,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -375,8 +385,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -386,12 +396,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
20
log/log.go
20
log/log.go
@@ -88,11 +88,11 @@ func SetLevel(l Level) {
|
||||
}
|
||||
|
||||
func SetLevelString(l string) {
|
||||
level := levelFromString(l)
|
||||
level := ParseLogLevel(l)
|
||||
SetLevel(level)
|
||||
}
|
||||
|
||||
func levelFromString(l string) Level {
|
||||
func ParseLogLevel(l string) Level {
|
||||
envLevel := strings.ToLower(l)
|
||||
var level Level
|
||||
switch envLevel {
|
||||
@@ -118,7 +118,7 @@ func SetLogLevels(levels map[string]string) {
|
||||
defer loggerMu.Unlock()
|
||||
logLevels = nil
|
||||
for k, v := range levels {
|
||||
logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)})
|
||||
logLevels = append(logLevels, levelPath{path: k, level: ParseLogLevel(v)})
|
||||
}
|
||||
sort.Slice(logLevels, func(i, j int) bool {
|
||||
return logLevels[i].path > logLevels[j].path
|
||||
@@ -185,31 +185,31 @@ func IsGreaterOrEqualTo(level Level) bool {
|
||||
}
|
||||
|
||||
func Fatal(args ...interface{}) {
|
||||
log(LevelFatal, args...)
|
||||
Log(LevelFatal, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func Error(args ...interface{}) {
|
||||
log(LevelError, args...)
|
||||
Log(LevelError, args...)
|
||||
}
|
||||
|
||||
func Warn(args ...interface{}) {
|
||||
log(LevelWarn, args...)
|
||||
Log(LevelWarn, args...)
|
||||
}
|
||||
|
||||
func Info(args ...interface{}) {
|
||||
log(LevelInfo, args...)
|
||||
Log(LevelInfo, args...)
|
||||
}
|
||||
|
||||
func Debug(args ...interface{}) {
|
||||
log(LevelDebug, args...)
|
||||
Log(LevelDebug, args...)
|
||||
}
|
||||
|
||||
func Trace(args ...interface{}) {
|
||||
log(LevelTrace, args...)
|
||||
Log(LevelTrace, args...)
|
||||
}
|
||||
|
||||
func log(level Level, args ...interface{}) {
|
||||
func Log(level Level, args ...interface{}) {
|
||||
if !shouldLog(level, 3) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type DataStore interface {
|
||||
UserProps(ctx context.Context) UserPropsRepository
|
||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||
Scrobble(ctx context.Context) ScrobbleRepository
|
||||
Plugin(ctx context.Context) PluginRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
|
||||
26
model/plugin.go
Normal file
26
model/plugin.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Plugin struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Manifest string `structs:"manifest" json:"manifest"`
|
||||
Config string `structs:"config" json:"config,omitempty"`
|
||||
Enabled bool `structs:"enabled" json:"enabled"`
|
||||
LastError string `structs:"last_error" json:"lastError,omitempty"`
|
||||
SHA256 string `structs:"sha256" json:"sha256"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Plugins []Plugin
|
||||
|
||||
type PluginRepository interface {
|
||||
ResourceRepository
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Delete(id string) error
|
||||
Get(id string) (*Plugin, error)
|
||||
GetAll(options ...QueryOptions) (Plugins, error)
|
||||
Put(p *Plugin) error
|
||||
}
|
||||
@@ -512,6 +512,70 @@ var _ = Describe("AlbumRepository", func() {
|
||||
// Clean up the test album created for this test
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
|
||||
})
|
||||
|
||||
It("removes stale role associations when artist role changes", func() {
|
||||
// Regression test for issue #4242: Composers displayed in albumartist list
|
||||
// This happens when an artist's role changes (e.g., was both albumartist and composer,
|
||||
// now only composer) and the old role association isn't properly removed.
|
||||
|
||||
// Create an artist that will have changing roles
|
||||
artist := &model.Artist{
|
||||
ID: "role-change-artist-1",
|
||||
Name: "Role Change Artist",
|
||||
OrderArtistName: "role change artist",
|
||||
}
|
||||
err := createArtistWithLibrary(artistRepo, artist, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create album with artist as both albumartist and composer
|
||||
album := &model.Album{
|
||||
LibraryID: 1,
|
||||
ID: "test-album-role-change",
|
||||
Name: "Test Album Role Change",
|
||||
AlbumArtistID: "role-change-artist-1",
|
||||
AlbumArtist: "Role Change Artist",
|
||||
Participants: model.Participants{
|
||||
model.RoleAlbumArtist: {
|
||||
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
|
||||
},
|
||||
model.RoleComposer: {
|
||||
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = albumRepo.Put(album)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify initial state: artist has both albumartist and composer roles
|
||||
expected := []albumArtistRecord{
|
||||
{ArtistID: "role-change-artist-1", Role: "albumartist", SubRole: ""},
|
||||
{ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
|
||||
}
|
||||
verifyAlbumArtists(album.ID, expected)
|
||||
|
||||
// Now update album so artist is ONLY a composer (remove albumartist role)
|
||||
album.Participants = model.Participants{
|
||||
model.RoleComposer: {
|
||||
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
|
||||
},
|
||||
}
|
||||
|
||||
err = albumRepo.Put(album)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify that the albumartist role was removed - only composer should remain
|
||||
// This is the key test: before the fix, the albumartist role would remain
|
||||
// causing composers to appear in the albumartist filter
|
||||
expectedAfter := []albumArtistRecord{
|
||||
{ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
|
||||
}
|
||||
verifyAlbumArtists(album.ID, expectedAfter)
|
||||
|
||||
// Clean up
|
||||
_, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID}))
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -95,45 +95,82 @@ func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) {
|
||||
}
|
||||
|
||||
func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) {
|
||||
// If no specific paths, return all folders in the library
|
||||
if len(targetPaths) == 0 {
|
||||
return r.getFolderUpdateInfoAll(lib)
|
||||
}
|
||||
|
||||
// Check if any path is root (return all folders)
|
||||
for _, targetPath := range targetPaths {
|
||||
if targetPath == "" || targetPath == "." {
|
||||
return r.getFolderUpdateInfoAll(lib)
|
||||
}
|
||||
}
|
||||
|
||||
// Process paths in batches to avoid SQLite's expression tree depth limit (max 1000).
|
||||
// Each path generates ~3 conditions, so batch size of 100 keeps us well under the limit.
|
||||
const batchSize = 100
|
||||
result := make(map[string]model.FolderUpdateInfo)
|
||||
|
||||
for batch := range slices.Chunk(targetPaths, batchSize) {
|
||||
batchResult, err := r.getFolderUpdateInfoBatch(lib, batch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for id, info := range batchResult {
|
||||
result[id] = info
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getFolderUpdateInfoAll returns update info for all non-missing folders in the library
|
||||
func (r folderRepository) getFolderUpdateInfoAll(lib model.Library) (map[string]model.FolderUpdateInfo, error) {
|
||||
where := And{
|
||||
Eq{"library_id": lib.ID},
|
||||
Eq{"missing": false},
|
||||
}
|
||||
return r.queryFolderUpdateInfo(where)
|
||||
}
|
||||
|
||||
// getFolderUpdateInfoBatch returns update info for a batch of target paths and their descendants
|
||||
func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) {
|
||||
where := And{
|
||||
Eq{"library_id": lib.ID},
|
||||
Eq{"missing": false},
|
||||
}
|
||||
|
||||
// If specific paths are requested, include those folders and all their descendants
|
||||
if len(targetPaths) > 0 {
|
||||
// Collect folder IDs for exact target folders and path conditions for descendants
|
||||
folderIDs := make([]string, 0, len(targetPaths))
|
||||
pathConditions := make(Or, 0, len(targetPaths)*2)
|
||||
// Collect folder IDs for exact target folders and path conditions for descendants
|
||||
folderIDs := make([]string, 0, len(targetPaths))
|
||||
pathConditions := make(Or, 0, len(targetPaths)*2)
|
||||
|
||||
for _, targetPath := range targetPaths {
|
||||
if targetPath == "" || targetPath == "." {
|
||||
// Root path - include everything in this library
|
||||
pathConditions = Or{}
|
||||
folderIDs = nil
|
||||
break
|
||||
}
|
||||
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
|
||||
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
|
||||
cleanPath = filepath.Clean(cleanPath)
|
||||
for _, targetPath := range targetPaths {
|
||||
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
|
||||
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
|
||||
cleanPath = filepath.Clean(cleanPath)
|
||||
|
||||
// Include the target folder itself by ID
|
||||
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
|
||||
// Include the target folder itself by ID
|
||||
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
|
||||
|
||||
// Include all descendants: folders whose path field equals or starts with the target path
|
||||
// Note: Folder.Path is the directory path, so children have path = targetPath
|
||||
pathConditions = append(pathConditions, Eq{"path": cleanPath})
|
||||
pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
|
||||
}
|
||||
|
||||
// Combine conditions: exact folder IDs OR descendant path patterns
|
||||
if len(folderIDs) > 0 {
|
||||
where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
|
||||
} else if len(pathConditions) > 0 {
|
||||
where = append(where, pathConditions)
|
||||
}
|
||||
// Include all descendants: folders whose path field equals or starts with the target path
|
||||
// Note: Folder.Path is the directory path, so children have path = targetPath
|
||||
pathConditions = append(pathConditions, Eq{"path": cleanPath})
|
||||
pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
|
||||
}
|
||||
|
||||
// Combine conditions: exact folder IDs OR descendant path patterns
|
||||
if len(folderIDs) > 0 {
|
||||
where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
|
||||
} else if len(pathConditions) > 0 {
|
||||
where = append(where, pathConditions)
|
||||
}
|
||||
|
||||
return r.queryFolderUpdateInfo(where)
|
||||
}
|
||||
|
||||
// queryFolderUpdateInfo executes the query and returns the result map
|
||||
func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) {
|
||||
sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
|
||||
var res []struct {
|
||||
ID string
|
||||
|
||||
@@ -93,6 +93,10 @@ func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
|
||||
return NewScrobbleRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository {
|
||||
return NewPluginRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
switch m.(type) {
|
||||
case model.User:
|
||||
@@ -117,6 +121,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
|
||||
return s.Share(ctx).(model.ResourceRepository)
|
||||
case model.Tag:
|
||||
return s.Tag(ctx).(model.ResourceRepository)
|
||||
case model.Plugin:
|
||||
return s.Plugin(ctx).(model.ResourceRepository)
|
||||
}
|
||||
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||
return nil
|
||||
|
||||
153
persistence/plugin_repository.go
Normal file
153
persistence/plugin_repository.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type pluginRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPluginRepository(ctx context.Context, db dbx.Builder) model.PluginRepository {
|
||||
r := &pluginRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.Plugin{}, map[string]filterFunc{
|
||||
"id": idFilter("plugin"),
|
||||
"enabled": booleanFilter,
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *pluginRepository) isPermitted() bool {
|
||||
user := loggedUser(r.ctx)
|
||||
return user.IsAdmin
|
||||
}
|
||||
|
||||
func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
if !r.isPermitted() {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
sql := r.newSelect()
|
||||
return r.count(sql, options...)
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Delete(id string) error {
|
||||
if !r.isPermitted() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Get(id string) (*model.Plugin, error) {
|
||||
if !r.isPermitted() {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
}
|
||||
sel := r.newSelect().Where(Eq{"id": id}).Columns("*")
|
||||
res := model.Plugin{}
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) GetAll(options ...model.QueryOptions) (model.Plugins, error) {
|
||||
if !r.isPermitted() {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
}
|
||||
sel := r.newSelect(options...).Columns("*")
|
||||
res := model.Plugins{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
||||
if !r.isPermitted() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
plugin.UpdatedAt = time.Now()
|
||||
|
||||
if plugin.ID == "" {
|
||||
return errors.New("plugin ID cannot be empty")
|
||||
}
|
||||
|
||||
// Upsert using INSERT ... ON CONFLICT for atomic operation
|
||||
_, err := r.db.NewQuery(`
|
||||
INSERT INTO plugin (id, path, manifest, config, enabled, last_error, sha256, created_at, updated_at)
|
||||
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
path = excluded.path,
|
||||
manifest = excluded.manifest,
|
||||
config = excluded.config,
|
||||
enabled = excluded.enabled,
|
||||
last_error = excluded.last_error,
|
||||
sha256 = excluded.sha256,
|
||||
updated_at = excluded.updated_at
|
||||
`).Bind(dbx.Params{
|
||||
"id": plugin.ID,
|
||||
"path": plugin.Path,
|
||||
"manifest": plugin.Manifest,
|
||||
"config": plugin.Config,
|
||||
"enabled": plugin.Enabled,
|
||||
"last_error": plugin.LastError,
|
||||
"sha256": plugin.SHA256,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": plugin.UpdatedAt,
|
||||
}).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *pluginRepository) EntityName() string {
|
||||
return "plugin"
|
||||
}
|
||||
|
||||
func (r *pluginRepository) NewInstance() interface{} {
|
||||
return &model.Plugin{}
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Save(entity interface{}) (string, error) {
|
||||
p := entity.(*model.Plugin)
|
||||
if !r.isPermitted() {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.Put(p)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return "", rest.ErrNotFound
|
||||
}
|
||||
return p.ID, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
p := entity.(*model.Plugin)
|
||||
p.ID = id
|
||||
if !r.isPermitted() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.Put(p)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var _ model.PluginRepository = (*pluginRepository)(nil)
|
||||
var _ rest.Repository = (*pluginRepository)(nil)
|
||||
var _ rest.Persistable = (*pluginRepository)(nil)
|
||||
227
persistence/plugin_repository_test.go
Normal file
227
persistence/plugin_repository_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PluginRepository", func() {
|
||||
var repo model.PluginRepository
|
||||
|
||||
Describe("Admin User", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := GinkgoT().Context()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPluginRepository(ctx, GetDBXBuilder())
|
||||
|
||||
// Clean up any existing plugins
|
||||
all, _ := repo.GetAll()
|
||||
for _, p := range all {
|
||||
_ = repo.Delete(p.ID)
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up after tests
|
||||
all, _ := repo.GetAll()
|
||||
for _, p := range all {
|
||||
_ = repo.Delete(p.ID)
|
||||
}
|
||||
})
|
||||
|
||||
Describe("CountAll", func() {
|
||||
It("returns 0 when no plugins exist", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(0)))
|
||||
})
|
||||
|
||||
It("returns the number of plugins in the DB", func() {
|
||||
_ = repo.Put(&model.Plugin{ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: "{}", SHA256: "abc123"})
|
||||
_ = repo.Put(&model.Plugin{ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: "{}", SHA256: "def456"})
|
||||
|
||||
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("deletes existing item", func() {
|
||||
plugin := &model.Plugin{ID: "to-delete", Path: "/plugins/delete.wasm", Manifest: "{}", SHA256: "hash"}
|
||||
_ = repo.Put(plugin)
|
||||
|
||||
err := repo.Delete(plugin.ID)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
_, err = repo.Get(plugin.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing item", func() {
|
||||
plugin := &model.Plugin{ID: "test-get", Path: "/plugins/test.wasm", Manifest: `{"name":"test"}`, SHA256: "hash123"}
|
||||
_ = repo.Put(plugin)
|
||||
|
||||
res, err := repo.Get(plugin.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(res.ID).To(Equal(plugin.ID))
|
||||
Expect(res.Path).To(Equal(plugin.Path))
|
||||
Expect(res.Manifest).To(Equal(plugin.Manifest))
|
||||
})
|
||||
|
||||
It("errors when missing", func() {
|
||||
_, err := repo.Get("notanid")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("returns all items from the DB", func() {
|
||||
_ = repo.Put(&model.Plugin{ID: "plugin-a", Path: "/plugins/a.wasm", Manifest: "{}", SHA256: "hash1"})
|
||||
_ = repo.Put(&model.Plugin{ID: "plugin-b", Path: "/plugins/b.wasm", Manifest: "{}", SHA256: "hash2"})
|
||||
|
||||
all, err := repo.GetAll()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(all).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("supports pagination", func() {
|
||||
_ = repo.Put(&model.Plugin{ID: "plugin-1", Path: "/plugins/1.wasm", Manifest: "{}", SHA256: "h1"})
|
||||
_ = repo.Put(&model.Plugin{ID: "plugin-2", Path: "/plugins/2.wasm", Manifest: "{}", SHA256: "h2"})
|
||||
_ = repo.Put(&model.Plugin{ID: "plugin-3", Path: "/plugins/3.wasm", Manifest: "{}", SHA256: "h3"})
|
||||
|
||||
page1, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 0, Sort: "id"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(page1).To(HaveLen(2))
|
||||
|
||||
page2, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 2, Sort: "id"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(page2).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put", func() {
|
||||
It("successfully creates a new plugin", func() {
|
||||
plugin := &model.Plugin{
|
||||
ID: "new-plugin",
|
||||
Path: "/plugins/new.wasm",
|
||||
Manifest: `{"name":"new","version":"1.0"}`,
|
||||
Config: `{"setting":"value"}`,
|
||||
SHA256: "sha256hash",
|
||||
Enabled: false,
|
||||
}
|
||||
|
||||
err := repo.Put(plugin)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
saved, err := repo.Get(plugin.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(saved.Path).To(Equal(plugin.Path))
|
||||
Expect(saved.Manifest).To(Equal(plugin.Manifest))
|
||||
Expect(saved.Config).To(Equal(plugin.Config))
|
||||
Expect(saved.Enabled).To(BeFalse())
|
||||
Expect(saved.CreatedAt).NotTo(BeZero())
|
||||
Expect(saved.UpdatedAt).NotTo(BeZero())
|
||||
})
|
||||
|
||||
It("successfully updates an existing plugin", func() {
|
||||
plugin := &model.Plugin{
|
||||
ID: "update-plugin",
|
||||
Path: "/plugins/update.wasm",
|
||||
Manifest: `{"name":"test"}`,
|
||||
SHA256: "original",
|
||||
Enabled: false,
|
||||
}
|
||||
_ = repo.Put(plugin)
|
||||
|
||||
plugin.Enabled = true
|
||||
plugin.Config = `{"new":"config"}`
|
||||
plugin.SHA256 = "updated"
|
||||
err := repo.Put(plugin)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
saved, err := repo.Get(plugin.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(saved.Enabled).To(BeTrue())
|
||||
Expect(saved.Config).To(Equal(`{"new":"config"}`))
|
||||
Expect(saved.SHA256).To(Equal("updated"))
|
||||
})
|
||||
|
||||
It("stores and retrieves last_error", func() {
|
||||
plugin := &model.Plugin{
|
||||
ID: "error-plugin",
|
||||
Path: "/plugins/error.wasm",
|
||||
Manifest: "{}",
|
||||
SHA256: "hash",
|
||||
LastError: "failed to load: missing export",
|
||||
}
|
||||
err := repo.Put(plugin)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
saved, err := repo.Get(plugin.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(saved.LastError).To(Equal("failed to load: missing export"))
|
||||
})
|
||||
|
||||
It("fails when ID is empty", func() {
|
||||
plugin := &model.Plugin{
|
||||
Path: "/plugins/noid.wasm",
|
||||
Manifest: "{}",
|
||||
SHA256: "hash",
|
||||
}
|
||||
err := repo.Put(plugin)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("ID cannot be empty"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular User", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := GinkgoT().Context()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false})
|
||||
repo = NewPluginRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("CountAll", func() {
|
||||
It("fails to count items", func() {
|
||||
_, err := repo.CountAll()
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("fails to delete items", func() {
|
||||
err := repo.Delete("any-id")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("fails to get items", func() {
|
||||
_, err := repo.Get("any-id")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("fails to get all items", func() {
|
||||
_, err := repo.GetAll()
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put", func() {
|
||||
It("fails to create/update item", func() {
|
||||
err := repo.Put(&model.Plugin{
|
||||
ID: "user-create",
|
||||
Path: "/plugins/create.wasm",
|
||||
Manifest: "{}",
|
||||
SHA256: "hash",
|
||||
})
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -51,8 +51,10 @@ func unmarshalParticipants(data string) (model.Participants, error) {
|
||||
}
|
||||
|
||||
func (r sqlRepository) updateParticipants(itemID string, participants model.Participants) error {
|
||||
ids := participants.AllIDs()
|
||||
sqd := Delete(r.tableName + "_artists").Where(And{Eq{r.tableName + "_id": itemID}, NotEq{"artist_id": ids}})
|
||||
// Delete all existing participant entries for this item.
|
||||
// This ensures stale role associations are removed when an artist's role changes
|
||||
// (e.g., an artist was both albumartist and composer, but is now only composer).
|
||||
sqd := Delete(r.tableName + "_artists").Where(Eq{r.tableName + "_id": itemID})
|
||||
_, err := r.executeSQL(sqd)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
4
plugins/.gitignore
vendored
Normal file
4
plugins/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Rust build artifacts
|
||||
# Cargo.lock is not needed for library crates (this is a cdylib)
|
||||
Cargo.lock
|
||||
target
|
||||
2485
plugins/README.md
2485
plugins/README.md
File diff suppressed because it is too large
Load Diff
@@ -1,166 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin
|
||||
func newWasmMediaAgent(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmMediaAgent{
|
||||
baseCapability: newBaseCapability[api.MetadataAgent, *api.MetadataAgentPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityMetadataAgent,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// wasmMediaAgent adapts a MetadataAgent plugin to implement the agents.Interface
|
||||
type wasmMediaAgent struct {
|
||||
*baseCapability[api.MetadataAgent, *api.MetadataAgentPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) AgentName() string {
|
||||
return w.id
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) mapError(err error) error {
|
||||
if err != nil && (err.Error() == api.ErrNotFound.Error() || err.Error() == api.ErrNotImplemented.Error()) {
|
||||
return agents.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Album-related methods
|
||||
|
||||
func (w *wasmMediaAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
res, err := callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*api.AlbumInfoResponse, error) {
|
||||
return inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
if res == nil || res.Info == nil {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
info := res.Info
|
||||
return &agents.AlbumInfo{
|
||||
Name: info.Name,
|
||||
MBID: info.Mbid,
|
||||
Description: info.Description,
|
||||
URL: info.Url,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
res, err := callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) (*api.AlbumImagesResponse, error) {
|
||||
return inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(res.Images), nil
|
||||
}
|
||||
|
||||
// Artist-related methods
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
res, err := callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (*api.ArtistMBIDResponse, error) {
|
||||
return inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetMbid(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
res, err := callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (*api.ArtistURLResponse, error) {
|
||||
return inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetUrl(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
res, err := callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (*api.ArtistBiographyResponse, error) {
|
||||
return inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetBiography(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
resp, err := callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) (*api.ArtistSimilarResponse, error) {
|
||||
return inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
artists := make([]agents.Artist, 0, len(resp.GetArtists()))
|
||||
for _, a := range resp.GetArtists() {
|
||||
artists = append(artists, agents.Artist{
|
||||
Name: a.GetName(),
|
||||
MBID: a.GetMbid(),
|
||||
})
|
||||
}
|
||||
return artists, nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
resp, err := callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) (*api.ArtistImageResponse, error) {
|
||||
return inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(resp.Images), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
resp, err := callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) (*api.ArtistTopSongsResponse, error) {
|
||||
return inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
songs := make([]agents.Song, 0, len(resp.GetSongs()))
|
||||
for _, s := range resp.GetSongs() {
|
||||
songs = append(songs, agents.Song{
|
||||
Name: s.GetName(),
|
||||
MBID: s.GetMbid(),
|
||||
})
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
// Helper function to convert ExternalImage objects from the API to the agents package
|
||||
func convertExternalImages(images []*api.ExternalImage) []agents.ExternalImage {
|
||||
result := make([]agents.ExternalImage, 0, len(images))
|
||||
for _, img := range images {
|
||||
result = append(result, agents.ExternalImage{
|
||||
URL: img.GetUrl(),
|
||||
Size: int(img.GetSize()),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Adapter Media Agent", func() {
|
||||
var ctx context.Context
|
||||
var mgr *managerImpl
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
// Ensure plugins folder is set to testdata
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
|
||||
|
||||
mgr = createManager(nil, metrics.NewNoopInstance())
|
||||
mgr.ScanPlugins()
|
||||
|
||||
// Wait for all plugins to compile to avoid race conditions
|
||||
err := mgr.EnsureCompiled("multi_plugin")
|
||||
Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully")
|
||||
err = mgr.EnsureCompiled("fake_album_agent")
|
||||
Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully")
|
||||
})
|
||||
|
||||
Describe("AgentName and PluginName", func() {
|
||||
It("should return the plugin name", func() {
|
||||
agent := mgr.LoadPlugin("multi_plugin", "MetadataAgent")
|
||||
Expect(agent).NotTo(BeNil(), "multi_plugin should be loaded")
|
||||
Expect(agent.PluginID()).To(Equal("multi_plugin"))
|
||||
})
|
||||
It("should return the agent name", func() {
|
||||
agent, ok := mgr.LoadMediaAgent("multi_plugin")
|
||||
Expect(ok).To(BeTrue(), "multi_plugin should be loaded as media agent")
|
||||
Expect(agent.AgentName()).To(Equal("multi_plugin"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album methods", func() {
|
||||
var agent *wasmMediaAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
a, ok := mgr.LoadMediaAgent("fake_album_agent")
|
||||
Expect(ok).To(BeTrue(), "fake_album_agent should be loaded")
|
||||
agent = a.(*wasmMediaAgent)
|
||||
})
|
||||
|
||||
Context("GetAlbumInfo", func() {
|
||||
It("should return album information", func() {
|
||||
info, err := agent.GetAlbumInfo(ctx, "Test Album", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(info).NotTo(BeNil())
|
||||
Expect(info.Name).To(Equal("Test Album"))
|
||||
Expect(info.MBID).To(Equal("album-mbid-123"))
|
||||
Expect(info.Description).To(Equal("This is a test album description"))
|
||||
Expect(info.URL).To(Equal("https://example.com/album"))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns not found", func() {
|
||||
_, err := agent.GetAlbumInfo(ctx, "Test Album", "", "mbid")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns nil response", func() {
|
||||
_, err := agent.GetAlbumInfo(ctx, "", "", "")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetAlbumImages", func() {
|
||||
It("should return album images", func() {
|
||||
images, err := agent.GetAlbumImages(ctx, "Test Album", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(images).To(Equal([]agents.ExternalImage{
|
||||
{URL: "https://example.com/album1.jpg", Size: 300},
|
||||
{URL: "https://example.com/album2.jpg", Size: 400},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Artist methods", func() {
|
||||
var agent *wasmMediaAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
a, ok := mgr.LoadMediaAgent("fake_artist_agent")
|
||||
Expect(ok).To(BeTrue(), "fake_artist_agent should be loaded")
|
||||
agent = a.(*wasmMediaAgent)
|
||||
})
|
||||
|
||||
Context("GetArtistMBID", func() {
|
||||
It("should return artist MBID", func() {
|
||||
mbid, err := agent.GetArtistMBID(ctx, "artist-id", "Test Artist")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mbid).To(Equal("1234567890"))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns not found", func() {
|
||||
_, err := agent.GetArtistMBID(ctx, "artist-id", "")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistURL", func() {
|
||||
It("should return artist URL", func() {
|
||||
url, err := agent.GetArtistURL(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(url).To(Equal("https://example.com"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistBiography", func() {
|
||||
It("should return artist biography", func() {
|
||||
bio, err := agent.GetArtistBiography(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(bio).To(Equal("This is a test biography"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetSimilarArtists", func() {
|
||||
It("should return similar artists", func() {
|
||||
artists, err := agent.GetSimilarArtists(ctx, "artist-id", "Test Artist", "mbid", 10)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(artists).To(Equal([]agents.Artist{
|
||||
{Name: "Similar Artist 1", MBID: "mbid1"},
|
||||
{Name: "Similar Artist 2", MBID: "mbid2"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistImages", func() {
|
||||
It("should return artist images", func() {
|
||||
images, err := agent.GetArtistImages(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(images).To(Equal([]agents.ExternalImage{
|
||||
{URL: "https://example.com/image1.jpg", Size: 100},
|
||||
{URL: "https://example.com/image2.jpg", Size: 200},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistTopSongs", func() {
|
||||
It("should return artist top songs", func() {
|
||||
songs, err := agent.GetArtistTopSongs(ctx, "artist-id", "Test Artist", "mbid", 10)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(songs).To(Equal([]agents.Song{
|
||||
{Name: "Song 1", MBID: "mbid1"},
|
||||
{Name: "Song 2", MBID: "mbid2"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Helper functions", func() {
|
||||
It("convertExternalImages should convert API image objects to agent image objects", func() {
|
||||
apiImages := []*api.ExternalImage{
|
||||
{Url: "https://example.com/image1.jpg", Size: 100},
|
||||
{Url: "https://example.com/image2.jpg", Size: 200},
|
||||
}
|
||||
|
||||
agentImages := convertExternalImages(apiImages)
|
||||
Expect(agentImages).To(HaveLen(2))
|
||||
|
||||
for i, img := range agentImages {
|
||||
Expect(img.URL).To(Equal(apiImages[i].Url))
|
||||
Expect(img.Size).To(Equal(int(apiImages[i].Size)))
|
||||
}
|
||||
})
|
||||
|
||||
It("convertExternalImages should handle empty slice", func() {
|
||||
agentImages := convertExternalImages([]*api.ExternalImage{})
|
||||
Expect(agentImages).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("convertExternalImages should handle nil", func() {
|
||||
agentImages := convertExternalImages(nil)
|
||||
Expect(agentImages).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error mapping", func() {
|
||||
var agent wasmMediaAgent
|
||||
|
||||
It("should map API ErrNotFound to agents.ErrNotFound", func() {
|
||||
err := agent.mapError(api.ErrNotFound)
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should map API ErrNotImplemented to agents.ErrNotFound", func() {
|
||||
err := agent.mapError(api.ErrNotImplemented)
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should pass through other errors", func() {
|
||||
testErr := errors.New("test error")
|
||||
err := agent.mapError(testErr)
|
||||
Expect(err).To(Equal(testErr))
|
||||
})
|
||||
|
||||
It("should handle nil error", func() {
|
||||
err := agent.mapError(nil)
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin
|
||||
func newWasmSchedulerCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmSchedulerCallback{
|
||||
baseCapability: newBaseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilitySchedulerCallback,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// wasmSchedulerCallback adapts a SchedulerCallback plugin
|
||||
type wasmSchedulerCallback struct {
|
||||
*baseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmSchedulerCallback) OnSchedulerCallback(ctx context.Context, scheduleID string, payload []byte, isRecurring bool) error {
|
||||
_, err := callMethod(ctx, w, "OnSchedulerCallback", func(inst api.SchedulerCallback) (*api.SchedulerCallbackResponse, error) {
|
||||
return inst.OnSchedulerCallback(ctx, &api.SchedulerCallbackRequest{
|
||||
ScheduleId: scheduleID,
|
||||
Payload: payload,
|
||||
IsRecurring: isRecurring,
|
||||
})
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
func newWasmScrobblerPlugin(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmScrobblerPlugin{
|
||||
baseCapability: newBaseCapability[api.Scrobbler, *api.ScrobblerPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityScrobbler,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type wasmScrobblerPlugin struct {
|
||||
*baseCapability[api.Scrobbler, *api.ScrobblerPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
resp, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (*api.ScrobblerIsAuthorizedResponse, error) {
|
||||
return inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("Error calling IsAuthorized", "userId", userId, "pluginID", w.id, err)
|
||||
}
|
||||
return err == nil && resp.Authorized
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
|
||||
trackInfo := w.toTrackInfo(track, position)
|
||||
_, err := callMethod(ctx, w, "NowPlaying", func(inst api.Scrobbler) (struct{}, error) {
|
||||
resp, err := inst.NowPlaying(ctx, &api.ScrobblerNowPlayingRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
Track: trackInfo,
|
||||
Timestamp: time.Now().Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return struct{}{}, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
return struct{}{}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
trackInfo := w.toTrackInfo(&s.MediaFile, 0)
|
||||
_, err := callMethod(ctx, w, "Scrobble", func(inst api.Scrobbler) (struct{}, error) {
|
||||
resp, err := inst.Scrobble(ctx, &api.ScrobblerScrobbleRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
Track: trackInfo,
|
||||
Timestamp: s.TimeStamp.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return struct{}{}, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
return struct{}{}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) toTrackInfo(track *model.MediaFile, position int) *api.TrackInfo {
|
||||
artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
|
||||
|
||||
for _, a := range track.Participants[model.RoleArtist] {
|
||||
artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
|
||||
for _, a := range track.Participants[model.RoleAlbumArtist] {
|
||||
albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
trackInfo := &api.TrackInfo{
|
||||
Id: track.ID,
|
||||
Mbid: track.MbzRecordingID,
|
||||
Name: track.Title,
|
||||
Album: track.Album,
|
||||
AlbumMbid: track.MbzAlbumID,
|
||||
Artists: artists,
|
||||
AlbumArtists: albumArtists,
|
||||
Length: int32(track.Duration),
|
||||
Position: int32(position),
|
||||
}
|
||||
return trackInfo
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin
|
||||
func newWasmWebSocketCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmWebSocketCallback{
|
||||
baseCapability: newBaseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityWebSocketCallback,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.WebSocketCallbackPlugin, path string) (api.WebSocketCallback, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// wasmWebSocketCallback adapts a WebSocketCallback plugin
|
||||
type wasmWebSocketCallback struct {
|
||||
*baseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package api;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/api;api";
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service MetadataAgent {
|
||||
// Artist metadata methods
|
||||
rpc GetArtistMBID(ArtistMBIDRequest) returns (ArtistMBIDResponse);
|
||||
rpc GetArtistURL(ArtistURLRequest) returns (ArtistURLResponse);
|
||||
rpc GetArtistBiography(ArtistBiographyRequest) returns (ArtistBiographyResponse);
|
||||
rpc GetSimilarArtists(ArtistSimilarRequest) returns (ArtistSimilarResponse);
|
||||
rpc GetArtistImages(ArtistImageRequest) returns (ArtistImageResponse);
|
||||
rpc GetArtistTopSongs(ArtistTopSongsRequest) returns (ArtistTopSongsResponse);
|
||||
|
||||
// Album metadata methods
|
||||
rpc GetAlbumInfo(AlbumInfoRequest) returns (AlbumInfoResponse);
|
||||
rpc GetAlbumImages(AlbumImagesRequest) returns (AlbumImagesResponse);
|
||||
}
|
||||
|
||||
message ArtistMBIDRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
message ArtistMBIDResponse {
|
||||
string mbid = 1;
|
||||
}
|
||||
|
||||
message ArtistURLRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message ArtistURLResponse {
|
||||
string url = 1;
|
||||
}
|
||||
|
||||
message ArtistBiographyRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message ArtistBiographyResponse {
|
||||
string biography = 1;
|
||||
}
|
||||
|
||||
message ArtistSimilarRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
int32 limit = 4;
|
||||
}
|
||||
|
||||
message Artist {
|
||||
string name = 1;
|
||||
string mbid = 2;
|
||||
}
|
||||
|
||||
message ArtistSimilarResponse {
|
||||
repeated Artist artists = 1;
|
||||
}
|
||||
|
||||
message ArtistImageRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message ExternalImage {
|
||||
string url = 1;
|
||||
int32 size = 2;
|
||||
}
|
||||
|
||||
message ArtistImageResponse {
|
||||
repeated ExternalImage images = 1;
|
||||
}
|
||||
|
||||
message ArtistTopSongsRequest {
|
||||
string id = 1;
|
||||
string artistName = 2;
|
||||
string mbid = 3;
|
||||
int32 count = 4;
|
||||
}
|
||||
|
||||
message Song {
|
||||
string name = 1;
|
||||
string mbid = 2;
|
||||
}
|
||||
|
||||
message ArtistTopSongsResponse {
|
||||
repeated Song songs = 1;
|
||||
}
|
||||
|
||||
message AlbumInfoRequest {
|
||||
string name = 1;
|
||||
string artist = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message AlbumInfo {
|
||||
string name = 1;
|
||||
string mbid = 2;
|
||||
string description = 3;
|
||||
string url = 4;
|
||||
}
|
||||
|
||||
message AlbumInfoResponse {
|
||||
AlbumInfo info = 1;
|
||||
}
|
||||
|
||||
message AlbumImagesRequest {
|
||||
string name = 1;
|
||||
string artist = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message AlbumImagesResponse {
|
||||
repeated ExternalImage images = 1;
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service Scrobbler {
|
||||
rpc IsAuthorized(ScrobblerIsAuthorizedRequest) returns (ScrobblerIsAuthorizedResponse);
|
||||
rpc NowPlaying(ScrobblerNowPlayingRequest) returns (ScrobblerNowPlayingResponse);
|
||||
rpc Scrobble(ScrobblerScrobbleRequest) returns (ScrobblerScrobbleResponse);
|
||||
}
|
||||
|
||||
message ScrobblerIsAuthorizedRequest {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
}
|
||||
|
||||
message ScrobblerIsAuthorizedResponse {
|
||||
bool authorized = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message TrackInfo {
|
||||
string id = 1;
|
||||
string mbid = 2;
|
||||
string name = 3;
|
||||
string album = 4;
|
||||
string album_mbid = 5;
|
||||
repeated Artist artists = 6;
|
||||
repeated Artist album_artists = 7;
|
||||
int32 length = 8; // seconds
|
||||
int32 position = 9; // seconds
|
||||
}
|
||||
|
||||
message ScrobblerNowPlayingRequest {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
TrackInfo track = 3;
|
||||
int64 timestamp = 4;
|
||||
}
|
||||
|
||||
message ScrobblerNowPlayingResponse {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message ScrobblerScrobbleRequest {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
TrackInfo track = 3;
|
||||
int64 timestamp = 4;
|
||||
}
|
||||
|
||||
message ScrobblerScrobbleResponse {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service SchedulerCallback {
|
||||
rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse);
|
||||
}
|
||||
|
||||
message SchedulerCallbackRequest {
|
||||
string schedule_id = 1; // ID of the scheduled job that triggered this callback
|
||||
bytes payload = 2; // The data passed when the job was scheduled
|
||||
bool is_recurring = 3; // Whether this is from a recurring schedule (cron job)
|
||||
}
|
||||
|
||||
message SchedulerCallbackResponse {
|
||||
string error = 1; // Error message if the callback failed
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service LifecycleManagement {
|
||||
rpc OnInit(InitRequest) returns (InitResponse);
|
||||
}
|
||||
|
||||
message InitRequest {
|
||||
map<string, string> config = 1; // Configuration specific to this plugin
|
||||
}
|
||||
|
||||
message InitResponse {
|
||||
string error = 1; // Error message if initialization failed
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service WebSocketCallback {
|
||||
// Called when a text message is received
|
||||
rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse);
|
||||
|
||||
// Called when a binary message is received
|
||||
rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse);
|
||||
|
||||
// Called when an error occurs
|
||||
rpc OnError(OnErrorRequest) returns (OnErrorResponse);
|
||||
|
||||
// Called when the connection is closed
|
||||
rpc OnClose(OnCloseRequest) returns (OnCloseResponse);
|
||||
}
|
||||
|
||||
message OnTextMessageRequest {
|
||||
string connection_id = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message OnTextMessageResponse {}
|
||||
|
||||
message OnBinaryMessageRequest {
|
||||
string connection_id = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
message OnBinaryMessageResponse {}
|
||||
|
||||
message OnErrorRequest {
|
||||
string connection_id = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message OnErrorResponse {}
|
||||
|
||||
message OnCloseRequest {
|
||||
string connection_id = 1;
|
||||
int32 code = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
message OnCloseResponse {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: api/api.proto
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
wasi_snapshot_preview1 "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
)
|
||||
|
||||
type wazeroConfigOption func(plugin *WazeroConfig)
|
||||
|
||||
type WazeroNewRuntime func(context.Context) (wazero.Runtime, error)
|
||||
|
||||
type WazeroConfig struct {
|
||||
newRuntime func(context.Context) (wazero.Runtime, error)
|
||||
moduleConfig wazero.ModuleConfig
|
||||
}
|
||||
|
||||
func WazeroRuntime(newRuntime WazeroNewRuntime) wazeroConfigOption {
|
||||
return func(h *WazeroConfig) {
|
||||
h.newRuntime = newRuntime
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultWazeroRuntime() WazeroNewRuntime {
|
||||
return func(ctx context.Context) (wazero.Runtime, error) {
|
||||
r := wazero.NewRuntime(ctx)
|
||||
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
|
||||
func WazeroModuleConfig(moduleConfig wazero.ModuleConfig) wazeroConfigOption {
|
||||
return func(h *WazeroConfig) {
|
||||
h.moduleConfig = moduleConfig
|
||||
}
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: api/api.proto
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
)
|
||||
|
||||
const MetadataAgentPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport metadata_agent_api_version
|
||||
func _metadata_agent_api_version() uint64 {
|
||||
return MetadataAgentPluginAPIVersion
|
||||
}
|
||||
|
||||
var metadataAgent MetadataAgent
|
||||
|
||||
func RegisterMetadataAgent(p MetadataAgent) {
|
||||
metadataAgent = p
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_mbid
|
||||
func _metadata_agent_get_artist_mbid(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistMBIDRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistMBID(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_url
|
||||
func _metadata_agent_get_artist_url(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistURLRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistURL(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_biography
|
||||
func _metadata_agent_get_artist_biography(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistBiographyRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistBiography(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_similar_artists
|
||||
func _metadata_agent_get_similar_artists(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistSimilarRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetSimilarArtists(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_images
|
||||
func _metadata_agent_get_artist_images(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistImageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistImages(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_top_songs
|
||||
func _metadata_agent_get_artist_top_songs(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistTopSongsRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistTopSongs(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_album_info
|
||||
func _metadata_agent_get_album_info(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(AlbumInfoRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetAlbumInfo(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_album_images
|
||||
func _metadata_agent_get_album_images(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(AlbumImagesRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetAlbumImages(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const ScrobblerPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport scrobbler_api_version
|
||||
func _scrobbler_api_version() uint64 {
|
||||
return ScrobblerPluginAPIVersion
|
||||
}
|
||||
|
||||
var scrobbler Scrobbler
|
||||
|
||||
func RegisterScrobbler(p Scrobbler) {
|
||||
scrobbler = p
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_is_authorized
|
||||
func _scrobbler_is_authorized(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerIsAuthorizedRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.IsAuthorized(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_now_playing
|
||||
func _scrobbler_now_playing(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerNowPlayingRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.NowPlaying(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_scrobble
|
||||
func _scrobbler_scrobble(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerScrobbleRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.Scrobble(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const SchedulerCallbackPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport scheduler_callback_api_version
|
||||
func _scheduler_callback_api_version() uint64 {
|
||||
return SchedulerCallbackPluginAPIVersion
|
||||
}
|
||||
|
||||
var schedulerCallback SchedulerCallback
|
||||
|
||||
func RegisterSchedulerCallback(p SchedulerCallback) {
|
||||
schedulerCallback = p
|
||||
}
|
||||
|
||||
//go:wasmexport scheduler_callback_on_scheduler_callback
|
||||
func _scheduler_callback_on_scheduler_callback(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(SchedulerCallbackRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := schedulerCallback.OnSchedulerCallback(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const LifecycleManagementPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport lifecycle_management_api_version
|
||||
func _lifecycle_management_api_version() uint64 {
|
||||
return LifecycleManagementPluginAPIVersion
|
||||
}
|
||||
|
||||
var lifecycleManagement LifecycleManagement
|
||||
|
||||
func RegisterLifecycleManagement(p LifecycleManagement) {
|
||||
lifecycleManagement = p
|
||||
}
|
||||
|
||||
//go:wasmexport lifecycle_management_on_init
|
||||
func _lifecycle_management_on_init(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(InitRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := lifecycleManagement.OnInit(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const WebSocketCallbackPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport web_socket_callback_api_version
|
||||
func _web_socket_callback_api_version() uint64 {
|
||||
return WebSocketCallbackPluginAPIVersion
|
||||
}
|
||||
|
||||
var webSocketCallback WebSocketCallback
|
||||
|
||||
func RegisterWebSocketCallback(p WebSocketCallback) {
|
||||
webSocketCallback = p
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_text_message
|
||||
func _web_socket_callback_on_text_message(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnTextMessageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnTextMessage(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_binary_message
|
||||
func _web_socket_callback_on_binary_message(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnBinaryMessageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnBinaryMessage(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_error
|
||||
func _web_socket_callback_on_error(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnErrorRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnError(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_close
|
||||
func _web_socket_callback_on_close(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnCloseRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnClose(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package api
|
||||
|
||||
import "github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
|
||||
// This file exists to provide stubs for the plugin registration functions when building for non-WASM targets.
|
||||
// This is useful for testing and development purposes, as it allows you to build and run your plugin code
|
||||
// without having to compile it to WASM.
|
||||
// In a real-world scenario, you would compile your plugin to WASM and use the generated registration functions.
|
||||
|
||||
func RegisterMetadataAgent(MetadataAgent) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterScrobbler(Scrobbler) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterSchedulerCallback(SchedulerCallback) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterLifecycleManagement(LifecycleManagement) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterWebSocketCallback(WebSocketCallback) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService {
|
||||
panic("not implemented")
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
//go:build wasip1
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
)
|
||||
|
||||
var callbacks = make(namedCallbacks)
|
||||
|
||||
// RegisterNamedSchedulerCallback registers a named scheduler callback. Named callbacks allow multiple callbacks to be registered
|
||||
// within the same plugin, and for the schedules to be scoped to the named callback. If you only need a single callback, you can use
|
||||
// the default (unnamed) callback registration function, RegisterSchedulerCallback.
|
||||
// It returns a scheduler.SchedulerService that can be used to schedule jobs for the named callback.
|
||||
//
|
||||
// Notes:
|
||||
//
|
||||
// - You can't mix named and unnamed callbacks within the same plugin.
|
||||
// - The name should be unique within the plugin, and it's recommended to use a short, descriptive name.
|
||||
// - The name is case-sensitive.
|
||||
func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService {
|
||||
callbacks[name] = cb
|
||||
RegisterSchedulerCallback(&callbacks)
|
||||
return &namedSchedulerService{name: name, svc: scheduler.NewSchedulerService()}
|
||||
}
|
||||
|
||||
const zwsp = string('\u200b')
|
||||
|
||||
// namedCallbacks is a map of named scheduler callbacks. The key is the name of the callback, and the value is the callback itself.
|
||||
type namedCallbacks map[string]SchedulerCallback
|
||||
|
||||
func parseKey(key string) (string, string) {
|
||||
parts := strings.SplitN(key, zwsp, 2)
|
||||
if len(parts) != 2 {
|
||||
return "", ""
|
||||
}
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
|
||||
func (n *namedCallbacks) OnSchedulerCallback(ctx context.Context, req *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) {
|
||||
name, scheduleId := parseKey(req.ScheduleId)
|
||||
cb, exists := callbacks[name]
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
req.ScheduleId = scheduleId
|
||||
return cb.OnSchedulerCallback(ctx, req)
|
||||
}
|
||||
|
||||
// namedSchedulerService is a wrapper around the host scheduler service that prefixes the schedule IDs with the
|
||||
// callback name. It is returned by RegisterNamedSchedulerCallback, and should be used by the plugin to schedule
|
||||
// jobs for the named callback.
|
||||
type namedSchedulerService struct {
|
||||
name string
|
||||
cb SchedulerCallback
|
||||
svc scheduler.SchedulerService
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) makeKey(id string) string {
|
||||
return n.name + zwsp + id
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) mapResponse(resp *scheduler.ScheduleResponse, err error) (*scheduler.ScheduleResponse, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, resp.ScheduleId = parseKey(resp.ScheduleId)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) ScheduleOneTime(ctx context.Context, request *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) {
|
||||
key := n.makeKey(request.ScheduleId)
|
||||
request.ScheduleId = key
|
||||
return n.mapResponse(n.svc.ScheduleOneTime(ctx, request))
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) ScheduleRecurring(ctx context.Context, request *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) {
|
||||
key := n.makeKey(request.ScheduleId)
|
||||
request.ScheduleId = key
|
||||
return n.mapResponse(n.svc.ScheduleRecurring(ctx, request))
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *scheduler.CancelRequest) (*scheduler.CancelResponse, error) {
|
||||
key := n.makeKey(request.ScheduleId)
|
||||
request.ScheduleId = key
|
||||
return n.svc.CancelSchedule(ctx, request)
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) TimeNow(ctx context.Context, request *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
|
||||
return n.svc.TimeNow(ctx, request)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
package api
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrNotImplemented indicates that the plugin does not implement the requested method.
|
||||
// No logic should be executed by the plugin.
|
||||
ErrNotImplemented = errors.New("plugin:not_implemented")
|
||||
|
||||
// ErrNotFound indicates that the requested resource was not found by the plugin.
|
||||
ErrNotFound = errors.New("plugin:not_found")
|
||||
)
|
||||
@@ -1,159 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
)
|
||||
|
||||
// newBaseCapability creates a new instance of baseCapability with the required parameters.
|
||||
func newBaseCapability[S any, P any](wasmPath, id, capability string, m metrics.Metrics, loader P, loadFunc loaderFunc[S, P]) *baseCapability[S, P] {
|
||||
return &baseCapability[S, P]{
|
||||
wasmPath: wasmPath,
|
||||
id: id,
|
||||
capability: capability,
|
||||
loader: loader,
|
||||
loadFunc: loadFunc,
|
||||
metrics: m,
|
||||
}
|
||||
}
|
||||
|
||||
// LoaderFunc is a generic function type that loads a plugin instance.
|
||||
type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error)
|
||||
|
||||
// baseCapability is a generic base implementation for WASM plugins.
|
||||
// S is the capability interface type and P is the plugin loader type.
|
||||
type baseCapability[S any, P any] struct {
|
||||
wasmPath string
|
||||
id string
|
||||
capability string
|
||||
loader P
|
||||
loadFunc loaderFunc[S, P]
|
||||
metrics metrics.Metrics
|
||||
}
|
||||
|
||||
func (w *baseCapability[S, P]) PluginID() string {
|
||||
return w.id
|
||||
}
|
||||
|
||||
func (w *baseCapability[S, P]) serviceName() string {
|
||||
return w.id + "_" + w.capability
|
||||
}
|
||||
|
||||
func (w *baseCapability[S, P]) getMetrics() metrics.Metrics {
|
||||
return w.metrics
|
||||
}
|
||||
|
||||
// getInstance loads a new plugin instance and returns a cleanup function.
|
||||
func (w *baseCapability[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) {
|
||||
start := time.Now()
|
||||
// Add context metadata for tracing
|
||||
ctx = log.NewContext(ctx, "capability", w.serviceName(), "method", methodName)
|
||||
|
||||
inst, err := w.loadFunc(ctx, w.loader, w.wasmPath)
|
||||
if err != nil {
|
||||
var zero S
|
||||
return zero, func() {}, fmt.Errorf("baseCapability: failed to load instance for %s: %w", w.serviceName(), err)
|
||||
}
|
||||
// Add context metadata for tracing
|
||||
ctx = log.NewContext(ctx, "instanceID", getInstanceID(inst))
|
||||
log.Trace(ctx, "baseCapability: loaded instance", "elapsed", time.Since(start))
|
||||
return inst, func() {
|
||||
log.Trace(ctx, "baseCapability: finished using instance", "elapsed", time.Since(start))
|
||||
if closer, ok := any(inst).(interface{ Close(context.Context) error }); ok {
|
||||
_ = closer.Close(ctx)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
type wasmPlugin[S any] interface {
|
||||
PluginID() string
|
||||
getInstance(ctx context.Context, methodName string) (S, func(), error)
|
||||
getMetrics() metrics.Metrics
|
||||
}
|
||||
|
||||
func callMethod[S any, R any](ctx context.Context, wp WasmPlugin, methodName string, fn func(inst S) (R, error)) (R, error) {
|
||||
// Add a unique call ID to the context for tracing
|
||||
ctx = log.NewContext(ctx, "callID", id.NewRandom())
|
||||
var r R
|
||||
|
||||
p, ok := wp.(wasmPlugin[S])
|
||||
if !ok {
|
||||
log.Error(ctx, "callMethod: not a wasm plugin", "method", methodName, "pluginID", wp.PluginID())
|
||||
return r, fmt.Errorf("wasm plugin: not a wasm plugin: %s", wp.PluginID())
|
||||
}
|
||||
|
||||
inst, done, err := p.getInstance(ctx, methodName)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
start := time.Now()
|
||||
defer done()
|
||||
r, err = checkErr(fn(inst))
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if !errors.Is(err, api.ErrNotImplemented) {
|
||||
id := p.PluginID()
|
||||
isOk := err == nil
|
||||
metrics := p.getMetrics()
|
||||
if metrics != nil {
|
||||
metrics.RecordPluginRequest(ctx, id, methodName, isOk, elapsed.Milliseconds())
|
||||
log.Trace(ctx, "callMethod: sending metrics", "plugin", id, "method", methodName, "ok", isOk, "elapsed", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
return r, err
|
||||
}
|
||||
|
||||
// errorResponse is an interface that defines a method to retrieve an error message.
|
||||
// It is automatically implemented (generated) by all plugin responses that have an Error field
|
||||
type errorResponse interface {
|
||||
GetError() string
|
||||
}
|
||||
|
||||
// checkErr returns an updated error if the response implements errorResponse and contains an error message.
|
||||
// If the response is nil, it returns the original error. Otherwise, it wraps or creates an error as needed.
|
||||
// It also maps error strings to their corresponding api.Err* constants.
|
||||
func checkErr[T any](resp T, err error) (T, error) {
|
||||
if any(resp) == nil {
|
||||
return resp, mapAPIError(err)
|
||||
}
|
||||
respErr, ok := any(resp).(errorResponse)
|
||||
if ok && respErr.GetError() != "" {
|
||||
respErrMsg := respErr.GetError()
|
||||
respErrErr := errors.New(respErrMsg)
|
||||
mappedErr := mapAPIError(respErrErr)
|
||||
// Check if the error was mapped to an API error (different from the temp error)
|
||||
if errors.Is(mappedErr, api.ErrNotImplemented) || errors.Is(mappedErr, api.ErrNotFound) {
|
||||
// Return the mapped API error instead of wrapping
|
||||
return resp, mappedErr
|
||||
}
|
||||
// For non-API errors, use wrap the original error if it is not nil
|
||||
return resp, errors.Join(respErrErr, err)
|
||||
}
|
||||
return resp, mapAPIError(err)
|
||||
}
|
||||
|
||||
// mapAPIError maps error strings to their corresponding api.Err* constants.
|
||||
// This is needed as errors from plugins may not be of type api.Error, due to serialization/deserialization.
|
||||
func mapAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
switch errStr {
|
||||
case api.ErrNotImplemented.Error():
|
||||
return api.ErrNotImplemented
|
||||
case api.ErrNotFound.Error():
|
||||
return api.ErrNotFound
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type nilInstance struct{}
|
||||
|
||||
var _ = Describe("baseCapability", func() {
|
||||
var ctx = context.Background()
|
||||
|
||||
It("should load instance using loadFunc", func() {
|
||||
called := false
|
||||
plugin := &baseCapability[*nilInstance, any]{
|
||||
wasmPath: "",
|
||||
id: "test",
|
||||
capability: "test",
|
||||
loadFunc: func(ctx context.Context, _ any, path string) (*nilInstance, error) {
|
||||
called = true
|
||||
return &nilInstance{}, nil
|
||||
},
|
||||
}
|
||||
inst, done, err := plugin.getInstance(ctx, "test")
|
||||
defer done()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(inst).ToNot(BeNil())
|
||||
Expect(called).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("checkErr", func() {
|
||||
Context("when resp is nil", func() {
|
||||
It("should return nil error when both resp and err are nil", func() {
|
||||
var resp *testErrorResponse
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return original error unchanged for non-API errors", func() {
|
||||
var resp *testErrorResponse
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(Equal(originalErr))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotImplemented", func() {
|
||||
var resp *testErrorResponse
|
||||
err := errors.New("plugin:not_implemented")
|
||||
|
||||
result, mappedErr := checkErr(resp, err)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(mappedErr).To(Equal(api.ErrNotImplemented))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotFound", func() {
|
||||
var resp *testErrorResponse
|
||||
err := errors.New("plugin:not_found")
|
||||
|
||||
result, mappedErr := checkErr(resp, err)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(mappedErr).To(Equal(api.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp is a typed nil that implements errorResponse", func() {
|
||||
It("should not panic and return original error", func() {
|
||||
var resp *testErrorResponse // typed nil
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
// This should not panic
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(Equal(originalErr))
|
||||
})
|
||||
|
||||
It("should handle typed nil with nil error gracefully", func() {
|
||||
var resp *testErrorResponse // typed nil
|
||||
|
||||
// This should not panic
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp implements errorResponse with non-empty error", func() {
|
||||
It("should create new error when original error is nil", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin error"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError("plugin error"))
|
||||
})
|
||||
|
||||
It("should wrap original error when both exist", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin error"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Check that both error messages are present in the joined error
|
||||
errStr := err.Error()
|
||||
Expect(errStr).To(ContainSubstring("plugin error"))
|
||||
Expect(errStr).To(ContainSubstring("original error"))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotImplemented when no original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_implemented"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotFound when no original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_found"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotImplemented even with original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_implemented"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotFound even with original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_found"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp implements errorResponse with empty error", func() {
|
||||
It("should return original error unchanged", func() {
|
||||
resp := &testErrorResponse{errorMsg: ""}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(originalErr))
|
||||
})
|
||||
|
||||
It("should return nil error when both are empty/nil", func() {
|
||||
resp := &testErrorResponse{errorMsg: ""}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should map original API error when response error is empty", func() {
|
||||
resp := &testErrorResponse{errorMsg: ""}
|
||||
originalErr := errors.New("plugin:not_implemented")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp does not implement errorResponse", func() {
|
||||
It("should return original error unchanged", func() {
|
||||
resp := &testNonErrorResponse{data: "some data"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(Equal(originalErr))
|
||||
})
|
||||
|
||||
It("should return nil error when original error is nil", func() {
|
||||
resp := &testNonErrorResponse{data: "some data"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should map original API error when response doesn't implement errorResponse", func() {
|
||||
resp := &testNonErrorResponse{data: "some data"}
|
||||
originalErr := errors.New("plugin:not_found")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp is a value type (not pointer)", func() {
|
||||
It("should handle value types that implement errorResponse", func() {
|
||||
resp := testValueErrorResponse{errorMsg: "value error"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Check that both error messages are present in the joined error
|
||||
errStr := err.Error()
|
||||
Expect(errStr).To(ContainSubstring("value error"))
|
||||
Expect(errStr).To(ContainSubstring("original error"))
|
||||
})
|
||||
|
||||
It("should handle value types with empty error", func() {
|
||||
resp := testValueErrorResponse{errorMsg: ""}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(originalErr))
|
||||
})
|
||||
|
||||
It("should handle value types with API error", func() {
|
||||
resp := testValueErrorResponse{errorMsg: "plugin:not_implemented"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Test helper types
|
||||
type testErrorResponse struct {
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
func (t *testErrorResponse) GetError() string {
|
||||
if t == nil {
|
||||
return "" // This is what would typically happen with a typed nil
|
||||
}
|
||||
return t.errorMsg
|
||||
}
|
||||
|
||||
type testNonErrorResponse struct {
|
||||
data string
|
||||
}
|
||||
|
||||
type testValueErrorResponse struct {
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
func (t testValueErrorResponse) GetError() string {
|
||||
return t.errorMsg
|
||||
}
|
||||
47
plugins/capabilities.go
Normal file
47
plugins/capabilities.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package plugins
|
||||
|
||||
// Capability represents a plugin capability type.
|
||||
// Capabilities are detected by checking which functions a plugin exports.
|
||||
type Capability string
|
||||
|
||||
// capabilityFunctions maps each capability to its required/optional functions.
|
||||
// A plugin has a capability if it exports at least one of these functions.
|
||||
var capabilityFunctions = map[Capability][]string{}
|
||||
|
||||
// registerCapability registers a capability with its associated functions.
|
||||
func registerCapability(cap Capability, functions ...string) {
|
||||
capabilityFunctions[cap] = functions
|
||||
}
|
||||
|
||||
// functionExistsChecker is an interface for checking if a function exists in a plugin.
|
||||
// This allows for testing without a real plugin instance.
|
||||
type functionExistsChecker interface {
|
||||
FunctionExists(name string) bool
|
||||
}
|
||||
|
||||
// detectCapabilities detects which capabilities a plugin has by checking
|
||||
// which functions it exports.
|
||||
func detectCapabilities(plugin functionExistsChecker) []Capability {
|
||||
var capabilities []Capability
|
||||
|
||||
for cap, functions := range capabilityFunctions {
|
||||
for _, fn := range functions {
|
||||
if plugin.FunctionExists(fn) {
|
||||
capabilities = append(capabilities, cap)
|
||||
break // Found at least one function, plugin has this capability
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities
|
||||
}
|
||||
|
||||
// hasCapability checks if the given capabilities slice contains a specific capability.
|
||||
func hasCapability(capabilities []Capability, cap Capability) bool {
|
||||
for _, c := range capabilities {
|
||||
if c == cap {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
87
plugins/capabilities/README.md
Normal file
87
plugins/capabilities/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Navidrome Plugin Capabilities
|
||||
|
||||
This directory contains the Go interface definitions for Navidrome plugin capabilities. These interfaces are the **source of truth** for plugin development and are used to generate:
|
||||
|
||||
1. **Go PDK packages** (`pdk/go/*/`) - Type-safe wrappers for Go plugin developers
|
||||
2. **Rust PDK crates** (`pdk/rust/*/`) - Type-safe wrappers for Rust plugin developers
|
||||
3. **XTP YAML schemas** (`*.yaml`) - Schema files for other [Extism plugin languages](https://extism.org/docs/concepts/pdk/) (TypeScript, Python, C#, Zig, C++, ...)
|
||||
|
||||
## For Go Plugin Developers
|
||||
|
||||
Go developers should use the generated PDK packages in `plugins/pdk/go/`. See the example Go plugins in `plugins/examples/` for usage patterns.
|
||||
|
||||
## For Rust Plugin Developers
|
||||
|
||||
Rust developers should use the generated PDK crate in `plugins/pdk/rust/nd-pdk`. See the example Rust plugins in `plugins/examples` for usage patterns.
|
||||
|
||||
## For Non-Go Plugin Developers
|
||||
|
||||
If you're developing plugins in other languages (TypeScript, Rust, Python, C#, Zig, C++), you can use the XTP CLI to generate type-safe bindings from the YAML schema files in this directory.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Install the XTP CLI:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install dylibso/tap/xtp
|
||||
|
||||
# Other platforms - see https://docs.xtp.dylibso.com/docs/cli
|
||||
curl https://static.dylibso.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
### Generating Plugin Scaffolding
|
||||
|
||||
Use the XTP CLI to generate plugin boilerplate from any capability schema:
|
||||
|
||||
```bash
|
||||
# TypeScript
|
||||
xtp plugin init --schema-file plugins/capabilities/metadata_agent.yaml \
|
||||
--template typescript --path my-plugin
|
||||
|
||||
# Rust
|
||||
xtp plugin init --schema-file plugins/capabilities/scrobbler.yaml \
|
||||
--template rust --path my-plugin
|
||||
|
||||
# Python
|
||||
xtp plugin init --schema-file plugins/capabilities/lifecycle.yaml \
|
||||
--template python --path my-plugin
|
||||
|
||||
# C#
|
||||
xtp plugin init --schema-file plugins/capabilities/scheduler_callback.yaml \
|
||||
--template csharp --path my-plugin
|
||||
|
||||
# Go (alternative to using the PDK packages)
|
||||
xtp plugin init --schema-file plugins/capabilities/websocket_callback.yaml \
|
||||
--template go --path my-plugin
|
||||
```
|
||||
|
||||
### Available Capabilities
|
||||
|
||||
| Capability | Schema File | Description |
|
||||
|--------------------|---------------------------|-------------------------------------------------------------|
|
||||
| Metadata Agent | `metadata_agent.yaml` | Fetch artist biographies, album images, and similar artists |
|
||||
| Scrobbler | `scrobbler.yaml` | Report listening activity to external services |
|
||||
| Lifecycle | `lifecycle.yaml` | Plugin initialization callbacks |
|
||||
| Scheduler Callback | `scheduler_callback.yaml` | Scheduled task execution |
|
||||
| WebSocket Callback | `websocket_callback.yaml` | Real-time WebSocket message handling |
|
||||
|
||||
### Building Your Plugin
|
||||
|
||||
After generating the scaffolding, implement the required functions and build your plugin as a WebAssembly module. The exact build process depends on your chosen language - see the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for language-specific guides.
|
||||
|
||||
## XTP Schema Generation
|
||||
|
||||
The YAML schemas in this package are automatically generated from the capability Go interfaces using `ndpgen`.
|
||||
To regenerate the schemas after modifying the interfaces, run:
|
||||
|
||||
```bash
|
||||
cd plugins/cmd/ndpgen && go run . -schemas -input=./plugins/capabilities
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [XTP Documentation](https://docs.xtp.dylibso.com/)
|
||||
- [XTP Bindgen Repository](https://github.com/dylibso/xtp-bindgen)
|
||||
- [Extism Plugin Development Kit](https://extism.org/docs/concepts/pdk)
|
||||
- [XTP Schema Definition](https://raw.githubusercontent.com/dylibso/xtp-bindgen/5090518dd86ba5e734dc225a33066ecc0ed2e12d/plugin/schema.json)
|
||||
56
plugins/capabilities/doc.go
Normal file
56
plugins/capabilities/doc.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Package capabilities defines Go interfaces for Navidrome plugin capabilities.
|
||||
//
|
||||
// These interfaces serve as the source of truth for capability definitions.
|
||||
// The ndpgen tool generates:
|
||||
// - Go export wrappers in plugins/pdk/go/<capability>/ for Go plugins
|
||||
// - XTP YAML schemas for non-Go plugins (Rust, TypeScript, etc.)
|
||||
//
|
||||
// Each capability is defined as an annotated interface:
|
||||
//
|
||||
// //nd:capability name=metadata
|
||||
// type MetadataAgent interface {
|
||||
// //nd:export name=nd_get_artist_biography
|
||||
// GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error)
|
||||
// }
|
||||
//
|
||||
// Annotation Reference:
|
||||
//
|
||||
// //nd:capability name=<pkg> [required=true]
|
||||
// - Marks an interface as a capability
|
||||
// - name: Generated package name (e.g., name=metadata → pdk/go/metadata/)
|
||||
// - required: If true, all methods must be implemented (default: false)
|
||||
//
|
||||
// //nd:export name=<func>
|
||||
// - Marks a method as an exported WASM function
|
||||
// - name: The export name (e.g., nd_get_artist_biography)
|
||||
//
|
||||
// Generated Code Structure:
|
||||
//
|
||||
// For a capability like MetadataAgent with required=false:
|
||||
//
|
||||
// package metadata
|
||||
//
|
||||
// // Agent is the marker interface
|
||||
// type Agent interface{}
|
||||
//
|
||||
// // Optional provider interfaces
|
||||
// type ArtistBiographyProvider interface {
|
||||
// GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error)
|
||||
// }
|
||||
//
|
||||
// // Registration function
|
||||
// func Register(impl Agent) { ... }
|
||||
//
|
||||
// For a capability with required=true:
|
||||
//
|
||||
// package scrobbler
|
||||
//
|
||||
// // Scrobbler requires all methods
|
||||
// type Scrobbler interface {
|
||||
// IsAuthorized(IsAuthorizedRequest) (bool, error)
|
||||
// NowPlaying(NowPlayingRequest) error
|
||||
// Scrobble(ScrobbleRequest) error
|
||||
// }
|
||||
//
|
||||
// func Register(impl Scrobbler) { ... }
|
||||
package capabilities
|
||||
19
plugins/capabilities/lifecycle.go
Normal file
19
plugins/capabilities/lifecycle.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package capabilities
|
||||
|
||||
// Lifecycle provides plugin lifecycle hooks.
|
||||
// This capability allows plugins to perform initialization when loaded,
|
||||
// such as establishing connections, starting background processes, or
|
||||
// validating configuration.
|
||||
//
|
||||
// The OnInit function is called once when the plugin is loaded, and is NOT
|
||||
// called when the plugin is hot-reloaded. Plugins should not assume this
|
||||
// function will be called on every startup.
|
||||
//
|
||||
//nd:capability name=lifecycle
|
||||
type Lifecycle interface {
|
||||
// OnInit is called after a plugin is fully loaded with all services registered.
|
||||
// Plugins can use this function to perform one-time initialization tasks.
|
||||
// Errors are logged but will not prevent the plugin from being loaded.
|
||||
//nd:export name=nd_on_init
|
||||
OnInit() error
|
||||
}
|
||||
7
plugins/capabilities/lifecycle.yaml
Normal file
7
plugins/capabilities/lifecycle.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_on_init:
|
||||
description: |-
|
||||
OnInit is called after a plugin is fully loaded with all services registered.
|
||||
Plugins can use this function to perform one-time initialization tasks.
|
||||
Errors are logged but will not prevent the plugin from being loaded.
|
||||
173
plugins/capabilities/metadata_agent.go
Normal file
173
plugins/capabilities/metadata_agent.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package capabilities
|
||||
|
||||
// MetadataAgent provides artist and album metadata retrieval.
|
||||
// This capability allows plugins to provide external metadata for artists and albums,
|
||||
// such as biographies, images, similar artists, and top songs.
|
||||
//
|
||||
// Plugins implementing this capability can choose which methods to implement.
|
||||
// Each method is optional - plugins only need to provide the functionality they support.
|
||||
//
|
||||
//nd:capability name=metadata
|
||||
type MetadataAgent interface {
|
||||
// GetArtistMBID retrieves the MusicBrainz ID for an artist.
|
||||
//nd:export name=nd_get_artist_mbid
|
||||
GetArtistMBID(ArtistMBIDRequest) (*ArtistMBIDResponse, error)
|
||||
|
||||
// GetArtistURL retrieves the external URL for an artist.
|
||||
//nd:export name=nd_get_artist_url
|
||||
GetArtistURL(ArtistRequest) (*ArtistURLResponse, error)
|
||||
|
||||
// GetArtistBiography retrieves the biography for an artist.
|
||||
//nd:export name=nd_get_artist_biography
|
||||
GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error)
|
||||
|
||||
// GetSimilarArtists retrieves similar artists for a given artist.
|
||||
//nd:export name=nd_get_similar_artists
|
||||
GetSimilarArtists(SimilarArtistsRequest) (*SimilarArtistsResponse, error)
|
||||
|
||||
// GetArtistImages retrieves images for an artist.
|
||||
//nd:export name=nd_get_artist_images
|
||||
GetArtistImages(ArtistRequest) (*ArtistImagesResponse, error)
|
||||
|
||||
// GetArtistTopSongs retrieves top songs for an artist.
|
||||
//nd:export name=nd_get_artist_top_songs
|
||||
GetArtistTopSongs(TopSongsRequest) (*TopSongsResponse, error)
|
||||
|
||||
// GetAlbumInfo retrieves album information.
|
||||
//nd:export name=nd_get_album_info
|
||||
GetAlbumInfo(AlbumRequest) (*AlbumInfoResponse, error)
|
||||
|
||||
// GetAlbumImages retrieves images for an album.
|
||||
//nd:export name=nd_get_album_images
|
||||
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
|
||||
}
|
||||
|
||||
// ArtistMBIDRequest is the request for GetArtistMBID.
|
||||
type ArtistMBIDRequest struct {
|
||||
// ID is the internal Navidrome artist ID.
|
||||
ID string `json:"id"`
|
||||
// Name is the artist name.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ArtistMBIDResponse is the response for GetArtistMBID.
|
||||
type ArtistMBIDResponse struct {
|
||||
// MBID is the MusicBrainz ID for the artist.
|
||||
MBID string `json:"mbid"`
|
||||
}
|
||||
|
||||
// ArtistRequest is the common request for artist-related functions.
|
||||
type ArtistRequest struct {
|
||||
// ID is the internal Navidrome artist ID.
|
||||
ID string `json:"id"`
|
||||
// Name is the artist name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the artist (if known).
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
// ArtistURLResponse is the response for GetArtistURL.
|
||||
type ArtistURLResponse struct {
|
||||
// URL is the external URL for the artist.
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ArtistBiographyResponse is the response for GetArtistBiography.
|
||||
type ArtistBiographyResponse struct {
|
||||
// Biography is the artist biography text.
|
||||
Biography string `json:"biography"`
|
||||
}
|
||||
|
||||
// SimilarArtistsRequest is the request for GetSimilarArtists.
|
||||
type SimilarArtistsRequest struct {
|
||||
// ID is the internal Navidrome artist ID.
|
||||
ID string `json:"id"`
|
||||
// Name is the artist name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the artist (if known).
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
// Limit is the maximum number of similar artists to return.
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
// ArtistRef is a reference to an artist with name and optional MBID.
|
||||
type ArtistRef struct {
|
||||
// Name is the artist name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the artist.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
// SimilarArtistsResponse is the response for GetSimilarArtists.
|
||||
type SimilarArtistsResponse struct {
|
||||
// Artists is the list of similar artists.
|
||||
Artists []ArtistRef `json:"artists"`
|
||||
}
|
||||
|
||||
// ImageInfo represents an image with URL and size.
|
||||
type ImageInfo struct {
|
||||
// URL is the URL of the image.
|
||||
URL string `json:"url"`
|
||||
// Size is the size of the image in pixels (width or height).
|
||||
Size int32 `json:"size"`
|
||||
}
|
||||
|
||||
// ArtistImagesResponse is the response for GetArtistImages.
|
||||
type ArtistImagesResponse struct {
|
||||
// Images is the list of artist images.
|
||||
Images []ImageInfo `json:"images"`
|
||||
}
|
||||
|
||||
// TopSongsRequest is the request for GetArtistTopSongs.
|
||||
type TopSongsRequest struct {
|
||||
// ID is the internal Navidrome artist ID.
|
||||
ID string `json:"id"`
|
||||
// Name is the artist name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the artist (if known).
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
// Count is the maximum number of top songs to return.
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
// SongRef is a reference to a song with name and optional MBID.
|
||||
type SongRef struct {
|
||||
// Name is the song name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the song.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
// TopSongsResponse is the response for GetArtistTopSongs.
|
||||
type TopSongsResponse struct {
|
||||
// Songs is the list of top songs.
|
||||
Songs []SongRef `json:"songs"`
|
||||
}
|
||||
|
||||
// AlbumRequest is the common request for album-related functions.
|
||||
type AlbumRequest struct {
|
||||
// Name is the album name.
|
||||
Name string `json:"name"`
|
||||
// Artist is the album artist name.
|
||||
Artist string `json:"artist"`
|
||||
// MBID is the MusicBrainz ID for the album (if known).
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
// AlbumInfoResponse is the response for GetAlbumInfo.
|
||||
type AlbumInfoResponse struct {
|
||||
// Name is the album name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the album.
|
||||
MBID string `json:"mbid"`
|
||||
// Description is the album description/notes.
|
||||
Description string `json:"description"`
|
||||
// URL is the external URL for the album.
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// AlbumImagesResponse is the response for GetAlbumImages.
|
||||
type AlbumImagesResponse struct {
|
||||
// Images is the list of album images.
|
||||
Images []ImageInfo `json:"images"`
|
||||
}
|
||||
269
plugins/capabilities/metadata_agent.yaml
Normal file
269
plugins/capabilities/metadata_agent.yaml
Normal file
@@ -0,0 +1,269 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_get_artist_mbid:
|
||||
description: GetArtistMBID retrieves the MusicBrainz ID for an artist.
|
||||
input:
|
||||
$ref: '#/components/schemas/ArtistMBIDRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/ArtistMBIDResponse'
|
||||
contentType: application/json
|
||||
nd_get_artist_url:
|
||||
description: GetArtistURL retrieves the external URL for an artist.
|
||||
input:
|
||||
$ref: '#/components/schemas/ArtistRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/ArtistURLResponse'
|
||||
contentType: application/json
|
||||
nd_get_artist_biography:
|
||||
description: GetArtistBiography retrieves the biography for an artist.
|
||||
input:
|
||||
$ref: '#/components/schemas/ArtistRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/ArtistBiographyResponse'
|
||||
contentType: application/json
|
||||
nd_get_similar_artists:
|
||||
description: GetSimilarArtists retrieves similar artists for a given artist.
|
||||
input:
|
||||
$ref: '#/components/schemas/SimilarArtistsRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/SimilarArtistsResponse'
|
||||
contentType: application/json
|
||||
nd_get_artist_images:
|
||||
description: GetArtistImages retrieves images for an artist.
|
||||
input:
|
||||
$ref: '#/components/schemas/ArtistRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/ArtistImagesResponse'
|
||||
contentType: application/json
|
||||
nd_get_artist_top_songs:
|
||||
description: GetArtistTopSongs retrieves top songs for an artist.
|
||||
input:
|
||||
$ref: '#/components/schemas/TopSongsRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/TopSongsResponse'
|
||||
contentType: application/json
|
||||
nd_get_album_info:
|
||||
description: GetAlbumInfo retrieves album information.
|
||||
input:
|
||||
$ref: '#/components/schemas/AlbumRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/AlbumInfoResponse'
|
||||
contentType: application/json
|
||||
nd_get_album_images:
|
||||
description: GetAlbumImages retrieves images for an album.
|
||||
input:
|
||||
$ref: '#/components/schemas/AlbumRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/AlbumImagesResponse'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
AlbumImagesResponse:
|
||||
description: AlbumImagesResponse is the response for GetAlbumImages.
|
||||
properties:
|
||||
images:
|
||||
type: array
|
||||
description: Images is the list of album images.
|
||||
items:
|
||||
$ref: '#/components/schemas/ImageInfo'
|
||||
required:
|
||||
- images
|
||||
AlbumInfoResponse:
|
||||
description: AlbumInfoResponse is the response for GetAlbumInfo.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Name is the album name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the album.
|
||||
description:
|
||||
type: string
|
||||
description: Description is the album description/notes.
|
||||
url:
|
||||
type: string
|
||||
description: URL is the external URL for the album.
|
||||
required:
|
||||
- name
|
||||
- mbid
|
||||
- description
|
||||
- url
|
||||
AlbumRequest:
|
||||
description: AlbumRequest is the common request for album-related functions.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Name is the album name.
|
||||
artist:
|
||||
type: string
|
||||
description: Artist is the album artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the album (if known).
|
||||
required:
|
||||
- name
|
||||
- artist
|
||||
ArtistBiographyResponse:
|
||||
description: ArtistBiographyResponse is the response for GetArtistBiography.
|
||||
properties:
|
||||
biography:
|
||||
type: string
|
||||
description: Biography is the artist biography text.
|
||||
required:
|
||||
- biography
|
||||
ArtistImagesResponse:
|
||||
description: ArtistImagesResponse is the response for GetArtistImages.
|
||||
properties:
|
||||
images:
|
||||
type: array
|
||||
description: Images is the list of artist images.
|
||||
items:
|
||||
$ref: '#/components/schemas/ImageInfo'
|
||||
required:
|
||||
- images
|
||||
ArtistMBIDRequest:
|
||||
description: ArtistMBIDRequest is the request for GetArtistMBID.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome artist ID.
|
||||
name:
|
||||
type: string
|
||||
description: Name is the artist name.
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
ArtistMBIDResponse:
|
||||
description: ArtistMBIDResponse is the response for GetArtistMBID.
|
||||
properties:
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the artist.
|
||||
required:
|
||||
- mbid
|
||||
ArtistRef:
|
||||
description: ArtistRef is a reference to an artist with name and optional MBID.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Name is the artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the artist.
|
||||
required:
|
||||
- name
|
||||
ArtistRequest:
|
||||
description: ArtistRequest is the common request for artist-related functions.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome artist ID.
|
||||
name:
|
||||
type: string
|
||||
description: Name is the artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the artist (if known).
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
ArtistURLResponse:
|
||||
description: ArtistURLResponse is the response for GetArtistURL.
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
description: URL is the external URL for the artist.
|
||||
required:
|
||||
- url
|
||||
ImageInfo:
|
||||
description: ImageInfo represents an image with URL and size.
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
description: URL is the URL of the image.
|
||||
size:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Size is the size of the image in pixels (width or height).
|
||||
required:
|
||||
- url
|
||||
- size
|
||||
SimilarArtistsRequest:
|
||||
description: SimilarArtistsRequest is the request for GetSimilarArtists.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome artist ID.
|
||||
name:
|
||||
type: string
|
||||
description: Name is the artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the artist (if known).
|
||||
limit:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Limit is the maximum number of similar artists to return.
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- limit
|
||||
SimilarArtistsResponse:
|
||||
description: SimilarArtistsResponse is the response for GetSimilarArtists.
|
||||
properties:
|
||||
artists:
|
||||
type: array
|
||||
description: Artists is the list of similar artists.
|
||||
items:
|
||||
$ref: '#/components/schemas/ArtistRef'
|
||||
required:
|
||||
- artists
|
||||
SongRef:
|
||||
description: SongRef is a reference to a song with name and optional MBID.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Name is the song name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the song.
|
||||
required:
|
||||
- name
|
||||
TopSongsRequest:
|
||||
description: TopSongsRequest is the request for GetArtistTopSongs.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome artist ID.
|
||||
name:
|
||||
type: string
|
||||
description: Name is the artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the artist (if known).
|
||||
count:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Count is the maximum number of top songs to return.
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- count
|
||||
TopSongsResponse:
|
||||
description: TopSongsResponse is the response for GetArtistTopSongs.
|
||||
properties:
|
||||
songs:
|
||||
type: array
|
||||
description: Songs is the list of top songs.
|
||||
items:
|
||||
$ref: '#/components/schemas/SongRef'
|
||||
required:
|
||||
- songs
|
||||
27
plugins/capabilities/scheduler_callback.go
Normal file
27
plugins/capabilities/scheduler_callback.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package capabilities
|
||||
|
||||
// SchedulerCallback provides scheduled task handling.
|
||||
// This capability allows plugins to receive callbacks when their scheduled tasks execute.
|
||||
// Plugins that use the scheduler host service must implement this capability
|
||||
// to handle task execution.
|
||||
//
|
||||
//nd:capability name=scheduler
|
||||
type SchedulerCallback interface {
|
||||
// OnCallback is called when a scheduled task fires.
|
||||
// Errors are logged but do not affect the scheduling system.
|
||||
//nd:export name=nd_scheduler_callback
|
||||
OnCallback(SchedulerCallbackRequest) error
|
||||
}
|
||||
|
||||
// SchedulerCallbackRequest is the request provided when a scheduled task fires.
|
||||
type SchedulerCallbackRequest struct {
|
||||
// ScheduleID is the unique identifier for this scheduled task.
|
||||
// This is either the ID provided when scheduling, or an auto-generated UUID if none was specified.
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
// Payload is the payload data that was provided when the task was scheduled.
|
||||
// Can be used to pass context or parameters to the callback handler.
|
||||
Payload string `json:"payload"`
|
||||
// IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring),
|
||||
// false if it's a one-time schedule (created via ScheduleOneTime).
|
||||
IsRecurring bool `json:"isRecurring"`
|
||||
}
|
||||
33
plugins/capabilities/scheduler_callback.yaml
Normal file
33
plugins/capabilities/scheduler_callback.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_scheduler_callback:
|
||||
description: |-
|
||||
OnCallback is called when a scheduled task fires.
|
||||
Errors are logged but do not affect the scheduling system.
|
||||
input:
|
||||
$ref: '#/components/schemas/SchedulerCallbackRequest'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
SchedulerCallbackRequest:
|
||||
description: SchedulerCallbackRequest is the request provided when a scheduled task fires.
|
||||
properties:
|
||||
scheduleId:
|
||||
type: string
|
||||
description: |-
|
||||
ScheduleID is the unique identifier for this scheduled task.
|
||||
This is either the ID provided when scheduling, or an auto-generated UUID if none was specified.
|
||||
payload:
|
||||
type: string
|
||||
description: |-
|
||||
Payload is the payload data that was provided when the task was scheduled.
|
||||
Can be used to pass context or parameters to the callback handler.
|
||||
isRecurring:
|
||||
type: boolean
|
||||
description: |-
|
||||
IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring),
|
||||
false if it's a one-time schedule (created via ScheduleOneTime).
|
||||
required:
|
||||
- scheduleId
|
||||
- payload
|
||||
- isRecurring
|
||||
102
plugins/capabilities/scrobbler.go
Normal file
102
plugins/capabilities/scrobbler.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package capabilities
|
||||
|
||||
// Scrobbler provides scrobbling functionality to external services.
|
||||
// This capability allows plugins to submit listening history to services like Last.fm,
|
||||
// ListenBrainz, or custom scrobbling backends.
|
||||
//
|
||||
// All methods are required - plugins implementing this capability must provide
|
||||
// all three functions: IsAuthorized, NowPlaying, and Scrobble.
|
||||
//
|
||||
//nd:capability name=scrobbler required=true
|
||||
type Scrobbler interface {
|
||||
// IsAuthorized checks if a user is authorized to scrobble to this service.
|
||||
//nd:export name=nd_scrobbler_is_authorized
|
||||
IsAuthorized(IsAuthorizedRequest) (bool, error)
|
||||
|
||||
// NowPlaying sends a now playing notification to the scrobbling service.
|
||||
//nd:export name=nd_scrobbler_now_playing
|
||||
NowPlaying(NowPlayingRequest) error
|
||||
|
||||
// Scrobble submits a completed scrobble to the scrobbling service.
|
||||
//nd:export name=nd_scrobbler_scrobble
|
||||
Scrobble(ScrobbleRequest) error
|
||||
}
|
||||
|
||||
// IsAuthorizedRequest is the request for authorization check.
|
||||
type IsAuthorizedRequest struct {
|
||||
// UserID is the internal Navidrome user ID.
|
||||
UserID string `json:"userId"`
|
||||
// Username is the username of the user.
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// TrackInfo contains track metadata for scrobbling.
|
||||
type TrackInfo struct {
|
||||
// ID is the internal Navidrome track ID.
|
||||
ID string `json:"id"`
|
||||
// Title is the track title.
|
||||
Title string `json:"title"`
|
||||
// Album is the album name.
|
||||
Album string `json:"album"`
|
||||
// Artist is the track artist.
|
||||
Artist string `json:"artist"`
|
||||
// AlbumArtist is the album artist.
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
// Duration is the track duration in seconds.
|
||||
Duration float32 `json:"duration"`
|
||||
// TrackNumber is the track number on the album.
|
||||
TrackNumber int32 `json:"trackNumber"`
|
||||
// DiscNumber is the disc number.
|
||||
DiscNumber int32 `json:"discNumber"`
|
||||
// MBZRecordingID is the MusicBrainz recording ID.
|
||||
MBZRecordingID string `json:"mbzRecordingId,omitempty"`
|
||||
// MBZAlbumID is the MusicBrainz album/release ID.
|
||||
MBZAlbumID string `json:"mbzAlbumId,omitempty"`
|
||||
// MBZArtistID is the MusicBrainz artist ID.
|
||||
MBZArtistID string `json:"mbzArtistId,omitempty"`
|
||||
// MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZAlbumArtistID is the MusicBrainz album artist ID.
|
||||
MBZAlbumArtistID string `json:"mbzAlbumArtistId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
}
|
||||
|
||||
// NowPlayingRequest is the request for now playing notification.
|
||||
type NowPlayingRequest struct {
|
||||
// UserID is the internal Navidrome user ID.
|
||||
UserID string `json:"userId"`
|
||||
// Username is the username of the user.
|
||||
Username string `json:"username"`
|
||||
// Track is the track currently playing.
|
||||
Track TrackInfo `json:"track"`
|
||||
// Position is the current playback position in seconds.
|
||||
Position int32 `json:"position"`
|
||||
}
|
||||
|
||||
// ScrobbleRequest is the request for submitting a scrobble.
|
||||
type ScrobbleRequest struct {
|
||||
// UserID is the internal Navidrome user ID.
|
||||
UserID string `json:"userId"`
|
||||
// Username is the username of the user.
|
||||
Username string `json:"username"`
|
||||
// Track is the track that was played.
|
||||
Track TrackInfo `json:"track"`
|
||||
// Timestamp is the Unix timestamp when the track started playing.
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ScrobblerError represents an error type for scrobbling operations.
|
||||
type ScrobblerError string
|
||||
|
||||
const (
|
||||
// ScrobblerErrorNotAuthorized indicates the user is not authorized.
|
||||
ScrobblerErrorNotAuthorized ScrobblerError = "scrobbler(not_authorized)"
|
||||
// ScrobblerErrorRetryLater indicates the operation should be retried later.
|
||||
ScrobblerErrorRetryLater ScrobblerError = "scrobbler(retry_later)"
|
||||
// ScrobblerErrorUnrecoverable indicates an unrecoverable error.
|
||||
ScrobblerErrorUnrecoverable ScrobblerError = "scrobbler(unrecoverable)"
|
||||
)
|
||||
|
||||
// Error implements the error interface for ScrobblerError.
|
||||
func (e ScrobblerError) Error() string { return string(e) }
|
||||
133
plugins/capabilities/scrobbler.yaml
Normal file
133
plugins/capabilities/scrobbler.yaml
Normal file
@@ -0,0 +1,133 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_scrobbler_is_authorized:
|
||||
description: IsAuthorized checks if a user is authorized to scrobble to this service.
|
||||
input:
|
||||
$ref: '#/components/schemas/IsAuthorizedRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
type: boolean
|
||||
contentType: application/json
|
||||
nd_scrobbler_now_playing:
|
||||
description: NowPlaying sends a now playing notification to the scrobbling service.
|
||||
input:
|
||||
$ref: '#/components/schemas/NowPlayingRequest'
|
||||
contentType: application/json
|
||||
nd_scrobbler_scrobble:
|
||||
description: Scrobble submits a completed scrobble to the scrobbling service.
|
||||
input:
|
||||
$ref: '#/components/schemas/ScrobbleRequest'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
IsAuthorizedRequest:
|
||||
description: IsAuthorizedRequest is the request for authorization check.
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
description: UserID is the internal Navidrome user ID.
|
||||
username:
|
||||
type: string
|
||||
description: Username is the username of the user.
|
||||
required:
|
||||
- userId
|
||||
- username
|
||||
NowPlayingRequest:
|
||||
description: NowPlayingRequest is the request for now playing notification.
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
description: UserID is the internal Navidrome user ID.
|
||||
username:
|
||||
type: string
|
||||
description: Username is the username of the user.
|
||||
track:
|
||||
$ref: '#/components/schemas/TrackInfo'
|
||||
description: Track is the track currently playing.
|
||||
position:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Position is the current playback position in seconds.
|
||||
required:
|
||||
- userId
|
||||
- username
|
||||
- track
|
||||
- position
|
||||
ScrobbleRequest:
|
||||
description: ScrobbleRequest is the request for submitting a scrobble.
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
description: UserID is the internal Navidrome user ID.
|
||||
username:
|
||||
type: string
|
||||
description: Username is the username of the user.
|
||||
track:
|
||||
$ref: '#/components/schemas/TrackInfo'
|
||||
description: Track is the track that was played.
|
||||
timestamp:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Timestamp is the Unix timestamp when the track started playing.
|
||||
required:
|
||||
- userId
|
||||
- username
|
||||
- track
|
||||
- timestamp
|
||||
TrackInfo:
|
||||
description: TrackInfo contains track metadata for scrobbling.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome track ID.
|
||||
title:
|
||||
type: string
|
||||
description: Title is the track title.
|
||||
album:
|
||||
type: string
|
||||
description: Album is the album name.
|
||||
artist:
|
||||
type: string
|
||||
description: Artist is the track artist.
|
||||
albumArtist:
|
||||
type: string
|
||||
description: AlbumArtist is the album artist.
|
||||
duration:
|
||||
type: number
|
||||
format: float
|
||||
description: Duration is the track duration in seconds.
|
||||
trackNumber:
|
||||
type: integer
|
||||
format: int32
|
||||
description: TrackNumber is the track number on the album.
|
||||
discNumber:
|
||||
type: integer
|
||||
format: int32
|
||||
description: DiscNumber is the disc number.
|
||||
mbzRecordingId:
|
||||
type: string
|
||||
description: MBZRecordingID is the MusicBrainz recording ID.
|
||||
mbzAlbumId:
|
||||
type: string
|
||||
description: MBZAlbumID is the MusicBrainz album/release ID.
|
||||
mbzArtistId:
|
||||
type: string
|
||||
description: MBZArtistID is the MusicBrainz artist ID.
|
||||
mbzReleaseGroupId:
|
||||
type: string
|
||||
description: MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
mbzAlbumArtistId:
|
||||
type: string
|
||||
description: MBZAlbumArtistID is the MusicBrainz album artist ID.
|
||||
mbzReleaseTrackId:
|
||||
type: string
|
||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
- album
|
||||
- artist
|
||||
- albumArtist
|
||||
- duration
|
||||
- trackNumber
|
||||
- discNumber
|
||||
61
plugins/capabilities/websocket_callback.go
Normal file
61
plugins/capabilities/websocket_callback.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package capabilities
|
||||
|
||||
// WebSocketCallback provides WebSocket message handling.
|
||||
// This capability allows plugins to receive callbacks for WebSocket events
|
||||
// such as text messages, binary messages, errors, and connection closures.
|
||||
// Plugins that use the WebSocket host service must implement this capability
|
||||
// to handle incoming events.
|
||||
//
|
||||
//nd:capability name=websocket
|
||||
type WebSocketCallback interface {
|
||||
// OnTextMessage is called when a text message is received on a WebSocket connection.
|
||||
//nd:export name=nd_websocket_on_text_message
|
||||
OnTextMessage(OnTextMessageRequest) error
|
||||
|
||||
// OnBinaryMessage is called when a binary message is received on a WebSocket connection.
|
||||
//nd:export name=nd_websocket_on_binary_message
|
||||
OnBinaryMessage(OnBinaryMessageRequest) error
|
||||
|
||||
// OnError is called when an error occurs on a WebSocket connection.
|
||||
//nd:export name=nd_websocket_on_error
|
||||
OnError(OnErrorRequest) error
|
||||
|
||||
// OnClose is called when a WebSocket connection is closed.
|
||||
//nd:export name=nd_websocket_on_close
|
||||
OnClose(OnCloseRequest) error
|
||||
}
|
||||
|
||||
// OnTextMessageRequest is the request provided when a text message is received.
|
||||
type OnTextMessageRequest struct {
|
||||
// ConnectionID is the unique identifier for the WebSocket connection that received the message.
|
||||
ConnectionID string `json:"connectionId"`
|
||||
// Message is the text message content received from the WebSocket.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// OnBinaryMessageRequest is the request provided when a binary message is received.
|
||||
type OnBinaryMessageRequest struct {
|
||||
// ConnectionID is the unique identifier for the WebSocket connection that received the message.
|
||||
ConnectionID string `json:"connectionId"`
|
||||
// Data is the binary data received from the WebSocket, encoded as base64.
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
|
||||
type OnErrorRequest struct {
|
||||
// ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
|
||||
ConnectionID string `json:"connectionId"`
|
||||
// Error is the error message describing what went wrong.
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// OnCloseRequest is the request provided when a WebSocket connection is closed.
|
||||
type OnCloseRequest struct {
|
||||
// ConnectionID is the unique identifier for the WebSocket connection that was closed.
|
||||
ConnectionID string `json:"connectionId"`
|
||||
// Code is the WebSocket close status code (e.g., 1000 for normal closure,
|
||||
// 1001 for going away, 1006 for abnormal closure).
|
||||
Code int32 `json:"code"`
|
||||
// Reason is the human-readable reason for the connection closure, if provided.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
79
plugins/capabilities/websocket_callback.yaml
Normal file
79
plugins/capabilities/websocket_callback.yaml
Normal file
@@ -0,0 +1,79 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_websocket_on_text_message:
|
||||
description: OnTextMessage is called when a text message is received on a WebSocket connection.
|
||||
input:
|
||||
$ref: '#/components/schemas/OnTextMessageRequest'
|
||||
contentType: application/json
|
||||
nd_websocket_on_binary_message:
|
||||
description: OnBinaryMessage is called when a binary message is received on a WebSocket connection.
|
||||
input:
|
||||
$ref: '#/components/schemas/OnBinaryMessageRequest'
|
||||
contentType: application/json
|
||||
nd_websocket_on_error:
|
||||
description: OnError is called when an error occurs on a WebSocket connection.
|
||||
input:
|
||||
$ref: '#/components/schemas/OnErrorRequest'
|
||||
contentType: application/json
|
||||
nd_websocket_on_close:
|
||||
description: OnClose is called when a WebSocket connection is closed.
|
||||
input:
|
||||
$ref: '#/components/schemas/OnCloseRequest'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
OnBinaryMessageRequest:
|
||||
description: OnBinaryMessageRequest is the request provided when a binary message is received.
|
||||
properties:
|
||||
connectionId:
|
||||
type: string
|
||||
description: ConnectionID is the unique identifier for the WebSocket connection that received the message.
|
||||
data:
|
||||
type: string
|
||||
description: Data is the binary data received from the WebSocket, encoded as base64.
|
||||
required:
|
||||
- connectionId
|
||||
- data
|
||||
OnCloseRequest:
|
||||
description: OnCloseRequest is the request provided when a WebSocket connection is closed.
|
||||
properties:
|
||||
connectionId:
|
||||
type: string
|
||||
description: ConnectionID is the unique identifier for the WebSocket connection that was closed.
|
||||
code:
|
||||
type: integer
|
||||
format: int32
|
||||
description: |-
|
||||
Code is the WebSocket close status code (e.g., 1000 for normal closure,
|
||||
1001 for going away, 1006 for abnormal closure).
|
||||
reason:
|
||||
type: string
|
||||
description: Reason is the human-readable reason for the connection closure, if provided.
|
||||
required:
|
||||
- connectionId
|
||||
- code
|
||||
- reason
|
||||
OnErrorRequest:
|
||||
description: OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
|
||||
properties:
|
||||
connectionId:
|
||||
type: string
|
||||
description: ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
|
||||
error:
|
||||
type: string
|
||||
description: Error is the error message describing what went wrong.
|
||||
required:
|
||||
- connectionId
|
||||
- error
|
||||
OnTextMessageRequest:
|
||||
description: OnTextMessageRequest is the request provided when a text message is received.
|
||||
properties:
|
||||
connectionId:
|
||||
type: string
|
||||
description: ConnectionID is the unique identifier for the WebSocket connection that received the message.
|
||||
message:
|
||||
type: string
|
||||
description: Message is the text message content received from the WebSocket.
|
||||
required:
|
||||
- connectionId
|
||||
- message
|
||||
81
plugins/capabilities_test.go
Normal file
81
plugins/capabilities_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// mockFunctionChecker implements functionExistsChecker for testing
|
||||
type mockFunctionChecker struct {
|
||||
functions map[string]bool
|
||||
}
|
||||
|
||||
func (m *mockFunctionChecker) FunctionExists(name string) bool {
|
||||
return m.functions[name]
|
||||
}
|
||||
|
||||
var _ = Describe("Capabilities", func() {
|
||||
Describe("detectCapabilities", func() {
|
||||
It("detects MetadataAgent capability when plugin exports artist biography function", func() {
|
||||
checker := &mockFunctionChecker{
|
||||
functions: map[string]bool{
|
||||
FuncGetArtistBiography: true,
|
||||
},
|
||||
}
|
||||
|
||||
caps := detectCapabilities(checker)
|
||||
Expect(caps).To(ContainElement(CapabilityMetadataAgent))
|
||||
})
|
||||
|
||||
It("detects MetadataAgent capability when plugin exports multiple functions", func() {
|
||||
checker := &mockFunctionChecker{
|
||||
functions: map[string]bool{
|
||||
FuncGetArtistMBID: true,
|
||||
FuncGetArtistURL: true,
|
||||
FuncGetAlbumInfo: true,
|
||||
FuncGetAlbumImages: true,
|
||||
},
|
||||
}
|
||||
|
||||
caps := detectCapabilities(checker)
|
||||
Expect(caps).To(ContainElement(CapabilityMetadataAgent))
|
||||
Expect(caps).To(HaveLen(1)) // Should only have one MetadataAgent capability
|
||||
})
|
||||
|
||||
It("returns empty slice when no capability functions are exported", func() {
|
||||
checker := &mockFunctionChecker{
|
||||
functions: map[string]bool{
|
||||
"some_other_function": true,
|
||||
},
|
||||
}
|
||||
|
||||
caps := detectCapabilities(checker)
|
||||
Expect(caps).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty slice when plugin exports no functions", func() {
|
||||
checker := &mockFunctionChecker{
|
||||
functions: map[string]bool{},
|
||||
}
|
||||
|
||||
caps := detectCapabilities(checker)
|
||||
Expect(caps).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("hasCapability", func() {
|
||||
It("returns true when capability exists", func() {
|
||||
caps := []Capability{CapabilityMetadataAgent}
|
||||
Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when capability does not exist", func() {
|
||||
var caps []Capability
|
||||
Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when capabilities slice is nil", func() {
|
||||
Expect(hasCapability(nil, CapabilityMetadataAgent)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
38
plugins/capability_lifecycle.go
Normal file
38
plugins/capability_lifecycle.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// CapabilityLifecycle indicates the plugin has lifecycle callback functions.
|
||||
// Detected when the plugin exports the nd_on_init function.
|
||||
const CapabilityLifecycle Capability = "Lifecycle"
|
||||
|
||||
const FuncOnInit = "nd_on_init"
|
||||
|
||||
func init() {
|
||||
registerCapability(
|
||||
CapabilityLifecycle,
|
||||
FuncOnInit,
|
||||
)
|
||||
}
|
||||
|
||||
// callPluginInit calls the plugin's nd_on_init function if it has the Lifecycle capability.
|
||||
// This is called after the plugin is fully loaded with all services registered.
|
||||
func callPluginInit(ctx context.Context, instance *plugin) {
|
||||
if !hasCapability(instance.capabilities, CapabilityLifecycle) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Calling plugin init function", "plugin", instance.name)
|
||||
|
||||
err := callPluginFunctionNoInput(ctx, instance, FuncOnInit)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Plugin init function failed", "plugin", instance.name, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Plugin init function completed", "plugin", instance.name)
|
||||
}
|
||||
156
plugins/cmd/ndpgen/README.md
Normal file
156
plugins/cmd/ndpgen/README.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# ndpgen
|
||||
|
||||
Navidrome Plugin Development Kit (PDK) code generator. It reads Go interface definitions with special annotations and generates client wrappers for WASM plugins.
|
||||
|
||||
This tool is the unified code generator that handle both host function wrappers and capability wrappers.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
ndpgen -input <dir> -output <dir> [-package <name>] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust]
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Description | Default |
|
||||
|--------------|----------------------------------------------------------------|----------------------|
|
||||
| `-input` | Directory containing Go source files with annotated interfaces | Required |
|
||||
| `-output` | Directory where generated files will be written | Same as input |
|
||||
| `-package` | Package name for generated files | Inferred from output |
|
||||
| `-v` | Verbose output | `false` |
|
||||
| `-dry-run` | Parse and validate without writing files | `false` |
|
||||
| `-host-only` | Generate only host function wrappers (capability support TBD) | `true` |
|
||||
| `-go` | Generate Go client wrappers | `true`* |
|
||||
| `-python` | Generate Python client wrappers | `false` |
|
||||
| `-rust` | Generate Rust client wrappers | `false` |
|
||||
|
||||
\* `-go` is enabled by default when neither `-python` nor `-rust` is specified. Use combinations like `-go -python -rust` to generate multiple languages.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
go run ./plugins/cmd/ndpgen \
|
||||
-input ./plugins/host \
|
||||
-output ./plugins/pdk
|
||||
```
|
||||
|
||||
## Annotations
|
||||
|
||||
### `//nd:hostservice`
|
||||
|
||||
Marks an interface as a host service that will have wrappers generated.
|
||||
|
||||
```go
|
||||
//nd:hostservice name=<ServiceName> permission=<permission>
|
||||
type MyService interface { ... }
|
||||
```
|
||||
|
||||
| Parameter | Description | Required |
|
||||
|--------------|-----------------------------------------------------------------|----------|
|
||||
| `name` | Service name used in generated type names and function prefixes | Yes |
|
||||
| `permission` | Permission required by plugins to use this service | Yes |
|
||||
|
||||
### `//nd:hostfunc`
|
||||
|
||||
Marks a method within a host service interface for export to plugins.
|
||||
|
||||
```go
|
||||
//nd:hostfunc [name=<export_name>]
|
||||
MethodName(ctx context.Context, ...) (result Type, err error)
|
||||
```
|
||||
|
||||
| Parameter | Description | Required |
|
||||
|-----------|-------------------------------------------------------------------------|----------|
|
||||
| `name` | Custom export name (default: `<servicename>_<methodname>` in lowercase) | No |
|
||||
|
||||
## Input Format
|
||||
|
||||
Host service interfaces must follow these conventions:
|
||||
|
||||
1. **First parameter must be `context.Context`** - Required for all methods
|
||||
2. **Last return value should be `error`** - For proper error handling
|
||||
3. **Annotations must be on consecutive lines** - No blank comment lines between doc and annotation
|
||||
|
||||
### Example Interface
|
||||
|
||||
```go
|
||||
package host
|
||||
|
||||
import "context"
|
||||
|
||||
// SubsonicAPIService provides access to Navidrome's Subsonic API.
|
||||
// This documentation becomes part of the generated code.
|
||||
//nd:hostservice name=SubsonicAPI permission=subsonicapi
|
||||
type SubsonicAPIService interface {
|
||||
// Call executes a Subsonic API request and returns the response.
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (response string, err error)
|
||||
}
|
||||
```
|
||||
|
||||
## Generated Output
|
||||
|
||||
### Go Client Library (Go/TinyGo WASM)
|
||||
|
||||
Generated files are named `nd_host_<servicename>.go` (lowercase) and placed in `$output/go/host/`. The `$output/go/` directory becomes a complete Go module (`github.com/navidrome/navidrome/plugins/pdk/go`) with package name `host`, intended for import by Navidrome plugins built with TinyGo.
|
||||
|
||||
The generator creates:
|
||||
- `nd_host_<servicename>.go` - Client wrapper code (WASM build)
|
||||
- `nd_host_<servicename>_stub.go` - Stub code for non-WASM platforms
|
||||
- `doc.go` - Package documentation listing all available services
|
||||
- `go.mod` - Go module file with required dependencies
|
||||
|
||||
Each service file includes:
|
||||
|
||||
- `// Code generated by ndpgen. DO NOT EDIT.` header
|
||||
- Required imports (`encoding/json`, `errors`, `github.com/extism/go-pdk`)
|
||||
- `//go:wasmimport` declarations for each host function
|
||||
- Response struct types and any struct definitions from the service
|
||||
- Wrapper functions that handle memory allocation and JSON parsing
|
||||
|
||||
### Python Client Library
|
||||
|
||||
When using `-python`, Python client files are generated in a `python/` subdirectory.
|
||||
|
||||
### Rust Client Library
|
||||
|
||||
When using `-rust`, Rust client files are generated in a `rust/` subdirectory.
|
||||
|
||||
## Supported Types
|
||||
|
||||
ndpgen 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"`
|
||||
}
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
go test ./plugins/cmd/ndpgen/...
|
||||
```
|
||||
23
plugins/cmd/ndpgen/go.mod
Normal file
23
plugins/cmd/ndpgen/go.mod
Normal file
@@ -0,0 +1,23 @@
|
||||
module github.com/navidrome/navidrome/plugins/cmd/ndpgen
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/onsi/ginkgo/v2 v2.22.2
|
||||
github.com/onsi/gomega v1.36.2
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/tools v0.28.0 // indirect
|
||||
)
|
||||
42
plugins/cmd/ndpgen/go.sum
Normal file
42
plugins/cmd/ndpgen/go.sum
Normal file
@@ -0,0 +1,42 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
|
||||
github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
|
||||
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
|
||||
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
531
plugins/cmd/ndpgen/integration_test.go
Normal file
531
plugins/cmd/ndpgen/integration_test.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// normalizeGeneratedCode normalizes generated code for comparison with expected output.
|
||||
func normalizeGeneratedCode(code string) string {
|
||||
// Replace package names (generated uses ndpdk, testdata may use ndhost)
|
||||
code = strings.ReplaceAll(code, "package ndhost", "package ndpdk")
|
||||
return code
|
||||
}
|
||||
|
||||
var _ = Describe("ndpgen CLI", Ordered, func() {
|
||||
var (
|
||||
testDir string
|
||||
outputDir string
|
||||
ndpgenBin string
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
// Set testdata directory (relative to ndpgen root)
|
||||
testdataDir = filepath.Join(mustGetWd(GinkgoT()), "testdata")
|
||||
|
||||
// Build the ndpgen binary
|
||||
ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test")
|
||||
cmd := exec.Command("go", "build", "-o", ndpgenBin, ".")
|
||||
cmd.Dir = mustGetWd(GinkgoT())
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Failed to build ndpgen: %s", output)
|
||||
DeferCleanup(func() {
|
||||
os.Remove(ndpgenBin)
|
||||
})
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
testDir, err = os.MkdirTemp("", "ndpgen-test-input-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
outputDir, err = os.MkdirTemp("", "ndpgen-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(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-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("Base output directory:"))
|
||||
Expect(outputStr).To(ContainSubstring("Go 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(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-dry-run")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
Expect(string(output)).To(ContainSubstring("func TestDoAction("))
|
||||
Expect(filepath.Join(outputDir, "nd_host_test.go")).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("uses default package name 'host'", func() {
|
||||
customOutput, err := os.MkdirTemp("", "mypkg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer os.RemoveAll(customOutput)
|
||||
|
||||
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", customOutput)
|
||||
_, err = cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Go code goes to $output/go/host/
|
||||
content, err := os.ReadFile(filepath.Join(customOutput, "go", "host", "nd_host_test.go"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(content)).To(ContainSubstring("package host"))
|
||||
})
|
||||
|
||||
It("returns error for invalid input directory", func() {
|
||||
cmd := exec.Command(ndpgenBin, "-input", "/nonexistent/path")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(string(output)).To(ContainSubstring("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(ndpgenBin, "-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(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
Expect(string(output)).To(ContainSubstring("Found 2 host service(s)"))
|
||||
|
||||
// Go code goes to $output/go/host/
|
||||
goHostDir := filepath.Join(outputDir, "go", "host")
|
||||
Expect(filepath.Join(goHostDir, "nd_host_servicea.go")).To(BeAnExistingFile())
|
||||
Expect(filepath.Join(goHostDir, "nd_host_serviceb.go")).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("generates Go client code by default", func() {
|
||||
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
// Go client code goes to $output/go/host/
|
||||
goHostDir := filepath.Join(outputDir, "go", "host")
|
||||
Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile())
|
||||
// Stub file also generated
|
||||
Expect(filepath.Join(goHostDir, "nd_host_test_stub.go")).To(BeAnExistingFile())
|
||||
// doc.go in host dir
|
||||
Expect(filepath.Join(goHostDir, "doc.go")).To(BeAnExistingFile())
|
||||
// go.mod at parent $output/go/ for consolidated module
|
||||
goDir := filepath.Join(outputDir, "go")
|
||||
Expect(filepath.Join(goDir, "go.mod")).To(BeAnExistingFile())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("code generation", func() {
|
||||
DescribeTable("generates correct client output",
|
||||
func(serviceFile, goClientExpectedFile, pyClientExpectedFile, rsClientExpectedFile string) {
|
||||
serviceCode := readTestdata(serviceFile)
|
||||
goClientExpected := readTestdata(goClientExpectedFile)
|
||||
pyClientExpected := readTestdata(pyClientExpectedFile)
|
||||
rsClientExpected := readTestdata(rsClientExpectedFile)
|
||||
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||
|
||||
// Generate all client code (Go, Python, Rust)
|
||||
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python", "-rust")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
// Verify Go client code (now in $output/go/host/)
|
||||
goHostDir := filepath.Join(outputDir, "go", "host")
|
||||
entries, err := os.ReadDir(goHostDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var goClientFiles []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() &&
|
||||
!strings.HasSuffix(e.Name(), "_stub.go") &&
|
||||
e.Name() != "doc.go" && e.Name() != "go.mod" {
|
||||
goClientFiles = append(goClientFiles, e.Name())
|
||||
}
|
||||
}
|
||||
Expect(goClientFiles).To(HaveLen(1), "Expected exactly one Go client file, got: %v", goClientFiles)
|
||||
|
||||
goClientActual, err := os.ReadFile(filepath.Join(goHostDir, goClientFiles[0]))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
formattedGoClientActual, err := format.Source(goClientActual)
|
||||
Expect(err).ToNot(HaveOccurred(), "Generated Go client code is not valid Go:\n%s", goClientActual)
|
||||
|
||||
// Normalize expected code to match ndpgen output format
|
||||
normalizedExpected := normalizeGeneratedCode(goClientExpected)
|
||||
formattedGoClientExpected, err := format.Source([]byte(normalizedExpected))
|
||||
Expect(err).ToNot(HaveOccurred(), "Expected Go client code is not valid Go")
|
||||
|
||||
Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch")
|
||||
|
||||
// Verify Python client code (now in $output/python/host/)
|
||||
pythonHostDir := filepath.Join(outputDir, "python", "host")
|
||||
pyClientEntries, err := os.ReadDir(pythonHostDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pyClientEntries).To(HaveLen(1), "Expected exactly one Python client file")
|
||||
|
||||
pyClientActual, err := os.ReadFile(filepath.Join(pythonHostDir, pyClientEntries[0].Name()))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch")
|
||||
|
||||
// Verify Rust client code (now in $output/rust/nd-pdk-host/src/)
|
||||
rustSrcDir := filepath.Join(outputDir, "rust", "nd-pdk-host", "src")
|
||||
rsClientEntries, err := os.ReadDir(rustSrcDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rsClientEntries).To(HaveLen(2), "Expected Rust client file and lib.rs in src/")
|
||||
|
||||
// Find the client file (not lib.rs)
|
||||
var rsClientName string
|
||||
for _, entry := range rsClientEntries {
|
||||
if entry.Name() != "lib.rs" {
|
||||
rsClientName = entry.Name()
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(rsClientName).ToNot(BeEmpty(), "Expected to find Rust client file")
|
||||
|
||||
rsClientActual, err := os.ReadFile(filepath.Join(rustSrcDir, rsClientName))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(string(rsClientActual)).To(Equal(rsClientExpected), "Rust client code mismatch")
|
||||
},
|
||||
|
||||
Entry("simple string params",
|
||||
"echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.py", "echo_client_expected.rs"),
|
||||
|
||||
Entry("multiple simple params (int32)",
|
||||
"math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.py", "math_client_expected.rs"),
|
||||
|
||||
Entry("struct param with request type",
|
||||
"store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.py", "store_client_expected.rs"),
|
||||
|
||||
Entry("mixed simple and complex params",
|
||||
"list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.py", "list_client_expected.rs"),
|
||||
|
||||
Entry("method without error",
|
||||
"counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.py", "counter_client_expected.rs"),
|
||||
|
||||
Entry("no params, error only",
|
||||
"ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.py", "ping_client_expected.rs"),
|
||||
|
||||
Entry("map and interface types",
|
||||
"meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.py", "meta_client_expected.rs"),
|
||||
|
||||
Entry("pointer types",
|
||||
"users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.py", "users_client_expected.rs"),
|
||||
|
||||
Entry("multiple returns",
|
||||
"search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.py", "search_client_expected.rs"),
|
||||
|
||||
Entry("bytes",
|
||||
"codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.py", "codec_client_expected.rs"),
|
||||
)
|
||||
|
||||
It("generates compilable client code for comprehensive service", func() {
|
||||
serviceCode := readTestdata("comprehensive_service.go.txt")
|
||||
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||
|
||||
// Generate client code
|
||||
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
|
||||
|
||||
// Go code goes to $output/go/host/
|
||||
goHostDir := filepath.Join(outputDir, "go", "host")
|
||||
|
||||
// Read generated client code
|
||||
entries, err := os.ReadDir(goHostDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Find the client file
|
||||
var clientFileName string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name != "doc.go" && name != "go.mod" && !strings.HasSuffix(name, "_stub.go") && strings.HasSuffix(name, ".go") {
|
||||
clientFileName = name
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(clientFileName).ToNot(BeEmpty(), "Expected to find Go client file")
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(goHostDir, clientFileName))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify key expected content
|
||||
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 (private types in client code)
|
||||
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()"))
|
||||
|
||||
// Create a plugin directory with proper import structure
|
||||
pluginDir := filepath.Join(outputDir, "plugin")
|
||||
Expect(os.MkdirAll(pluginDir, 0750)).To(Succeed())
|
||||
|
||||
// go.mod is at parent $output/go/ for consolidated module
|
||||
goDir := filepath.Join(outputDir, "go")
|
||||
|
||||
// Create go.mod for the plugin that imports the generated library
|
||||
goMod := fmt.Sprintf(`module testplugin
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||||
|
||||
replace github.com/navidrome/navidrome/plugins/pdk/go => %s
|
||||
`, goDir)
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
|
||||
|
||||
// Add a simple main function that imports and uses the ndpdk package
|
||||
mainGo := `package main
|
||||
|
||||
import ndpdk "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
|
||||
func main() {}
|
||||
|
||||
// Use some functions to ensure import is not unused
|
||||
var _ = ndpdk.ComprehensiveNoParams
|
||||
`
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "main.go"), []byte(mainGo), 0600)).To(Succeed())
|
||||
|
||||
// Tidy dependencies for the generated go library
|
||||
goTidyLibCmd := exec.Command("go", "mod", "tidy")
|
||||
goTidyLibCmd.Dir = goDir
|
||||
goTidyLibOutput, err := goTidyLibCmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "go mod tidy (library) failed: %s", goTidyLibOutput)
|
||||
|
||||
// Tidy dependencies for the plugin
|
||||
goTidyCmd := exec.Command("go", "mod", "tidy")
|
||||
goTidyCmd.Dir = pluginDir
|
||||
goTidyOutput, err := goTidyCmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "go mod tidy (plugin) 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 = pluginDir
|
||||
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(pluginDir, "plugin.wasm")).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("generates Python client code with -python flag", 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())
|
||||
|
||||
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
// Verify Python client code exists in $output/python/host/
|
||||
pythonHostDir := filepath.Join(outputDir, "python", "host")
|
||||
Expect(pythonHostDir).To(BeADirectory())
|
||||
|
||||
pythonFile := filepath.Join(pythonHostDir, "nd_host_test.py")
|
||||
Expect(pythonFile).To(BeAnExistingFile())
|
||||
|
||||
content, err := os.ReadFile(pythonFile)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
contentStr := string(content)
|
||||
Expect(contentStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
|
||||
Expect(contentStr).To(ContainSubstring("class HostFunctionError(Exception):"))
|
||||
Expect(contentStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "test_doaction")`))
|
||||
Expect(contentStr).To(ContainSubstring("def test_do_action(input: str) -> str:"))
|
||||
})
|
||||
|
||||
It("generates both Go and Python client code with -go -python flags", 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())
|
||||
|
||||
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
// Verify Go client code exists in $output/go/host/
|
||||
goHostDir := filepath.Join(outputDir, "go", "host")
|
||||
Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile())
|
||||
|
||||
// Verify Python client code exists in $output/python/host/
|
||||
pythonHostDir := filepath.Join(outputDir, "python", "host")
|
||||
Expect(pythonHostDir).To(BeADirectory())
|
||||
Expect(filepath.Join(pythonHostDir, "nd_host_test.py")).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("generates Python code with dataclass for multi-value returns", func() {
|
||||
serviceCode := `package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Cache permission=cache
|
||||
type CacheService interface {
|
||||
//nd:hostfunc
|
||||
GetString(ctx context.Context, key string) (value string, exists bool, err error)
|
||||
}
|
||||
`
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||
|
||||
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_cache.py"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
contentStr := string(content)
|
||||
Expect(contentStr).To(ContainSubstring("@dataclass"))
|
||||
Expect(contentStr).To(ContainSubstring("class CacheGetStringResult:"))
|
||||
Expect(contentStr).To(ContainSubstring("value: str"))
|
||||
Expect(contentStr).To(ContainSubstring("exists: bool"))
|
||||
Expect(contentStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
|
||||
})
|
||||
|
||||
It("generates Python code for methods with no parameters", func() {
|
||||
serviceCode := `package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc
|
||||
Ping(ctx context.Context) (status string, err error)
|
||||
}
|
||||
`
|
||||
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
|
||||
|
||||
cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python")
|
||||
output, err := cmd.CombinedOutput()
|
||||
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_test.py"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
contentStr := string(content)
|
||||
Expect(contentStr).To(ContainSubstring("def test_ping() -> str:"))
|
||||
Expect(contentStr).To(ContainSubstring(`request_bytes = b"{}"`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func mustGetWd(t FullGinkgoTInterface) string {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Look for ndpgen's own go.mod (the subproject root)
|
||||
for {
|
||||
goModPath := filepath.Join(dir, "go.mod")
|
||||
if _, err := os.Stat(goModPath); err == nil {
|
||||
// Check if this is the ndpgen go.mod by reading it
|
||||
content, err := os.ReadFile(goModPath)
|
||||
if err == nil && strings.Contains(string(content), "plugins/cmd/ndpgen") {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatal("could not find ndpgen project root")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
598
plugins/cmd/ndpgen/internal/generator.go
Normal file
598
plugins/cmd/ndpgen/internal/generator.go
Normal file
@@ -0,0 +1,598 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//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()) },
|
||||
"requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
|
||||
"responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
|
||||
}
|
||||
}
|
||||
|
||||
// clientFuncMap returns the template functions for client code generation.
|
||||
// Uses private (lowercase) type names for request/response structs.
|
||||
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()) },
|
||||
"requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) },
|
||||
"responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) },
|
||||
"formatDoc": formatDoc,
|
||||
}
|
||||
}
|
||||
|
||||
// pythonFuncMap returns the template functions for Python client code generation.
|
||||
func pythonFuncMap(svc Service) template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"lower": strings.ToLower,
|
||||
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
|
||||
"pythonFunc": func(m Method) string { return m.PythonFunctionName(svc.ExportPrefix()) },
|
||||
"pythonResultType": func(m Method) string { return m.PythonResultTypeName(svc.Name) },
|
||||
"pythonDefault": pythonDefaultValue,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
data := templateData{
|
||||
Package: pkgName,
|
||||
Service: 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
|
||||
}
|
||||
|
||||
// GenerateClientGo generates client wrapper code for plugins to call host functions.
|
||||
func GenerateClientGo(svc Service, pkgName string) ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/client.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{
|
||||
Package: pkgName,
|
||||
Service: 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
|
||||
}
|
||||
|
||||
// GenerateClientGoStub generates stub code for non-WASM platforms.
|
||||
// These stubs provide type definitions and function signatures for IDE support,
|
||||
// but panic at runtime since host functions are only available in WASM plugins.
|
||||
func GenerateClientGoStub(svc Service, pkgName string) ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/client_stub.go.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading client stub template: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("client_stub").Funcs(clientFuncMap(svc)).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
data := templateData{
|
||||
Package: pkgName,
|
||||
Service: 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
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(doc), "\n")
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
result = append(result, "// "+strings.TrimRight(line, " \t"))
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// GenerateClientPython generates Python client wrapper code for plugins.
|
||||
func GenerateClientPython(svc Service) ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/client.py.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading Python client template: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("client_py").Funcs(pythonFuncMap(svc)).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
data := templateData{
|
||||
Service: 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
|
||||
}
|
||||
|
||||
// pythonDefaultValue returns a Python default value for response.get() calls.
|
||||
func pythonDefaultValue(p Param) string {
|
||||
switch p.Type {
|
||||
case "string":
|
||||
return `, ""`
|
||||
case "int", "int32", "int64":
|
||||
return ", 0"
|
||||
case "float32", "float64":
|
||||
return ", 0.0"
|
||||
case "bool":
|
||||
return ", False"
|
||||
case "[]byte":
|
||||
return ", b\"\""
|
||||
default:
|
||||
return ", None"
|
||||
}
|
||||
}
|
||||
|
||||
// rustFuncMap returns the template functions for Rust client code generation.
|
||||
func rustFuncMap(svc Service) template.FuncMap {
|
||||
knownStructs := svc.KnownStructs()
|
||||
return template.FuncMap{
|
||||
"lower": strings.ToLower,
|
||||
"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) },
|
||||
"rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) },
|
||||
"rustDocComment": RustDocComment,
|
||||
"rustType": func(p Param) string { return p.RustTypeWithStructs(knownStructs) },
|
||||
"rustParamType": func(p Param) string { return p.RustParamTypeWithStructs(knownStructs) },
|
||||
"fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) },
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateClientRust generates Rust client wrapper code for plugins.
|
||||
func GenerateClientRust(svc Service) ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/client.rs.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading Rust client template: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("client_rs").Funcs(rustFuncMap(svc)).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
data := templateData{
|
||||
Service: 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
|
||||
}
|
||||
|
||||
// firstLine returns the first line of a multi-line string, with the first word removed.
|
||||
func firstLine(s string) string {
|
||||
line := s
|
||||
if idx := strings.Index(s, "\n"); idx >= 0 {
|
||||
line = s[:idx]
|
||||
}
|
||||
// Remove the first word (service name like "ArtworkService")
|
||||
if idx := strings.Index(line, " "); idx >= 0 {
|
||||
line = line[idx+1:]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// GenerateRustLib generates the lib.rs file that exposes all service modules.
|
||||
func GenerateRustLib(services []Service) ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/lib.rs.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading Rust lib template: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("lib_rs").Funcs(template.FuncMap{
|
||||
"lower": strings.ToLower,
|
||||
"firstLine": firstLine,
|
||||
}).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Services []Service
|
||||
}{
|
||||
Services: services,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("executing template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GenerateGoDoc generates the doc.go file that provides package documentation.
|
||||
func GenerateGoDoc(services []Service, pkgName string) ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/doc.go.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading Go doc template: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("doc_go").Funcs(template.FuncMap{
|
||||
"firstLine": firstLine,
|
||||
}).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Package string
|
||||
Services []Service
|
||||
}{
|
||||
Package: pkgName,
|
||||
Services: services,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("executing template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GenerateGoMod generates the go.mod file for the Go client library.
|
||||
func GenerateGoMod() ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/go.mod.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading go.mod template: %w", err)
|
||||
}
|
||||
return tmplContent, nil
|
||||
}
|
||||
|
||||
// capabilityTemplateData holds data for capability template execution.
|
||||
type capabilityTemplateData struct {
|
||||
Package string
|
||||
Capability Capability
|
||||
}
|
||||
|
||||
// capabilityFuncMap returns template functions for capability code generation.
|
||||
func capabilityFuncMap(cap Capability) template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatDoc": formatDoc,
|
||||
"indent": indentText,
|
||||
"agentName": capabilityAgentName,
|
||||
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
||||
"implVar": func(e Export) string { return e.ImplVarName() },
|
||||
"exportFunc": func(e Export) string { return e.ExportFuncName() },
|
||||
}
|
||||
}
|
||||
|
||||
// indentText adds n tabs to each line of text.
|
||||
func indentText(n int, s string) string {
|
||||
indent := strings.Repeat("\t", n)
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
if line != "" {
|
||||
lines[i] = indent + line
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// capabilityAgentName returns the interface name for a capability.
|
||||
// Uses the Go interface name stripped of common suffixes.
|
||||
func capabilityAgentName(cap Capability) string {
|
||||
name := cap.Interface
|
||||
// Remove common suffixes to get a clean name
|
||||
for _, suffix := range []string{"Agent", "Callback", "Service"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
name = name[:len(name)-len(suffix)]
|
||||
break
|
||||
}
|
||||
}
|
||||
// Use the shortened name or the original if no suffix found
|
||||
if name == "" {
|
||||
name = cap.Interface
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// GenerateCapabilityGo generates Go export wrapper code for a capability.
|
||||
func GenerateCapabilityGo(cap Capability, pkgName string) ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/capability.go.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading capability template: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("capability").Funcs(capabilityFuncMap(cap)).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
data := capabilityTemplateData{
|
||||
Package: pkgName,
|
||||
Capability: cap,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("executing template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GenerateCapabilityGoStub generates stub code for non-WASM platforms.
|
||||
func GenerateCapabilityGoStub(cap Capability, pkgName string) ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/capability_stub.go.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading capability stub template: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("capability_stub").Funcs(capabilityFuncMap(cap)).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
data := capabilityTemplateData{
|
||||
Package: pkgName,
|
||||
Capability: cap,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("executing template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// rustCapabilityFuncMap returns template functions for Rust capability code generation.
|
||||
func rustCapabilityFuncMap(cap Capability) template.FuncMap {
|
||||
knownStructs := cap.KnownStructs()
|
||||
return template.FuncMap{
|
||||
"rustDocComment": RustDocComment,
|
||||
"rustTypeAlias": rustTypeAlias,
|
||||
"rustConstType": rustConstType,
|
||||
"rustConstName": rustConstName,
|
||||
"rustFieldName": func(name string) string { return ToSnakeCase(name) },
|
||||
"rustMethodName": func(name string) string { return ToSnakeCase(name) },
|
||||
"fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) },
|
||||
"rustOutputType": rustOutputType,
|
||||
"isPrimitiveRust": isPrimitiveRustType,
|
||||
"skipSerializingFunc": skipSerializingFunc,
|
||||
"hasHashMap": hasHashMap,
|
||||
"agentName": capabilityAgentName,
|
||||
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
||||
"registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) },
|
||||
"snakeCase": ToSnakeCase,
|
||||
"indent": func(spaces int, s string) string {
|
||||
indent := strings.Repeat(" ", spaces)
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
if line != "" {
|
||||
lines[i] = indent + line
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// rustTypeAlias converts a Go type to its Rust equivalent for type aliases.
|
||||
// For string types used as error sentinels/constants, we use &'static str
|
||||
// since Rust consts can't be heap-allocated String values.
|
||||
func rustTypeAlias(goType string) string {
|
||||
switch goType {
|
||||
case "string":
|
||||
return "&'static str"
|
||||
case "int", "int32":
|
||||
return "i32"
|
||||
case "int64":
|
||||
return "i64"
|
||||
default:
|
||||
return goType
|
||||
}
|
||||
}
|
||||
|
||||
// rustConstType converts a Go type to its Rust equivalent for const declarations.
|
||||
// For String types, it returns &'static str since Rust consts can't be heap-allocated.
|
||||
func rustConstType(goType string) string {
|
||||
switch goType {
|
||||
case "string", "String":
|
||||
return "&'static str"
|
||||
case "int", "int32":
|
||||
return "i32"
|
||||
case "int64":
|
||||
return "i64"
|
||||
default:
|
||||
return goType
|
||||
}
|
||||
}
|
||||
|
||||
// rustOutputType converts a Go type to Rust for capability method signatures.
|
||||
// It handles pointer types specially - for capability outputs, pointers become the base type
|
||||
// (not Option<T>) because Rust's Result<T, Error> already provides optional semantics.
|
||||
//
|
||||
// TODO: Pointer to primitive types (e.g., *string, *int32) are not handled correctly.
|
||||
// Currently "*string" returns "string" instead of "String". This would generate invalid
|
||||
// Rust code. No current capability uses this pattern, but it should be fixed if needed.
|
||||
func rustOutputType(goType string) string {
|
||||
// Strip pointer prefix - capability outputs use Result<T, Error> for optionality
|
||||
if strings.HasPrefix(goType, "*") {
|
||||
return goType[1:]
|
||||
}
|
||||
// Convert Go primitives to Rust primitives
|
||||
switch goType {
|
||||
case "bool":
|
||||
return "bool"
|
||||
case "string":
|
||||
return "String"
|
||||
case "int", "int32":
|
||||
return "i32"
|
||||
case "int64":
|
||||
return "i64"
|
||||
case "float32":
|
||||
return "f32"
|
||||
case "float64":
|
||||
return "f64"
|
||||
}
|
||||
return goType
|
||||
}
|
||||
|
||||
// isPrimitiveRustType returns true if the Go type maps to a Rust primitive type.
|
||||
func isPrimitiveRustType(goType string) bool {
|
||||
// Strip pointer prefix first
|
||||
if strings.HasPrefix(goType, "*") {
|
||||
goType = goType[1:]
|
||||
}
|
||||
switch goType {
|
||||
case "bool", "string", "int", "int32", "int64", "float32", "float64":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rustConstName converts a Go const name to Rust convention (SCREAMING_SNAKE_CASE).
|
||||
func rustConstName(name string) string {
|
||||
return strings.ToUpper(ToSnakeCase(name))
|
||||
}
|
||||
|
||||
// skipSerializingFunc returns the appropriate skip_serializing_if function name.
|
||||
func skipSerializingFunc(goType string) string {
|
||||
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") {
|
||||
return "Option::is_none"
|
||||
}
|
||||
switch goType {
|
||||
case "string":
|
||||
return "String::is_empty"
|
||||
case "bool":
|
||||
return "std::ops::Not::not"
|
||||
default:
|
||||
return "Option::is_none"
|
||||
}
|
||||
}
|
||||
|
||||
// hasHashMap returns true if any struct in the capability uses HashMap.
|
||||
func hasHashMap(cap Capability) bool {
|
||||
for _, st := range cap.Structs {
|
||||
for _, f := range st.Fields {
|
||||
if strings.HasPrefix(f.Type, "map[") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// registerMacroName returns the macro name for registering an optional method.
|
||||
// For package "websocket" and method "OnClose", returns "register_websocket_close".
|
||||
func registerMacroName(pkg, name string) string {
|
||||
// Remove common prefixes from method name
|
||||
for _, prefix := range []string{"Get", "On"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
name = name[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
return "register_" + ToSnakeCase(pkg) + "_" + ToSnakeCase(name)
|
||||
}
|
||||
|
||||
// GenerateCapabilityRust generates Rust export wrapper code for a capability.
|
||||
func GenerateCapabilityRust(cap Capability) ([]byte, error) {
|
||||
tmplContent, err := templatesFS.ReadFile("templates/capability.rs.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading Rust capability template: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("capability_rust").Funcs(rustCapabilityFuncMap(cap)).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
data := capabilityTemplateData{
|
||||
Package: cap.Name,
|
||||
Capability: cap,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("executing template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GenerateCapabilityRustLib generates the lib.rs file for the Rust capabilities crate.
|
||||
func GenerateCapabilityRustLib(capabilities []Capability) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("// Code generated by ndpgen. DO NOT EDIT.\n\n")
|
||||
buf.WriteString("//! Navidrome Plugin Development Kit - Capability Wrappers\n")
|
||||
buf.WriteString("//!\n")
|
||||
buf.WriteString("//! This crate provides type definitions, traits, and registration macros\n")
|
||||
buf.WriteString("//! for implementing Navidrome plugin capabilities in Rust.\n\n")
|
||||
|
||||
// Module declarations
|
||||
for _, cap := range capabilities {
|
||||
moduleName := ToSnakeCase(cap.Name)
|
||||
buf.WriteString(fmt.Sprintf("pub mod %s;\n", moduleName))
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
1160
plugins/cmd/ndpgen/internal/generator_test.go
Normal file
1160
plugins/cmd/ndpgen/internal/generator_test.go
Normal file
File diff suppressed because it is too large
Load Diff
13
plugins/cmd/ndpgen/internal/internal_suite_test.go
Normal file
13
plugins/cmd/ndpgen/internal/internal_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestInternal(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "NDPGen Internal Suite")
|
||||
}
|
||||
838
plugins/cmd/ndpgen/internal/parser.go
Normal file
838
plugins/cmd/ndpgen/internal/parser.go
Normal file
@@ -0,0 +1,838 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"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+(.*))?`)
|
||||
// //nd:capability name=PackageName [required=true]
|
||||
capabilityPattern = regexp.MustCompile(`//nd:capability\s+(.*)`)
|
||||
// //nd:export name=ExportName
|
||||
exportPattern = regexp.MustCompile(`//nd:export\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
|
||||
}
|
||||
|
||||
// ParseCapabilities parses all Go source files in a directory and extracts capabilities.
|
||||
func ParseCapabilities(dir string) ([]Capability, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading directory: %w", err)
|
||||
}
|
||||
|
||||
var capabilities []Capability
|
||||
fset := token.NewFileSet()
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
// Skip generated files, test files, and doc.go
|
||||
if strings.HasSuffix(entry.Name(), "_gen.go") ||
|
||||
strings.HasSuffix(entry.Name(), "_test.go") ||
|
||||
entry.Name() == "doc.go" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
parsed, err := parseCapabilityFile(fset, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err)
|
||||
}
|
||||
capabilities = append(capabilities, parsed...)
|
||||
}
|
||||
|
||||
return capabilities, nil
|
||||
}
|
||||
|
||||
// parseCapabilityFile parses a single Go source file and extracts capabilities.
|
||||
func parseCapabilityFile(fset *token.FileSet, path string) ([]Capability, error) {
|
||||
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// First pass: collect all struct definitions in the file
|
||||
allStructs := parseStructs(f)
|
||||
structMap := make(map[string]StructDef)
|
||||
for _, s := range allStructs {
|
||||
structMap[s.Name] = s
|
||||
}
|
||||
|
||||
// Collect type aliases and consts
|
||||
allTypeAliases := parseTypeAliases(f)
|
||||
aliasMap := make(map[string]TypeAlias)
|
||||
for _, a := range allTypeAliases {
|
||||
aliasMap[a.Name] = a
|
||||
}
|
||||
allConstGroups := parseConstGroups(f)
|
||||
|
||||
var capabilities []Capability
|
||||
|
||||
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:capability annotation in doc comment
|
||||
docText, rawDoc := getDocComment(genDecl, typeSpec)
|
||||
capAnnotation := parseCapabilityAnnotation(rawDoc)
|
||||
if capAnnotation == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract source file base name (e.g., "websocket_callback" from "websocket_callback.go")
|
||||
baseName := filepath.Base(path)
|
||||
sourceFile := strings.TrimSuffix(baseName, ".go")
|
||||
|
||||
capability := Capability{
|
||||
Name: capAnnotation["name"],
|
||||
Interface: typeSpec.Name.Name,
|
||||
Required: capAnnotation["required"] == "true",
|
||||
Doc: cleanDoc(docText),
|
||||
SourceFile: sourceFile,
|
||||
}
|
||||
|
||||
// Parse methods and collect referenced types
|
||||
referencedTypes := make(map[string]bool)
|
||||
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:export annotation
|
||||
methodDocText, methodRawDoc := getMethodDocComment(method)
|
||||
exportAnnotation := parseExportAnnotation(methodRawDoc)
|
||||
if exportAnnotation == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
export, err := parseExport(method.Names[0].Name, funcType, exportAnnotation, cleanDoc(methodDocText))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing export %s.%s: %w", typeSpec.Name.Name, method.Names[0].Name, err)
|
||||
}
|
||||
capability.Methods = append(capability.Methods, export)
|
||||
|
||||
// Collect referenced types from input and output
|
||||
collectReferencedTypes(export.Input.Type, referencedTypes)
|
||||
collectReferencedTypes(export.Output.Type, referencedTypes)
|
||||
}
|
||||
|
||||
// Recursively collect all struct dependencies
|
||||
collectAllStructDependencies(referencedTypes, structMap)
|
||||
|
||||
// Sort type names for stable output order
|
||||
sortedTypeNames := slices.Sorted(maps.Keys(referencedTypes))
|
||||
|
||||
// Attach referenced structs to the capability
|
||||
for _, typeName := range sortedTypeNames {
|
||||
if s, exists := structMap[typeName]; exists {
|
||||
capability.Structs = append(capability.Structs, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Attach referenced type aliases
|
||||
for _, typeName := range sortedTypeNames {
|
||||
if a, exists := aliasMap[typeName]; exists {
|
||||
capability.TypeAliases = append(capability.TypeAliases, a)
|
||||
}
|
||||
}
|
||||
|
||||
// Also attach type aliases prefixed with interface name (e.g., ScrobblerError for Scrobbler interface)
|
||||
// This supports error types that are not directly referenced in method signatures
|
||||
interfaceName := typeSpec.Name.Name
|
||||
for _, typeName := range slices.Sorted(maps.Keys(aliasMap)) {
|
||||
a := aliasMap[typeName]
|
||||
if strings.HasPrefix(typeName, interfaceName) && !referencedTypes[typeName] {
|
||||
capability.TypeAliases = append(capability.TypeAliases, a)
|
||||
referencedTypes[typeName] = true // Mark as referenced for const lookup
|
||||
}
|
||||
}
|
||||
|
||||
// Attach const groups that match referenced type aliases
|
||||
for _, group := range allConstGroups {
|
||||
if group.Type == "" {
|
||||
continue
|
||||
}
|
||||
if referencedTypes[group.Type] {
|
||||
capability.Consts = append(capability.Consts, group)
|
||||
}
|
||||
}
|
||||
|
||||
if len(capability.Methods) > 0 {
|
||||
capabilities = append(capabilities, capability)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities, nil
|
||||
}
|
||||
|
||||
// collectAllStructDependencies recursively collects all struct types referenced by other structs.
|
||||
func collectAllStructDependencies(referencedTypes map[string]bool, structMap map[string]StructDef) {
|
||||
// Keep iterating until no new types are added
|
||||
for {
|
||||
newTypes := make(map[string]bool)
|
||||
for typeName := range referencedTypes {
|
||||
if s, exists := structMap[typeName]; exists {
|
||||
for _, field := range s.Fields {
|
||||
collectReferencedTypes(field.Type, newTypes)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check if any new types were found
|
||||
foundNew := false
|
||||
for t := range newTypes {
|
||||
if !referencedTypes[t] {
|
||||
referencedTypes[t] = true
|
||||
foundNew = true
|
||||
}
|
||||
}
|
||||
if !foundNew {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseExport parses an export method signature into an Export struct.
|
||||
func parseExport(name string, funcType *ast.FuncType, annotation map[string]string, doc string) (Export, error) {
|
||||
export := Export{
|
||||
Name: name,
|
||||
ExportName: annotation["name"],
|
||||
Doc: doc,
|
||||
}
|
||||
|
||||
// Capability exports have exactly one input parameter (the struct type)
|
||||
if funcType.Params != nil && len(funcType.Params.List) == 1 {
|
||||
field := funcType.Params.List[0]
|
||||
typeName := typeToString(field.Type)
|
||||
paramName := "input"
|
||||
if len(field.Names) > 0 {
|
||||
paramName = field.Names[0].Name
|
||||
}
|
||||
export.Input = NewParam(paramName, typeName)
|
||||
}
|
||||
|
||||
// Capability exports return (OutputType, error)
|
||||
if funcType.Results != nil {
|
||||
for _, field := range funcType.Results.List {
|
||||
typeName := typeToString(field.Type)
|
||||
if typeName == "error" {
|
||||
continue // Skip error return
|
||||
}
|
||||
paramName := "output"
|
||||
if len(field.Names) > 0 {
|
||||
paramName = field.Names[0].Name
|
||||
}
|
||||
export.Output = NewParam(paramName, typeName)
|
||||
break // Only take the first non-error return
|
||||
}
|
||||
}
|
||||
|
||||
return export, 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
|
||||
}
|
||||
|
||||
// First pass: collect all struct definitions in the file
|
||||
allStructs := parseStructs(f)
|
||||
structMap := make(map[string]StructDef)
|
||||
for _, s := range allStructs {
|
||||
structMap[s.Name] = s
|
||||
}
|
||||
|
||||
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 and collect referenced types
|
||||
referencedTypes := make(map[string]bool)
|
||||
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)
|
||||
|
||||
// Collect referenced types from params and returns
|
||||
for _, p := range m.Params {
|
||||
collectReferencedTypes(p.Type, referencedTypes)
|
||||
}
|
||||
for _, r := range m.Returns {
|
||||
collectReferencedTypes(r.Type, referencedTypes)
|
||||
}
|
||||
}
|
||||
|
||||
// Attach referenced structs to the service (sorted for stable output)
|
||||
for _, typeName := range slices.Sorted(maps.Keys(referencedTypes)) {
|
||||
if s, exists := structMap[typeName]; exists {
|
||||
service.Structs = append(service.Structs, s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(service.Methods) > 0 {
|
||||
services = append(services, service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// parseStructs extracts all struct type definitions from a parsed Go file.
|
||||
func parseStructs(f *ast.File) []StructDef {
|
||||
var structs []StructDef
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
structType, ok := typeSpec.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
docText, _ := getDocComment(genDecl, typeSpec)
|
||||
s := StructDef{
|
||||
Name: typeSpec.Name.Name,
|
||||
Doc: cleanDoc(docText),
|
||||
}
|
||||
|
||||
// Parse struct fields
|
||||
for _, field := range structType.Fields.List {
|
||||
if len(field.Names) == 0 {
|
||||
continue // Embedded field
|
||||
}
|
||||
|
||||
fieldDef := parseStructField(field)
|
||||
s.Fields = append(s.Fields, fieldDef...)
|
||||
}
|
||||
|
||||
structs = append(structs, s)
|
||||
}
|
||||
}
|
||||
|
||||
return structs
|
||||
}
|
||||
|
||||
// parseTypeAliases extracts all type alias definitions from a parsed Go file.
|
||||
// Type aliases are non-struct type declarations like: type MyType string
|
||||
func parseTypeAliases(f *ast.File) []TypeAlias {
|
||||
var aliases []TypeAlias
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Skip struct and interface types
|
||||
if _, isStruct := typeSpec.Type.(*ast.StructType); isStruct {
|
||||
continue
|
||||
}
|
||||
if _, isInterface := typeSpec.Type.(*ast.InterfaceType); isInterface {
|
||||
continue
|
||||
}
|
||||
|
||||
docText, _ := getDocComment(genDecl, typeSpec)
|
||||
aliases = append(aliases, TypeAlias{
|
||||
Name: typeSpec.Name.Name,
|
||||
Type: typeToString(typeSpec.Type),
|
||||
Doc: cleanDoc(docText),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return aliases
|
||||
}
|
||||
|
||||
// parseConstGroups extracts const groups from a parsed Go file.
|
||||
func parseConstGroups(f *ast.File) []ConstGroup {
|
||||
var groups []ConstGroup
|
||||
|
||||
for _, decl := range f.Decls {
|
||||
genDecl, ok := decl.(*ast.GenDecl)
|
||||
if !ok || genDecl.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
|
||||
group := ConstGroup{}
|
||||
for _, spec := range genDecl.Specs {
|
||||
valueSpec, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get type if specified
|
||||
if valueSpec.Type != nil && group.Type == "" {
|
||||
group.Type = typeToString(valueSpec.Type)
|
||||
}
|
||||
|
||||
// Extract values
|
||||
for i, name := range valueSpec.Names {
|
||||
def := ConstDef{
|
||||
Name: name.Name,
|
||||
}
|
||||
// Get value if present
|
||||
if i < len(valueSpec.Values) {
|
||||
def.Value = exprToString(valueSpec.Values[i])
|
||||
}
|
||||
// Get doc comment
|
||||
if valueSpec.Doc != nil {
|
||||
def.Doc = cleanDoc(valueSpec.Doc.Text())
|
||||
} else if valueSpec.Comment != nil {
|
||||
def.Doc = cleanDoc(valueSpec.Comment.Text())
|
||||
}
|
||||
group.Values = append(group.Values, def)
|
||||
}
|
||||
}
|
||||
|
||||
if len(group.Values) > 0 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// exprToString converts an AST expression to a Go source string.
|
||||
func exprToString(expr ast.Expr) string {
|
||||
switch e := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
return e.Value
|
||||
case *ast.Ident:
|
||||
return e.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parseStructField parses a struct field and returns FieldDef for each name.
|
||||
func parseStructField(field *ast.Field) []FieldDef {
|
||||
var fields []FieldDef
|
||||
typeName := typeToString(field.Type)
|
||||
|
||||
// Parse struct tag for JSON field name and omitempty
|
||||
jsonTag := ""
|
||||
omitEmpty := false
|
||||
if field.Tag != nil {
|
||||
tag := field.Tag.Value
|
||||
// Remove backticks
|
||||
tag = strings.Trim(tag, "`")
|
||||
// Parse json tag
|
||||
jsonTag, omitEmpty = parseJSONTag(tag)
|
||||
}
|
||||
|
||||
// Get doc comment
|
||||
var doc string
|
||||
if field.Doc != nil {
|
||||
doc = cleanDoc(field.Doc.Text())
|
||||
}
|
||||
|
||||
for _, name := range field.Names {
|
||||
fieldJSONTag := jsonTag
|
||||
if fieldJSONTag == "" {
|
||||
// Default to field name with camelCase
|
||||
fieldJSONTag = toJSONName(name.Name)
|
||||
}
|
||||
fields = append(fields, FieldDef{
|
||||
Name: name.Name,
|
||||
Type: typeName,
|
||||
JSONTag: fieldJSONTag,
|
||||
OmitEmpty: omitEmpty,
|
||||
Doc: doc,
|
||||
})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// parseJSONTag extracts the json field name and omitempty flag from a struct tag.
|
||||
func parseJSONTag(tag string) (name string, omitEmpty bool) {
|
||||
// Find json:"..." in the tag
|
||||
for _, part := range strings.Split(tag, " ") {
|
||||
if strings.HasPrefix(part, `json:"`) {
|
||||
value := strings.TrimPrefix(part, `json:"`)
|
||||
value = strings.TrimSuffix(value, `"`)
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) > 0 && parts[0] != "-" {
|
||||
name = parts[0]
|
||||
}
|
||||
for _, opt := range parts[1:] {
|
||||
if opt == "omitempty" {
|
||||
omitEmpty = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// collectReferencedTypes extracts custom type names from a Go type string.
|
||||
// It handles pointers, slices, and maps, collecting base type names.
|
||||
func collectReferencedTypes(goType string, refs map[string]bool) {
|
||||
// Strip pointer
|
||||
if strings.HasPrefix(goType, "*") {
|
||||
collectReferencedTypes(goType[1:], refs)
|
||||
return
|
||||
}
|
||||
// Strip slice
|
||||
if strings.HasPrefix(goType, "[]") {
|
||||
if goType != "[]byte" {
|
||||
collectReferencedTypes(goType[2:], refs)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Handle map
|
||||
if strings.HasPrefix(goType, "map[") {
|
||||
rest := goType[4:] // Remove "map["
|
||||
depth := 1
|
||||
keyEnd := 0
|
||||
for i, r := range rest {
|
||||
if r == '[' {
|
||||
depth++
|
||||
} else if r == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
keyEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
keyType := rest[:keyEnd]
|
||||
valueType := rest[keyEnd+1:]
|
||||
collectReferencedTypes(keyType, refs)
|
||||
collectReferencedTypes(valueType, refs)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a custom type (starts with uppercase, not a builtin)
|
||||
if len(goType) > 0 && goType[0] >= 'A' && goType[0] <= 'Z' {
|
||||
switch goType {
|
||||
case "String", "Bool", "Int", "Int32", "Int64", "Float32", "Float64":
|
||||
// Not custom types (just capitalized for some reason)
|
||||
default:
|
||||
refs[goType] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toJSONName is imported from types.go via the same package
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// parseCapabilityAnnotation extracts //nd:capability annotation parameters.
|
||||
func parseCapabilityAnnotation(doc string) map[string]string {
|
||||
for _, line := range strings.Split(doc, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
match := capabilityPattern.FindStringSubmatch(line)
|
||||
if match != nil {
|
||||
return parseKeyValuePairs(match[1])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseExportAnnotation extracts //nd:export annotation parameters.
|
||||
func parseExportAnnotation(doc string) map[string]string {
|
||||
for _, line := range strings.Split(doc, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
match := exportPattern.FindStringSubmatch(line)
|
||||
if match != nil {
|
||||
return parseKeyValuePairs(match[1])
|
||||
}
|
||||
}
|
||||
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"))
|
||||
}
|
||||
547
plugins/cmd/ndpgen/internal/parser_test.go
Normal file
547
plugins/cmd/ndpgen/internal/parser_test.go
Normal file
@@ -0,0 +1,547 @@
|
||||
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("", "ndpgen-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"}
|
||||
// Host-side types are public
|
||||
Expect(m.RequestTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallRequest"))
|
||||
Expect(m.ResponseTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallResponse"))
|
||||
// Client/PDK types are private
|
||||
Expect(m.ClientRequestTypeName("SubsonicAPI")).To(Equal("subsonicAPICallRequest"))
|
||||
Expect(m.ClientResponseTypeName("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"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ParseCapabilities", func() {
|
||||
It("should parse a simple capability interface", func() {
|
||||
src := `package capabilities
|
||||
|
||||
// MetadataAgent provides metadata retrieval.
|
||||
//nd:capability name=metadata
|
||||
type MetadataAgent interface {
|
||||
// GetArtistBiography returns artist biography.
|
||||
//nd:export name=nd_get_artist_biography
|
||||
GetArtistBiography(ArtistInput) (ArtistBiographyOutput, error)
|
||||
}
|
||||
|
||||
// ArtistInput is the input for artist-related functions.
|
||||
type ArtistInput struct {
|
||||
// ID is the artist ID.
|
||||
ID string ` + "`json:\"id\"`" + `
|
||||
// Name is the artist name.
|
||||
Name string ` + "`json:\"name\"`" + `
|
||||
}
|
||||
|
||||
// ArtistBiographyOutput is the output for GetArtistBiography.
|
||||
type ArtistBiographyOutput struct {
|
||||
// Biography is the biography text.
|
||||
Biography string ` + "`json:\"biography\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "metadata.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
cap := capabilities[0]
|
||||
Expect(cap.Name).To(Equal("metadata"))
|
||||
Expect(cap.Interface).To(Equal("MetadataAgent"))
|
||||
Expect(cap.Required).To(BeFalse())
|
||||
Expect(cap.Doc).To(ContainSubstring("MetadataAgent provides metadata retrieval"))
|
||||
Expect(cap.Methods).To(HaveLen(1))
|
||||
|
||||
m := cap.Methods[0]
|
||||
Expect(m.Name).To(Equal("GetArtistBiography"))
|
||||
Expect(m.ExportName).To(Equal("nd_get_artist_biography"))
|
||||
Expect(m.Input.Type).To(Equal("ArtistInput"))
|
||||
Expect(m.Output.Type).To(Equal("ArtistBiographyOutput"))
|
||||
|
||||
// Check structs were collected
|
||||
Expect(cap.Structs).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("should parse a required capability", func() {
|
||||
src := `package capabilities
|
||||
|
||||
// Scrobbler requires all methods to be implemented.
|
||||
//nd:capability name=scrobbler required=true
|
||||
type Scrobbler interface {
|
||||
//nd:export name=nd_scrobbler_is_authorized
|
||||
IsAuthorized(AuthInput) (AuthOutput, error)
|
||||
|
||||
//nd:export name=nd_scrobbler_scrobble
|
||||
Scrobble(ScrobbleInput) (ScrobblerOutput, error)
|
||||
}
|
||||
|
||||
type AuthInput struct {
|
||||
UserID string ` + "`json:\"userId\"`" + `
|
||||
}
|
||||
|
||||
type AuthOutput struct {
|
||||
Authorized bool ` + "`json:\"authorized\"`" + `
|
||||
}
|
||||
|
||||
type ScrobbleInput struct {
|
||||
UserID string ` + "`json:\"userId\"`" + `
|
||||
}
|
||||
|
||||
type ScrobblerOutput struct {
|
||||
Error *string ` + "`json:\"error,omitempty\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
cap := capabilities[0]
|
||||
Expect(cap.Name).To(Equal("scrobbler"))
|
||||
Expect(cap.Required).To(BeTrue())
|
||||
Expect(cap.Methods).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("should parse type aliases and consts", func() {
|
||||
src := `package capabilities
|
||||
|
||||
//nd:capability name=scrobbler required=true
|
||||
type Scrobbler interface {
|
||||
//nd:export name=nd_scrobble
|
||||
Scrobble(ScrobbleInput) (ScrobblerOutput, error)
|
||||
}
|
||||
|
||||
type ScrobbleInput struct {
|
||||
UserID string ` + "`json:\"userId\"`" + `
|
||||
}
|
||||
|
||||
// ScrobblerErrorType indicates error handling behavior.
|
||||
type ScrobblerErrorType string
|
||||
|
||||
const (
|
||||
// ScrobblerErrorNone indicates no error.
|
||||
ScrobblerErrorNone ScrobblerErrorType = "none"
|
||||
// ScrobblerErrorRetry indicates retry later.
|
||||
ScrobblerErrorRetry ScrobblerErrorType = "retry"
|
||||
)
|
||||
|
||||
type ScrobblerOutput struct {
|
||||
ErrorType *ScrobblerErrorType ` + "`json:\"errorType,omitempty\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
cap := capabilities[0]
|
||||
// Type alias should be collected
|
||||
Expect(cap.TypeAliases).To(HaveLen(1))
|
||||
Expect(cap.TypeAliases[0].Name).To(Equal("ScrobblerErrorType"))
|
||||
Expect(cap.TypeAliases[0].Type).To(Equal("string"))
|
||||
|
||||
// Consts should be collected
|
||||
Expect(cap.Consts).To(HaveLen(1))
|
||||
Expect(cap.Consts[0].Type).To(Equal("ScrobblerErrorType"))
|
||||
Expect(cap.Consts[0].Values).To(HaveLen(2))
|
||||
Expect(cap.Consts[0].Values[0].Name).To(Equal("ScrobblerErrorNone"))
|
||||
Expect(cap.Consts[0].Values[0].Value).To(Equal(`"none"`))
|
||||
})
|
||||
|
||||
It("should collect nested struct dependencies", func() {
|
||||
src := `package capabilities
|
||||
|
||||
//nd:capability name=metadata
|
||||
type MetadataAgent interface {
|
||||
//nd:export name=nd_get_images
|
||||
GetImages(ArtistInput) (ImagesOutput, error)
|
||||
}
|
||||
|
||||
type ArtistInput struct {
|
||||
ID string ` + "`json:\"id\"`" + `
|
||||
}
|
||||
|
||||
type ImagesOutput struct {
|
||||
Images []ImageInfo ` + "`json:\"images\"`" + `
|
||||
}
|
||||
|
||||
type ImageInfo struct {
|
||||
URL string ` + "`json:\"url\"`" + `
|
||||
Size int32 ` + "`json:\"size\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "metadata.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
cap := capabilities[0]
|
||||
// Should collect all 3 structs: ArtistInput, ImagesOutput, and ImageInfo
|
||||
Expect(cap.Structs).To(HaveLen(3))
|
||||
|
||||
structNames := make([]string, len(cap.Structs))
|
||||
for i, s := range cap.Structs {
|
||||
structNames[i] = s.Name
|
||||
}
|
||||
Expect(structNames).To(ContainElements("ArtistInput", "ImagesOutput", "ImageInfo"))
|
||||
})
|
||||
|
||||
It("should return empty slice for directory with no capabilities", func() {
|
||||
src := `package capabilities
|
||||
|
||||
type RegularInterface interface {
|
||||
Method() error
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should ignore methods without export annotation", func() {
|
||||
src := `package capabilities
|
||||
|
||||
//nd:capability name=test
|
||||
type TestCapability interface {
|
||||
//nd:export name=nd_exported
|
||||
ExportedMethod(Input) (Output, error)
|
||||
|
||||
// This method has no export annotation
|
||||
NotExportedMethod(Input) (Output, error)
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Value string ` + "`json:\"value\"`" + `
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
Result string ` + "`json:\"result\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
// Only the exported method should be captured
|
||||
Expect(capabilities[0].Methods).To(HaveLen(1))
|
||||
Expect(capabilities[0].Methods[0].Name).To(Equal("ExportedMethod"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Export helpers", func() {
|
||||
It("should generate correct provider interface name", func() {
|
||||
e := Export{Name: "GetArtistBiography"}
|
||||
Expect(e.ProviderInterfaceName()).To(Equal("ArtistBiographyProvider"))
|
||||
|
||||
e = Export{Name: "OnInit"}
|
||||
Expect(e.ProviderInterfaceName()).To(Equal("InitProvider"))
|
||||
})
|
||||
|
||||
It("should generate correct impl variable name", func() {
|
||||
e := Export{Name: "GetArtistBiography"}
|
||||
Expect(e.ImplVarName()).To(Equal("artistBiographyImpl"))
|
||||
|
||||
e = Export{Name: "OnInit"}
|
||||
Expect(e.ImplVarName()).To(Equal("initImpl"))
|
||||
})
|
||||
|
||||
It("should generate correct export function name", func() {
|
||||
e := Export{Name: "GetArtistBiography", ExportName: "nd_get_artist_biography"}
|
||||
Expect(e.ExportFuncName()).To(Equal("_NdGetArtistBiography"))
|
||||
})
|
||||
})
|
||||
})
|
||||
223
plugins/cmd/ndpgen/internal/templates/capability.go.tmpl
Normal file
223
plugins/cmd/ndpgen/internal/templates/capability.go.tmpl
Normal file
@@ -0,0 +1,223 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the {{.Capability.Interface}} capability.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package {{.Package}}
|
||||
|
||||
import (
|
||||
pdk "github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
{{- /* Generate type alias definitions */ -}}
|
||||
{{- range .Capability.TypeAliases}}
|
||||
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc}}
|
||||
{{- end}}
|
||||
type {{.Name}} {{.Type}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate const definitions */ -}}
|
||||
{{- range .Capability.Consts}}
|
||||
{{- if .Values}}
|
||||
|
||||
const (
|
||||
{{- $type := .Type}}
|
||||
{{- range $i, $v := .Values}}
|
||||
{{- if $v.Doc}}
|
||||
{{formatDoc $v.Doc | indent 1}}
|
||||
{{- end}}
|
||||
{{- if $type}}
|
||||
{{$v.Name}} {{$type}} = {{$v.Value}}
|
||||
{{- else}}
|
||||
{{$v.Name}} = {{$v.Value}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
)
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate Error() methods for string type aliases with const values (implements error interface) */ -}}
|
||||
{{- $consts := .Capability.Consts}}
|
||||
{{- range .Capability.TypeAliases}}
|
||||
{{- if eq .Type "string"}}
|
||||
{{- $typeName := .Name}}
|
||||
{{- range $consts}}
|
||||
{{- if eq .Type $typeName}}
|
||||
|
||||
// Error implements the error interface for {{$typeName}}.
|
||||
func (e {{$typeName}}) Error() string { return string(e) }
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc}}
|
||||
{{- else}}
|
||||
// {{.Name}} represents the {{.Name}} data structure.
|
||||
{{- end}}
|
||||
type {{.Name}} struct {
|
||||
{{- range .Fields}}
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc | indent 1}}
|
||||
{{- end}}
|
||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate main interface based on required flag */ -}}
|
||||
{{if .Capability.Required}}
|
||||
|
||||
// {{agentName .Capability}} requires all methods to be implemented.
|
||||
{{- if .Capability.Doc}}
|
||||
{{formatDoc .Capability.Doc}}
|
||||
{{- end}}
|
||||
type {{agentName .Capability}} interface {
|
||||
{{- range .Capability.Methods}}
|
||||
// {{.Name}}{{if .Doc}} - {{.Doc}}{{end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
{{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error)
|
||||
{{- else if .HasInput}}
|
||||
{{.Name}}({{.Input.Type}}) error
|
||||
{{- else if .HasOutput}}
|
||||
{{.Name}}() ({{.Output.Type}}, error)
|
||||
{{- else}}
|
||||
{{.Name}}() error
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else}}
|
||||
|
||||
// {{agentName .Capability}} is the marker interface for {{.Package}} plugins.
|
||||
// Implement one or more of the provider interfaces below.
|
||||
{{- if .Capability.Doc}}
|
||||
{{formatDoc .Capability.Doc}}
|
||||
{{- end}}
|
||||
type {{agentName .Capability}} interface{}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate optional provider interfaces for non-required capabilities */ -}}
|
||||
{{- if not .Capability.Required}}
|
||||
{{- range .Capability.Methods}}
|
||||
|
||||
// {{providerInterface .}} provides the {{.Name}} function.
|
||||
type {{providerInterface .}} interface {
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
{{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error)
|
||||
{{- else if .HasInput}}
|
||||
{{.Name}}({{.Input.Type}}) error
|
||||
{{- else if .HasOutput}}
|
||||
{{.Name}}() ({{.Output.Type}}, error)
|
||||
{{- else}}
|
||||
{{.Name}}() error
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate implementation function holders */ -}}
|
||||
|
||||
// Internal implementation holders
|
||||
var (
|
||||
{{- range .Capability.Methods}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
{{implVar .}} func({{.Input.Type}}) ({{.Output.Type}}, error)
|
||||
{{- else if .HasInput}}
|
||||
{{implVar .}} func({{.Input.Type}}) error
|
||||
{{- else if .HasOutput}}
|
||||
{{implVar .}} func() ({{.Output.Type}}, error)
|
||||
{{- else}}
|
||||
{{implVar .}} func() error
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
)
|
||||
|
||||
// Register registers a {{.Package}} implementation.
|
||||
{{- if .Capability.Required}}
|
||||
// All methods are required.
|
||||
func Register(impl {{agentName .Capability}}) {
|
||||
{{- range .Capability.Methods}}
|
||||
{{implVar .}} = impl.{{.Name}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else}}
|
||||
// The implementation is checked for optional provider interfaces.
|
||||
func Register(impl {{agentName .Capability}}) {
|
||||
{{- range .Capability.Methods}}
|
||||
if p, ok := impl.({{providerInterface .}}); ok {
|
||||
{{implVar .}} = p.{{.Name}}
|
||||
}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||
// The host recognizes this and skips the plugin gracefully.
|
||||
const NotImplementedCode int32 = -2
|
||||
|
||||
{{- /* Generate export wrappers */ -}}
|
||||
{{range .Capability.Methods}}
|
||||
|
||||
//go:wasmexport {{.ExportName}}
|
||||
func {{exportFunc .}}() int32 {
|
||||
if {{implVar .}} == nil {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
{{- if .HasInput}}
|
||||
|
||||
var input {{.Input.Type}}
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
{{- end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
|
||||
output, err := {{implVar .}}(input)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
{{- else if .HasInput}}
|
||||
|
||||
if err := {{implVar .}}(input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
{{- else if .HasOutput}}
|
||||
|
||||
output, err := {{implVar .}}()
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
{{- else}}
|
||||
|
||||
if err := {{implVar .}}(); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
return 0
|
||||
}
|
||||
{{- end}}
|
||||
184
plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl
Normal file
184
plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl
Normal file
@@ -0,0 +1,184 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the {{.Capability.Interface}} capability.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
{{if .Capability.Structs}}
|
||||
use serde::{Deserialize, Serialize};
|
||||
{{- if hasHashMap .Capability}}
|
||||
use std::collections::HashMap;
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate type alias definitions */ -}}
|
||||
{{- range .Capability.TypeAliases}}
|
||||
|
||||
{{- if .Doc}}
|
||||
{{rustDocComment .Doc}}
|
||||
{{- end}}
|
||||
pub type {{.Name}} = {{rustTypeAlias .Type}};
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate const definitions */ -}}
|
||||
{{- range .Capability.Consts}}
|
||||
{{- if .Values}}
|
||||
{{- $type := .Type}}
|
||||
{{- range $i, $v := .Values}}
|
||||
|
||||
{{- if $v.Doc}}
|
||||
{{rustDocComment $v.Doc}}
|
||||
{{- end}}
|
||||
{{- /* Use the type alias name if a named type is provided, otherwise use &'static str */ -}}
|
||||
{{- if $type}}
|
||||
pub const {{rustConstName $v.Name}}: {{$type}} = {{$v.Value}};
|
||||
{{- else}}
|
||||
pub const {{rustConstName $v.Name}}: &'static str = {{$v.Value}};
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
{{rustDocComment .Doc}}
|
||||
{{- else}}
|
||||
/// {{.Name}} represents the {{.Name}} data structure.
|
||||
{{- end}}
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct {{.Name}} {
|
||||
{{- range .Fields}}
|
||||
{{- if .Doc}}
|
||||
{{rustDocComment .Doc | indent 4}}
|
||||
{{- end}}
|
||||
{{- if .OmitEmpty}}
|
||||
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
||||
{{- else}}
|
||||
#[serde(default)]
|
||||
{{- end}}
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
/// Error represents an error from a capability method.
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl Error {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
{{- /* Generate main interface based on required flag */ -}}
|
||||
{{if .Capability.Required}}
|
||||
|
||||
/// {{agentName .Capability}} requires all methods to be implemented.
|
||||
{{- if .Capability.Doc}}
|
||||
{{rustDocComment .Capability.Doc}}
|
||||
{{- end}}
|
||||
pub trait {{agentName .Capability}} {
|
||||
{{- range .Capability.Methods}}
|
||||
/// {{.Name}}{{if .Doc}} - {{.Doc}}{{end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<{{rustOutputType .Output.Type}}, Error>;
|
||||
{{- else if .HasInput}}
|
||||
fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<(), Error>;
|
||||
{{- else if .HasOutput}}
|
||||
fn {{rustMethodName .Name}}(&self) -> Result<{{rustOutputType .Output.Type}}, Error>;
|
||||
{{- else}}
|
||||
fn {{rustMethodName .Name}}(&self) -> Result<(), Error>;
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
/// Register all exports for the {{agentName .Capability}} capability.
|
||||
/// This macro generates the WASM export functions for all trait methods.
|
||||
#[macro_export]
|
||||
macro_rules! register_{{snakeCase .Package}} {
|
||||
($plugin_type:ty) => {
|
||||
{{- range .Capability.Methods}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}>
|
||||
{{- end}}
|
||||
) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{if isPrimitiveRust .Output.Type}}{{rustOutputType .Output.Type}}{{else}}$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}{{end}}>{{else}}(){{end}}> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
{{- else if .HasInput}}
|
||||
$crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?;
|
||||
Ok(())
|
||||
{{- else if .HasOutput}}
|
||||
let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin)?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
{{- else}}
|
||||
$crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin)?;
|
||||
Ok(())
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
};
|
||||
}
|
||||
{{- else}}
|
||||
|
||||
{{- /* Generate optional provider interfaces for non-required capabilities */ -}}
|
||||
{{- range .Capability.Methods}}
|
||||
|
||||
/// {{providerInterface .}} provides the {{.Name}} function.
|
||||
pub trait {{providerInterface .}} {
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<{{rustOutputType .Output.Type}}, Error>;
|
||||
{{- else if .HasInput}}
|
||||
fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<(), Error>;
|
||||
{{- else if .HasOutput}}
|
||||
fn {{rustMethodName .Name}}(&self) -> Result<{{rustOutputType .Output.Type}}, Error>;
|
||||
{{- else}}
|
||||
fn {{rustMethodName .Name}}(&self) -> Result<(), Error>;
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
/// Register the {{rustMethodName .Name}} export.
|
||||
/// This macro generates the WASM export function for this method.
|
||||
#[macro_export]
|
||||
macro_rules! {{registerMacroName .Name}} {
|
||||
($plugin_type:ty) => {
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}>
|
||||
{{- end}}
|
||||
) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{if isPrimitiveRust .Output.Type}}{{rustOutputType .Output.Type}}{{else}}$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}{{end}}>{{else}}(){{end}}> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
let result = $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
{{- else if .HasInput}}
|
||||
$crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?;
|
||||
Ok(())
|
||||
{{- else if .HasOutput}}
|
||||
let result = $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin)?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
{{- else}}
|
||||
$crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin)?;
|
||||
Ok(())
|
||||
{{- end}}
|
||||
}
|
||||
};
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
132
plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl
Normal file
132
plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl
Normal file
@@ -0,0 +1,132 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file provides stub implementations for non-WASM platforms.
|
||||
// It allows Go plugins to compile and run tests outside of WASM,
|
||||
// but the actual functionality is only available in WASM builds.
|
||||
//
|
||||
//go:build !wasip1
|
||||
|
||||
package {{.Package}}
|
||||
|
||||
{{- /* Generate type alias definitions */ -}}
|
||||
{{- range .Capability.TypeAliases}}
|
||||
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc}}
|
||||
{{- end}}
|
||||
type {{.Name}} {{.Type}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate const definitions */ -}}
|
||||
{{- range .Capability.Consts}}
|
||||
{{- if .Values}}
|
||||
|
||||
const (
|
||||
{{- $type := .Type}}
|
||||
{{- range $i, $v := .Values}}
|
||||
{{- if $v.Doc}}
|
||||
{{formatDoc $v.Doc | indent 1}}
|
||||
{{- end}}
|
||||
{{- if $type}}
|
||||
{{$v.Name}} {{$type}} = {{$v.Value}}
|
||||
{{- else}}
|
||||
{{$v.Name}} = {{$v.Value}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
)
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate Error() methods for string type aliases with const values (implements error interface) */ -}}
|
||||
{{- $consts := .Capability.Consts}}
|
||||
{{- range .Capability.TypeAliases}}
|
||||
{{- if eq .Type "string"}}
|
||||
{{- $typeName := .Name}}
|
||||
{{- range $consts}}
|
||||
{{- if eq .Type $typeName}}
|
||||
|
||||
// Error implements the error interface for {{$typeName}}.
|
||||
func (e {{$typeName}}) Error() string { return string(e) }
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc}}
|
||||
{{- else}}
|
||||
// {{.Name}} represents the {{.Name}} data structure.
|
||||
{{- end}}
|
||||
type {{.Name}} struct {
|
||||
{{- range .Fields}}
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc | indent 1}}
|
||||
{{- end}}
|
||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate main interface based on required flag */ -}}
|
||||
{{if .Capability.Required}}
|
||||
|
||||
// {{agentName .Capability}} requires all methods to be implemented.
|
||||
{{- if .Capability.Doc}}
|
||||
{{formatDoc .Capability.Doc}}
|
||||
{{- end}}
|
||||
type {{agentName .Capability}} interface {
|
||||
{{- range .Capability.Methods}}
|
||||
// {{.Name}}{{if .Doc}} - {{.Doc}}{{end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
{{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error)
|
||||
{{- else if .HasInput}}
|
||||
{{.Name}}({{.Input.Type}}) error
|
||||
{{- else if .HasOutput}}
|
||||
{{.Name}}() ({{.Output.Type}}, error)
|
||||
{{- else}}
|
||||
{{.Name}}() error
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else}}
|
||||
|
||||
// {{agentName .Capability}} is the marker interface for {{.Package}} plugins.
|
||||
// Implement one or more of the provider interfaces below.
|
||||
{{- if .Capability.Doc}}
|
||||
{{formatDoc .Capability.Doc}}
|
||||
{{- end}}
|
||||
type {{agentName .Capability}} interface{}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate optional provider interfaces for non-required capabilities */ -}}
|
||||
{{- if not .Capability.Required}}
|
||||
{{- range .Capability.Methods}}
|
||||
|
||||
// {{providerInterface .}} provides the {{.Name}} function.
|
||||
type {{providerInterface .}} interface {
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
{{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error)
|
||||
{{- else if .HasInput}}
|
||||
{{.Name}}({{.Input.Type}}) error
|
||||
{{- else if .HasOutput}}
|
||||
{{.Name}}() ({{.Output.Type}}, error)
|
||||
{{- else}}
|
||||
{{.Name}}() error
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||
const NotImplementedCode int32 = -2
|
||||
|
||||
// Register is a no-op on non-WASM platforms.
|
||||
// This stub allows code to compile outside of WASM.
|
||||
{{- if .Capability.Required}}
|
||||
func Register(_ {{agentName .Capability}}) {}
|
||||
{{- else}}
|
||||
func Register(_ {{agentName .Capability}}) {}
|
||||
{{- end}}
|
||||
125
plugins/cmd/ndpgen/internal/templates/client.go.tmpl
Normal file
125
plugins/cmd/ndpgen/internal/templates/client.go.tmpl
Normal file
@@ -0,0 +1,125 @@
|
||||
// Code generated by ndpgen. 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.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package {{.Package}}
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- range .Service.Structs}}
|
||||
|
||||
// {{.Name}} represents the {{.Name}} data structure.
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc}}
|
||||
{{- end}}
|
||||
type {{.Name}} struct {
|
||||
{{- range .Fields}}
|
||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}"`
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
{{- /* 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 .}}(uint64) uint64
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate request/response types for all methods (private) */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if .HasParams}}
|
||||
|
||||
type {{requestType .}} struct {
|
||||
{{- range .Params}}
|
||||
{{title .Name}} {{.Type}} `json:"{{.JSONName}}"`
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if not .IsErrorOnly}}
|
||||
|
||||
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}}) {{.ReturnSignature}} {
|
||||
{{- if .HasParams}}
|
||||
// Marshal request to JSON
|
||||
req := {{requestType .}}{
|
||||
{{- range .Params}}
|
||||
{{title .Name}}: {{.Name}},
|
||||
{{- end}}
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return {{if .HasReturns}}{{.ZeroValues}}{{end}}{{if and .HasReturns .HasError}}, {{end}}{{if .HasError}}err{{end}}
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
{{- else}}
|
||||
// No parameters - allocate empty JSON object
|
||||
reqMem := pdk.AllocateBytes([]byte("{}"))
|
||||
defer reqMem.Free()
|
||||
{{- end}}
|
||||
|
||||
// Call the host function
|
||||
responsePtr := {{exportName .}}(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
{{- if .IsErrorOnly}}
|
||||
|
||||
// Parse error-only response
|
||||
var response struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
if response.Error != "" {
|
||||
return errors.New(response.Error)
|
||||
}
|
||||
return nil
|
||||
{{- else}}
|
||||
|
||||
// Parse the response
|
||||
var response {{responseType .}}
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return {{if .HasReturns}}{{.ZeroValues}}{{end}}{{if and .HasReturns .HasError}}, {{end}}{{if .HasError}}err{{end}}
|
||||
}
|
||||
{{- if .HasError}}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return {{if .HasReturns}}{{.ZeroValues}}, {{end}}errors.New(response.Error)
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
return {{range $i, $r := .Returns}}{{if $i}}, {{end}}response.{{title $r.Name}}{{end}}{{if and .HasReturns .HasError}}, {{end}}{{if .HasError}}nil{{end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
95
plugins/cmd/ndpgen/internal/templates/client.py.tmpl
Normal file
95
plugins/cmd/ndpgen/internal/templates/client.py.tmpl
Normal file
@@ -0,0 +1,95 @@
|
||||
# Code generated by ndpgen. DO NOT EDIT.
|
||||
#
|
||||
# This file contains client wrappers for the {{.Service.Name}} host service.
|
||||
# It is intended for use in Navidrome plugins built with extism-py.
|
||||
#
|
||||
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
"""Raised when a host function returns an error."""
|
||||
pass
|
||||
|
||||
{{- /* Generate raw host function imports */ -}}
|
||||
{{range .Service.Methods}}
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "{{exportName .}}")
|
||||
def _{{exportName .}}(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
{{- end}}
|
||||
{{- /* Generate dataclasses for multi-value returns */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if .NeedsResultClass}}
|
||||
|
||||
|
||||
@dataclass
|
||||
class {{pythonResultType .}}:
|
||||
"""Result type for {{pythonFunc .}}."""
|
||||
{{- range .Returns}}
|
||||
{{.PythonName}}: {{.PythonType}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- /* Generate wrapper functions */ -}}
|
||||
{{range .Service.Methods}}
|
||||
|
||||
|
||||
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
|
||||
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
|
||||
Args:
|
||||
{{- range .Params}}
|
||||
{{.PythonName}}: {{.PythonType}} parameter.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if .HasReturns}}
|
||||
|
||||
Returns:
|
||||
{{- if .NeedsResultClass}}
|
||||
{{pythonResultType .}} containing{{range .Returns}} {{.PythonName}},{{end}}.
|
||||
{{- else}}
|
||||
{{(index .Returns 0).PythonType}}: The result value.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
{{- if .HasParams}}
|
||||
request = {
|
||||
{{- range .Params}}
|
||||
"{{.JSONName}}": {{.PythonName}},
|
||||
{{- end}}
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
{{- else}}
|
||||
request_bytes = b"{}"
|
||||
{{- end}}
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _{{exportName .}}(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
{{if .NeedsResultClass}}
|
||||
return {{pythonResultType .}}(
|
||||
{{- range .Returns}}
|
||||
{{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
|
||||
{{- end}}
|
||||
)
|
||||
{{- else if .HasReturns}}
|
||||
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
103
plugins/cmd/ndpgen/internal/templates/client.rs.tmpl
Normal file
103
plugins/cmd/ndpgen/internal/templates/client.rs.tmpl
Normal file
@@ -0,0 +1,103 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the {{.Service.Name}} host service.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- range .Service.Structs}}
|
||||
{{if .Doc}}
|
||||
{{rustDocComment .Doc}}
|
||||
{{else}}
|
||||
{{end}}#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct {{.Name}} {
|
||||
{{- range .Fields}}
|
||||
{{- if .NeedsDefault}}
|
||||
#[serde(default)]
|
||||
{{- end}}
|
||||
pub {{.RustName}}: {{fieldRustType .}},
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- /* Generate request/response types */ -}}
|
||||
{{- range .Service.Methods}}
|
||||
{{- if .HasParams}}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct {{requestType .}} {
|
||||
{{- range .Params}}
|
||||
{{.RustName}}: {{rustType .}},
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct {{responseType .}} {
|
||||
{{- range .Returns}}
|
||||
#[serde(default)]
|
||||
{{.RustName}}: {{rustType .}},
|
||||
{{- end}}
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
{{- range .Service.Methods}}
|
||||
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
{{- /* Generate wrapper functions */ -}}
|
||||
{{range .Service.Methods}}
|
||||
|
||||
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
///
|
||||
/// # Arguments
|
||||
{{- range .Params}}
|
||||
/// * `{{.RustName}}` - {{rustType .}} parameter.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if .HasReturns}}
|
||||
///
|
||||
/// # Returns
|
||||
{{- if eq (len .Returns) 1}}
|
||||
/// The {{(index .Returns 0).RustName}} value.
|
||||
{{- else}}
|
||||
/// A tuple of ({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{$r.RustName}}{{end}}).
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<{{if eq (len .Returns) 0}}(){{else if eq (len .Returns) 1}}{{rustType (index .Returns 0)}}{{else}}({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{rustType $r}}{{end}}){{end}}, Error> {
|
||||
let response = unsafe {
|
||||
{{- if .HasParams}}
|
||||
{{exportName .}}(Json({{requestType .}} {
|
||||
{{- range .Params}}
|
||||
{{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}},
|
||||
{{- end}}
|
||||
}))?
|
||||
{{- else}}
|
||||
{{exportName .}}(Json(serde_json::json!({})))?
|
||||
{{- end}}
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
{{if eq (len .Returns) 0}}
|
||||
Ok(())
|
||||
{{- else if eq (len .Returns) 1}}
|
||||
Ok(response.0.{{(index .Returns 0).RustName}})
|
||||
{{- else}}
|
||||
Ok(({{range $i, $r := .Returns}}{{if $i}}, {{end}}response.0.{{$r.RustName}}{{end}}))
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
35
plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl
Normal file
35
plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl
Normal file
@@ -0,0 +1,35 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains stub implementations for non-WASM builds.
|
||||
// These stubs allow IDE support and compilation on non-WASM platforms.
|
||||
// They panic at runtime since host functions are only available in WASM plugins.
|
||||
//
|
||||
//go:build !wasip1
|
||||
|
||||
package {{.Package}}
|
||||
|
||||
{{- /* Generate struct definitions (same as main file, needed for type references in function signatures) */ -}}
|
||||
{{- range .Service.Structs}}
|
||||
|
||||
// {{.Name}} represents the {{.Name}} data structure.
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc}}
|
||||
{{- end}}
|
||||
type {{.Name}} struct {
|
||||
{{- range .Fields}}
|
||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}"`
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate stub wrapper functions that panic */ -}}
|
||||
{{range .Service.Methods}}
|
||||
|
||||
// {{$.Service.Name}}{{.Name}} is a stub that panics on non-WASM platforms.
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc}}
|
||||
{{- end}}
|
||||
func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) {{.ReturnSignature}} {
|
||||
panic("{{$.Package}}: {{$.Service.Name}}{{.Name}} is only available in WASM plugins")
|
||||
}
|
||||
{{- end}}
|
||||
49
plugins/cmd/ndpgen/internal/templates/doc.go.tmpl
Normal file
49
plugins/cmd/ndpgen/internal/templates/doc.go.tmpl
Normal file
@@ -0,0 +1,49 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
|
||||
/*
|
||||
Package {{.Package}} provides Navidrome Plugin Development Kit wrappers for Go/TinyGo plugins.
|
||||
|
||||
This package is auto-generated by the ndpgen tool and should not be edited manually.
|
||||
|
||||
# Usage
|
||||
|
||||
Add this module as a dependency in your plugin's go.mod:
|
||||
|
||||
require github.com/navidrome/navidrome/plugins/pdk/go/host v0.0.0
|
||||
|
||||
Then import the package in your plugin code:
|
||||
|
||||
import {{.Package}} "github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
|
||||
func myPluginFunction() error {
|
||||
// Use the cache service
|
||||
_, err := {{.Package}}.CacheSetString("my_key", "my_value", 3600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Schedule a recurring task
|
||||
_, err = {{.Package}}.SchedulerScheduleRecurring("@every 5m", "payload", "task_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
# Available Services
|
||||
|
||||
The following host services are available:
|
||||
{{range .Services}}
|
||||
- {{.Name}}: {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} service{{end}}
|
||||
{{- end}}
|
||||
|
||||
# Building Plugins
|
||||
|
||||
Go plugins must be compiled to WebAssembly using TinyGo:
|
||||
|
||||
tinygo build -o plugin.wasm -target=wasip1 -buildmode=c-shared .
|
||||
|
||||
See the examples directory for complete plugin implementations.
|
||||
*/
|
||||
package {{.Package}}
|
||||
5
plugins/cmd/ndpgen/internal/templates/go.mod.tmpl
Normal file
5
plugins/cmd/ndpgen/internal/templates/go.mod.tmpl
Normal file
@@ -0,0 +1,5 @@
|
||||
module github.com/navidrome/navidrome/plugins/pdk/go
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
119
plugins/cmd/ndpgen/internal/templates/host.go.tmpl
Normal file
119
plugins/cmd/ndpgen/internal/templates/host.go.tmpl
Normal file
@@ -0,0 +1,119 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
|
||||
package {{.Package}}
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
{{- /* Generate request/response types for all methods */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if .HasParams}}
|
||||
|
||||
// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
|
||||
type {{requestType .}} struct {
|
||||
{{- range .Params}}
|
||||
{{title .Name}} {{.Type}} `json:"{{.JSONName}}"`
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
// {{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}}
|
||||
|
||||
// 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}}
|
||||
// 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
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
// Call the service method
|
||||
{{- if .HasReturns}}
|
||||
{{- if .HasError}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
if svcErr != nil {
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
{{- else}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
{{- end}}
|
||||
{{- else if .HasError}}
|
||||
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
{{- else}}
|
||||
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
{{- end}}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := {{responseType .}}{
|
||||
{{- range .Returns}}
|
||||
{{title .Name}}: {{lower .Name}},
|
||||
{{- end}}
|
||||
}
|
||||
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
{{end}}
|
||||
|
||||
// {{.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
|
||||
}
|
||||
|
||||
// {{.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
|
||||
}
|
||||
47
plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl
Normal file
47
plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl
Normal file
@@ -0,0 +1,47 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
//! Navidrome Host Function Wrappers for Rust Plugins
|
||||
//!
|
||||
//! This crate provides idiomatic Rust wrappers for all Navidrome host services.
|
||||
//! It is auto-generated by the ndpgen tool and should not be edited manually.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! Add this crate as a dependency in your plugin's Cargo.toml:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [dependencies]
|
||||
//! nd-host = { path = "../../host/rust" }
|
||||
//! ```
|
||||
//!
|
||||
//! Then import the services you need:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use nd_host::{cache, scheduler};
|
||||
//!
|
||||
//! fn my_plugin_function() -> Result<(), extism_pdk::Error> {
|
||||
//! // Use the cache service
|
||||
//! cache::set_string("my_key", "my_value", 3600)?;
|
||||
//!
|
||||
//! // Schedule a recurring task
|
||||
//! scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Available Services
|
||||
//!
|
||||
{{- range .Services}}
|
||||
//! - [`{{.Name | lower}}`] - {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} service{{end}}
|
||||
{{- end}}
|
||||
{{range .Services}}
|
||||
#[doc(hidden)]
|
||||
mod nd_host_{{.Name | lower}};
|
||||
/// {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} host service wrappers.{{end}}
|
||||
pub mod {{.Name | lower}} {
|
||||
pub use super::nd_host_{{.Name | lower}}::*;
|
||||
}
|
||||
{{end}}
|
||||
// Re-export commonly used types from extism-pdk for convenience
|
||||
pub use extism_pdk::Error;
|
||||
592
plugins/cmd/ndpgen/internal/types.go
Normal file
592
plugins/cmd/ndpgen/internal/types.go
Normal file
@@ -0,0 +1,592 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// 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
|
||||
Structs []StructDef // Structs used by this service
|
||||
}
|
||||
|
||||
// Capability represents a parsed capability interface for plugin exports.
|
||||
type Capability struct {
|
||||
Name string // Package name from annotation (e.g., "metadata")
|
||||
Interface string // Go interface name (e.g., "MetadataAgent")
|
||||
Required bool // If true, all methods must be implemented
|
||||
Methods []Export // Methods marked with //nd:export
|
||||
Doc string // Documentation comment for the capability
|
||||
Structs []StructDef // Structs used by this capability
|
||||
TypeAliases []TypeAlias // Type aliases used by this capability
|
||||
Consts []ConstGroup // Const groups used by this capability
|
||||
SourceFile string // Base name of source file without extension (e.g., "websocket_callback")
|
||||
}
|
||||
|
||||
// TypeAlias represents a type alias definition (e.g., type ScrobblerErrorType string).
|
||||
type TypeAlias struct {
|
||||
Name string // Type name
|
||||
Type string // Underlying type
|
||||
Doc string // Documentation comment
|
||||
}
|
||||
|
||||
// ConstGroup represents a group of const definitions.
|
||||
type ConstGroup struct {
|
||||
Type string // Type name for typed consts (empty for untyped)
|
||||
Values []ConstDef // Const definitions
|
||||
}
|
||||
|
||||
// ConstDef represents a single const definition.
|
||||
type ConstDef struct {
|
||||
Name string // Const name
|
||||
Value string // Const value
|
||||
Doc string // Documentation comment
|
||||
}
|
||||
|
||||
// KnownStructs returns a map of struct names defined in this capability.
|
||||
func (c Capability) KnownStructs() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
for _, st := range c.Structs {
|
||||
result[st.Name] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Export represents an exported WASM function within a capability.
|
||||
type Export struct {
|
||||
Name string // Go method name (e.g., "GetArtistBiography")
|
||||
ExportName string // WASM export name (e.g., "nd_get_artist_biography")
|
||||
Input Param // Single input parameter (the struct type)
|
||||
Output Param // Single output return value (the struct type)
|
||||
Doc string // Documentation comment for the method
|
||||
}
|
||||
|
||||
// ProviderInterfaceName returns the optional provider interface name.
|
||||
// For a method "GetArtistBiography", returns "ArtistBiographyProvider".
|
||||
func (e Export) ProviderInterfaceName() string {
|
||||
// Remove "Get", "On", etc. prefixes and add "Provider" suffix
|
||||
name := e.Name
|
||||
for _, prefix := range []string{"Get", "On"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
name = name[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
return name + "Provider"
|
||||
}
|
||||
|
||||
// ImplVarName returns the internal implementation variable name.
|
||||
// For "GetArtistBiography", returns "artistBiographyImpl".
|
||||
func (e Export) ImplVarName() string {
|
||||
name := e.Name
|
||||
for _, prefix := range []string{"Get", "On"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
name = name[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
// Convert to camelCase
|
||||
if len(name) > 0 {
|
||||
name = strings.ToLower(string(name[0])) + name[1:]
|
||||
}
|
||||
return name + "Impl"
|
||||
}
|
||||
|
||||
// ExportFuncName returns the unexported WASM export function name.
|
||||
// For "nd_get_artist_biography", returns "_ndGetArtistBiography".
|
||||
func (e Export) ExportFuncName() string {
|
||||
// Convert snake_case to PascalCase
|
||||
parts := strings.Split(e.ExportName, "_")
|
||||
var result strings.Builder
|
||||
result.WriteString("_")
|
||||
for _, part := range parts {
|
||||
if len(part) > 0 {
|
||||
result.WriteString(strings.ToUpper(string(part[0])))
|
||||
result.WriteString(part[1:])
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// HasInput returns true if the method has an input parameter.
|
||||
func (e Export) HasInput() bool {
|
||||
return e.Input.Type != ""
|
||||
}
|
||||
|
||||
// HasOutput returns true if the method has a non-error return value.
|
||||
func (e Export) HasOutput() bool {
|
||||
return e.Output.Type != ""
|
||||
}
|
||||
|
||||
// IsPointerOutput returns true if the output type is a pointer.
|
||||
func (e Export) IsPointerOutput() bool {
|
||||
return strings.HasPrefix(e.Output.Type, "*")
|
||||
}
|
||||
|
||||
// StructDef represents a Go struct type definition.
|
||||
type StructDef struct {
|
||||
Name string // Go struct name (e.g., "Library")
|
||||
Fields []FieldDef // Struct fields
|
||||
Doc string // Documentation comment
|
||||
}
|
||||
|
||||
// FieldDef represents a field within a struct.
|
||||
type FieldDef struct {
|
||||
Name string // Go field name (e.g., "TotalSongs")
|
||||
Type string // Go type (e.g., "int32", "*string", "[]User")
|
||||
JSONTag string // JSON tag value (e.g., "totalSongs,omitempty")
|
||||
OmitEmpty bool // Whether the field has omitempty tag
|
||||
Doc string // Field documentation
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// KnownStructs returns a map of struct names defined in this service.
|
||||
func (s Service) KnownStructs() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
for _, st := range s.Structs {
|
||||
result[st.Name] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 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 (public, for host-side code).
|
||||
func (m Method) RequestTypeName(serviceName string) string {
|
||||
return serviceName + m.Name + "Request"
|
||||
}
|
||||
|
||||
// ResponseTypeName returns the generated response type name (public, for host-side code).
|
||||
func (m Method) ResponseTypeName(serviceName string) string {
|
||||
return serviceName + m.Name + "Response"
|
||||
}
|
||||
|
||||
// ClientRequestTypeName returns the generated request type name (private, for client/PDK code).
|
||||
func (m Method) ClientRequestTypeName(serviceName string) string {
|
||||
return lowerFirst(serviceName) + m.Name + "Request"
|
||||
}
|
||||
|
||||
// ClientResponseTypeName returns the generated response type name (private, for client/PDK code).
|
||||
func (m Method) ClientResponseTypeName(serviceName string) string {
|
||||
return lowerFirst(serviceName) + m.Name + "Response"
|
||||
}
|
||||
|
||||
// lowerFirst returns the string with the first letter lowercased.
|
||||
func lowerFirst(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
r[0] = unicode.ToLower(r[0])
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// IsErrorOnly returns true if the method only returns an error (no data fields).
|
||||
func (m Method) IsErrorOnly() bool {
|
||||
return m.HasError && !m.HasReturns()
|
||||
}
|
||||
|
||||
// IsSingleReturn returns true if the method has exactly one return value (excluding error).
|
||||
func (m Method) IsSingleReturn() bool {
|
||||
return len(m.Returns) == 1
|
||||
}
|
||||
|
||||
// IsMultiReturn returns true if the method has multiple return values (excluding error).
|
||||
func (m Method) IsMultiReturn() bool {
|
||||
return len(m.Returns) > 1
|
||||
}
|
||||
|
||||
// ReturnSignature returns the Go return type signature for the wrapper function.
|
||||
// For error-only: "error"
|
||||
// For single return with error: "(Type, error)"
|
||||
// For single return no error: "Type"
|
||||
// For multi return: "(Type1, Type2, ..., error)"
|
||||
func (m Method) ReturnSignature() string {
|
||||
if m.IsErrorOnly() {
|
||||
return "error"
|
||||
}
|
||||
var parts []string
|
||||
for _, r := range m.Returns {
|
||||
parts = append(parts, r.Type)
|
||||
}
|
||||
if m.HasError {
|
||||
parts = append(parts, "error")
|
||||
}
|
||||
// Single return without error doesn't need parentheses
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return "(" + strings.Join(parts, ", ") + ")"
|
||||
}
|
||||
|
||||
// ZeroValues returns the zero value expressions for all return types (excluding error).
|
||||
// Used for error return statements like "return "", false, err".
|
||||
func (m Method) ZeroValues() string {
|
||||
var zeros []string
|
||||
for _, r := range m.Returns {
|
||||
zeros = append(zeros, zeroValue(r.Type))
|
||||
}
|
||||
return strings.Join(zeros, ", ")
|
||||
}
|
||||
|
||||
// zeroValue returns the zero value for a Go type.
|
||||
func zeroValue(typ string) string {
|
||||
switch {
|
||||
case typ == "string":
|
||||
return `""`
|
||||
case typ == "bool":
|
||||
return "false"
|
||||
case typ == "int", typ == "int8", typ == "int16", typ == "int32", typ == "int64",
|
||||
typ == "uint", typ == "uint8", typ == "uint16", typ == "uint32", typ == "uint64",
|
||||
typ == "float32", typ == "float64":
|
||||
return "0"
|
||||
case typ == "[]byte":
|
||||
return "nil"
|
||||
case strings.HasPrefix(typ, "[]"):
|
||||
return "nil"
|
||||
case strings.HasPrefix(typ, "map["):
|
||||
return "nil"
|
||||
case strings.HasPrefix(typ, "*"):
|
||||
return "nil"
|
||||
case typ == "any", typ == "interface{}":
|
||||
return "nil"
|
||||
default:
|
||||
// For custom struct types, return empty struct
|
||||
return typ + "{}"
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
// toJSONName converts a Go identifier to camelCase JSON field name.
|
||||
// This matches Rust serde's rename_all = "camelCase" behavior.
|
||||
// Examples: "ConnectionID" -> "connectionId", "NewConnectionID" -> "newConnectionId"
|
||||
func toJSONName(name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(name)
|
||||
result := make([]rune, 0, len(runes))
|
||||
|
||||
for i, r := range runes {
|
||||
if i == 0 {
|
||||
// First character is always lowercase
|
||||
result = append(result, unicode.ToLower(r))
|
||||
} else if unicode.IsUpper(r) {
|
||||
// Check if this is part of an acronym (consecutive uppercase)
|
||||
// or a word boundary
|
||||
prevIsUpper := unicode.IsUpper(runes[i-1])
|
||||
nextIsLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
|
||||
|
||||
if prevIsUpper && !nextIsLower {
|
||||
// Middle of an acronym - lowercase it
|
||||
result = append(result, unicode.ToLower(r))
|
||||
} else if prevIsUpper && nextIsLower {
|
||||
// End of acronym followed by lowercase - this starts a new word
|
||||
// Keep uppercase
|
||||
result = append(result, r)
|
||||
} else {
|
||||
// Regular word boundary - keep uppercase
|
||||
result = append(result, r)
|
||||
}
|
||||
} else {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// ToPythonType converts a Go type to its Python equivalent.
|
||||
func ToPythonType(goType string) string {
|
||||
switch goType {
|
||||
case "string":
|
||||
return "str"
|
||||
case "int", "int32", "int64":
|
||||
return "int"
|
||||
case "float32", "float64":
|
||||
return "float"
|
||||
case "bool":
|
||||
return "bool"
|
||||
case "[]byte":
|
||||
return "bytes"
|
||||
default:
|
||||
return "Any"
|
||||
}
|
||||
}
|
||||
|
||||
// ToSnakeCase converts a PascalCase or camelCase string to snake_case.
|
||||
// It handles consecutive uppercase letters correctly (e.g., "ScheduleID" -> "schedule_id").
|
||||
func ToSnakeCase(s string) string {
|
||||
var result strings.Builder
|
||||
runes := []rune(s)
|
||||
for i, r := range runes {
|
||||
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||
// Add underscore before uppercase, but not if:
|
||||
// - Previous char was uppercase AND next char is uppercase or end of string
|
||||
// (this handles acronyms like "ID" in "NewScheduleID")
|
||||
prevUpper := runes[i-1] >= 'A' && runes[i-1] <= 'Z'
|
||||
nextUpper := i+1 < len(runes) && runes[i+1] >= 'A' && runes[i+1] <= 'Z'
|
||||
atEnd := i+1 == len(runes)
|
||||
|
||||
// Only skip underscore if we're in the middle of an acronym
|
||||
if !prevUpper || (!nextUpper && !atEnd) {
|
||||
result.WriteByte('_')
|
||||
}
|
||||
}
|
||||
result.WriteRune(r)
|
||||
}
|
||||
return strings.ToLower(result.String())
|
||||
}
|
||||
|
||||
// PythonFunctionName returns the Python function name for a method.
|
||||
func (m Method) PythonFunctionName(servicePrefix string) string {
|
||||
return ToSnakeCase(servicePrefix + m.Name)
|
||||
}
|
||||
|
||||
// PythonResultTypeName returns the Python dataclass name for multi-value returns.
|
||||
func (m Method) PythonResultTypeName(serviceName string) string {
|
||||
return serviceName + m.Name + "Result"
|
||||
}
|
||||
|
||||
// NeedsResultClass returns true if the method needs a dataclass for returns.
|
||||
func (m Method) NeedsResultClass() bool {
|
||||
return len(m.Returns) > 1
|
||||
}
|
||||
|
||||
// PythonType returns the Python type for this parameter.
|
||||
func (p Param) PythonType() string {
|
||||
return ToPythonType(p.Type)
|
||||
}
|
||||
|
||||
// PythonName returns the snake_case Python name for this parameter.
|
||||
func (p Param) PythonName() string {
|
||||
return ToSnakeCase(p.Name)
|
||||
}
|
||||
|
||||
// ToRustType converts a Go type to its Rust equivalent.
|
||||
func ToRustType(goType string) string {
|
||||
return ToRustTypeWithStructs(goType, nil)
|
||||
}
|
||||
|
||||
// RustParamType returns the Rust type for a function parameter (uses &str for strings).
|
||||
func RustParamType(goType string) string {
|
||||
if goType == "string" {
|
||||
return "&str"
|
||||
}
|
||||
return ToRustType(goType)
|
||||
}
|
||||
|
||||
// RustDefaultValue returns the default value for a Rust type.
|
||||
func RustDefaultValue(goType string) string {
|
||||
switch goType {
|
||||
case "string":
|
||||
return `String::new()`
|
||||
case "int", "int32":
|
||||
return "0"
|
||||
case "int64":
|
||||
return "0"
|
||||
case "float32", "float64":
|
||||
return "0.0"
|
||||
case "bool":
|
||||
return "false"
|
||||
default:
|
||||
if strings.HasPrefix(goType, "[]") {
|
||||
return "Vec::new()"
|
||||
}
|
||||
if strings.HasPrefix(goType, "map[") {
|
||||
return "std::collections::HashMap::new()"
|
||||
}
|
||||
if strings.HasPrefix(goType, "*") {
|
||||
return "None"
|
||||
}
|
||||
return "serde_json::Value::Null"
|
||||
}
|
||||
}
|
||||
|
||||
// RustFunctionName returns the Rust function name for a method (snake_case).
|
||||
// Uses just the method name without service prefix since the module provides namespacing.
|
||||
func (m Method) RustFunctionName(_ string) string {
|
||||
return ToSnakeCase(m.Name)
|
||||
}
|
||||
|
||||
// RustDocComment returns a properly formatted Rust doc comment.
|
||||
// Each line of the input doc string is prefixed with "/// ".
|
||||
func RustDocComment(doc string) string {
|
||||
if doc == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(doc, "\n")
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
result = append(result, "/// "+line)
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// RustType returns the Rust type for this parameter.
|
||||
func (p Param) RustType() string {
|
||||
return ToRustType(p.Type)
|
||||
}
|
||||
|
||||
// RustTypeWithStructs returns the Rust type using known struct names.
|
||||
func (p Param) RustTypeWithStructs(knownStructs map[string]bool) string {
|
||||
return ToRustTypeWithStructs(p.Type, knownStructs)
|
||||
}
|
||||
|
||||
// RustParamType returns the Rust type for this parameter when used as a function argument.
|
||||
func (p Param) RustParamType() string {
|
||||
return RustParamType(p.Type)
|
||||
}
|
||||
|
||||
// RustParamTypeWithStructs returns the Rust param type using known struct names.
|
||||
func (p Param) RustParamTypeWithStructs(knownStructs map[string]bool) string {
|
||||
if p.Type == "string" {
|
||||
return "&str"
|
||||
}
|
||||
return ToRustTypeWithStructs(p.Type, knownStructs)
|
||||
}
|
||||
|
||||
// RustName returns the snake_case Rust name for this parameter.
|
||||
func (p Param) RustName() string {
|
||||
return ToSnakeCase(p.Name)
|
||||
}
|
||||
|
||||
// NeedsToOwned returns true if the parameter needs .to_owned() when used.
|
||||
func (p Param) NeedsToOwned() bool {
|
||||
return p.Type == "string"
|
||||
}
|
||||
|
||||
// RustType returns the Rust type for this field, using known struct names.
|
||||
func (f FieldDef) RustType(knownStructs map[string]bool) string {
|
||||
return ToRustTypeWithStructs(f.Type, knownStructs)
|
||||
}
|
||||
|
||||
// RustName returns the snake_case Rust name for this field.
|
||||
func (f FieldDef) RustName() string {
|
||||
return ToSnakeCase(f.Name)
|
||||
}
|
||||
|
||||
// NeedsDefault returns true if the field needs #[serde(default)] attribute.
|
||||
// This is true for fields with omitempty tag.
|
||||
func (f FieldDef) NeedsDefault() bool {
|
||||
return f.OmitEmpty
|
||||
}
|
||||
|
||||
// ToRustTypeWithStructs converts a Go type to its Rust equivalent,
|
||||
// using known struct names instead of serde_json::Value.
|
||||
func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string {
|
||||
// Handle pointer types
|
||||
if strings.HasPrefix(goType, "*") {
|
||||
inner := ToRustTypeWithStructs(goType[1:], knownStructs)
|
||||
return "Option<" + inner + ">"
|
||||
}
|
||||
// Handle slice types
|
||||
if strings.HasPrefix(goType, "[]") {
|
||||
if goType == "[]byte" {
|
||||
return "Vec<u8>"
|
||||
}
|
||||
inner := ToRustTypeWithStructs(goType[2:], knownStructs)
|
||||
return "Vec<" + inner + ">"
|
||||
}
|
||||
// Handle map types
|
||||
if strings.HasPrefix(goType, "map[") {
|
||||
// Extract key and value types from map[K]V
|
||||
rest := goType[4:] // Remove "map["
|
||||
depth := 1
|
||||
keyEnd := 0
|
||||
for i, r := range rest {
|
||||
if r == '[' {
|
||||
depth++
|
||||
} else if r == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
keyEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
keyType := rest[:keyEnd]
|
||||
valueType := rest[keyEnd+1:]
|
||||
return "std::collections::HashMap<" + ToRustTypeWithStructs(keyType, knownStructs) + ", " + ToRustTypeWithStructs(valueType, knownStructs) + ">"
|
||||
}
|
||||
|
||||
switch goType {
|
||||
case "string":
|
||||
return "String"
|
||||
case "int", "int32":
|
||||
return "i32"
|
||||
case "int64":
|
||||
return "i64"
|
||||
case "float32":
|
||||
return "f32"
|
||||
case "float64":
|
||||
return "f64"
|
||||
case "bool":
|
||||
return "bool"
|
||||
case "interface{}", "any":
|
||||
return "serde_json::Value"
|
||||
default:
|
||||
// Check if this is a known struct type
|
||||
if knownStructs != nil && knownStructs[goType] {
|
||||
return goType
|
||||
}
|
||||
// For unknown custom types, fall back to Value
|
||||
return "serde_json::Value"
|
||||
}
|
||||
}
|
||||
327
plugins/cmd/ndpgen/internal/xtp_schema.go
Normal file
327
plugins/cmd/ndpgen/internal/xtp_schema.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// XTP Schema types for YAML marshalling
|
||||
type (
|
||||
xtpSchema struct {
|
||||
Version string `yaml:"version"`
|
||||
Exports yaml.Node `yaml:"exports,omitempty"`
|
||||
Components *xtpComponents `yaml:"components,omitempty"`
|
||||
}
|
||||
|
||||
xtpComponents struct {
|
||||
Schemas yaml.Node `yaml:"schemas"`
|
||||
}
|
||||
|
||||
xtpExport struct {
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Input *xtpIOParam `yaml:"input,omitempty"`
|
||||
Output *xtpIOParam `yaml:"output,omitempty"`
|
||||
}
|
||||
|
||||
xtpIOParam struct {
|
||||
Ref string `yaml:"$ref,omitempty"`
|
||||
Type string `yaml:"type,omitempty"`
|
||||
ContentType string `yaml:"contentType"`
|
||||
}
|
||||
|
||||
// xtpObjectSchema represents an object schema in XTP.
|
||||
// Per the XTP JSON Schema, ObjectSchema has properties, required, and description
|
||||
// but NOT a type field.
|
||||
xtpObjectSchema struct {
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Properties yaml.Node `yaml:"properties"`
|
||||
Required []string `yaml:"required,omitempty"`
|
||||
}
|
||||
|
||||
xtpEnumSchema struct {
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Type string `yaml:"type"`
|
||||
Enum []string `yaml:"enum"`
|
||||
}
|
||||
|
||||
xtpProperty struct {
|
||||
Ref string `yaml:"$ref,omitempty"`
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Format string `yaml:"format,omitempty"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Nullable bool `yaml:"nullable,omitempty"`
|
||||
Items *xtpProperty `yaml:"items,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
// GenerateSchema generates an XTP YAML schema from a capability.
|
||||
func GenerateSchema(cap Capability) ([]byte, error) {
|
||||
schema := xtpSchema{Version: "v1-draft"}
|
||||
|
||||
// Build exports as ordered map
|
||||
if len(cap.Methods) > 0 {
|
||||
schema.Exports = yaml.Node{Kind: yaml.MappingNode}
|
||||
for _, export := range cap.Methods {
|
||||
addToMap(&schema.Exports, export.ExportName, buildExport(export))
|
||||
}
|
||||
}
|
||||
|
||||
// Build components/schemas
|
||||
schemas := buildSchemas(cap)
|
||||
if len(schemas.Content) > 0 {
|
||||
schema.Components = &xtpComponents{Schemas: schemas}
|
||||
}
|
||||
|
||||
return yaml.Marshal(schema)
|
||||
}
|
||||
|
||||
func buildExport(export Export) xtpExport {
|
||||
e := xtpExport{Description: cleanDocForYAML(export.Doc)}
|
||||
if export.Input.Type != "" {
|
||||
e.Input = &xtpIOParam{
|
||||
Ref: "#/components/schemas/" + strings.TrimPrefix(export.Input.Type, "*"),
|
||||
ContentType: "application/json",
|
||||
}
|
||||
}
|
||||
if export.Output.Type != "" {
|
||||
outputType := strings.TrimPrefix(export.Output.Type, "*")
|
||||
// Check if output is a primitive type
|
||||
if isPrimitiveGoType(outputType) {
|
||||
e.Output = &xtpIOParam{
|
||||
Type: goTypeToXTPType(outputType),
|
||||
ContentType: "application/json",
|
||||
}
|
||||
} else {
|
||||
e.Output = &xtpIOParam{
|
||||
Ref: "#/components/schemas/" + outputType,
|
||||
ContentType: "application/json",
|
||||
}
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// isPrimitiveGoType returns true if the Go type is a primitive type.
|
||||
func isPrimitiveGoType(goType string) bool {
|
||||
switch goType {
|
||||
case "bool", "string", "int", "int32", "int64", "float32", "float64", "[]byte":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildSchemas(cap Capability) yaml.Node {
|
||||
schemas := yaml.Node{Kind: yaml.MappingNode}
|
||||
knownTypes := cap.KnownStructs()
|
||||
for _, alias := range cap.TypeAliases {
|
||||
knownTypes[alias.Name] = true
|
||||
}
|
||||
|
||||
// Collect types that are actually used by exports
|
||||
usedTypes := collectUsedTypes(cap, knownTypes)
|
||||
|
||||
// Sort structs by name for consistent output
|
||||
structNames := make([]string, 0, len(cap.Structs))
|
||||
structMap := make(map[string]StructDef)
|
||||
for _, st := range cap.Structs {
|
||||
if usedTypes[st.Name] {
|
||||
structNames = append(structNames, st.Name)
|
||||
structMap[st.Name] = st
|
||||
}
|
||||
}
|
||||
sort.Strings(structNames)
|
||||
|
||||
for _, name := range structNames {
|
||||
st := structMap[name]
|
||||
addToMap(&schemas, name, buildObjectSchema(st, knownTypes))
|
||||
}
|
||||
|
||||
// Build enum types from type aliases (only if used by exports)
|
||||
for _, alias := range cap.TypeAliases {
|
||||
if !usedTypes[alias.Name] {
|
||||
continue
|
||||
}
|
||||
if alias.Type == "string" {
|
||||
for _, cg := range cap.Consts {
|
||||
if cg.Type == alias.Name {
|
||||
addToMap(&schemas, alias.Name, buildEnumSchema(alias, cg))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return schemas
|
||||
}
|
||||
|
||||
// collectUsedTypes returns a set of type names that are reachable from exports.
|
||||
func collectUsedTypes(cap Capability, knownTypes map[string]bool) map[string]bool {
|
||||
used := make(map[string]bool)
|
||||
|
||||
// Start with types directly referenced by exports
|
||||
for _, export := range cap.Methods {
|
||||
if export.Input.Type != "" {
|
||||
addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, used)
|
||||
}
|
||||
if export.Output.Type != "" {
|
||||
outputType := strings.TrimPrefix(export.Output.Type, "*")
|
||||
if !isPrimitiveGoType(outputType) {
|
||||
addTypeAndDeps(outputType, cap, knownTypes, used)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return used
|
||||
}
|
||||
|
||||
// addTypeAndDeps adds a type and all its dependencies to the used set.
|
||||
func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, used map[string]bool) {
|
||||
if used[typeName] || !knownTypes[typeName] {
|
||||
return
|
||||
}
|
||||
used[typeName] = true
|
||||
|
||||
// Find the struct and add its field types
|
||||
for _, st := range cap.Structs {
|
||||
if st.Name == typeName {
|
||||
for _, field := range st.Fields {
|
||||
fieldType := strings.TrimPrefix(field.Type, "*")
|
||||
fieldType = strings.TrimPrefix(fieldType, "[]")
|
||||
if knownTypes[fieldType] {
|
||||
addTypeAndDeps(fieldType, cap, knownTypes, used)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema {
|
||||
schema := xtpObjectSchema{
|
||||
Description: cleanDocForYAML(st.Doc),
|
||||
Properties: yaml.Node{Kind: yaml.MappingNode},
|
||||
}
|
||||
|
||||
for _, field := range st.Fields {
|
||||
propName := getJSONFieldName(field)
|
||||
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
|
||||
|
||||
if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty {
|
||||
schema.Required = append(schema.Required, propName)
|
||||
}
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
func buildEnumSchema(alias TypeAlias, cg ConstGroup) xtpEnumSchema {
|
||||
values := make([]string, 0, len(cg.Values))
|
||||
for _, cv := range cg.Values {
|
||||
values = append(values, strings.Trim(cv.Value, `"`))
|
||||
}
|
||||
return xtpEnumSchema{
|
||||
Description: cleanDocForYAML(alias.Doc),
|
||||
Type: "string",
|
||||
Enum: values,
|
||||
}
|
||||
}
|
||||
|
||||
func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
||||
goType := field.Type
|
||||
isPointer := strings.HasPrefix(goType, "*")
|
||||
if isPointer {
|
||||
goType = goType[1:]
|
||||
}
|
||||
|
||||
prop := xtpProperty{
|
||||
Description: cleanDocForYAML(field.Doc),
|
||||
Nullable: isPointer,
|
||||
}
|
||||
|
||||
// Handle reference types (use $ref instead of type)
|
||||
if isKnownType(goType, knownTypes) && !strings.HasPrefix(goType, "[]") {
|
||||
prop.Ref = "#/components/schemas/" + goType
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle slice types
|
||||
if strings.HasPrefix(goType, "[]") {
|
||||
elemType := goType[2:]
|
||||
prop.Type = "array"
|
||||
prop.Items = &xtpProperty{}
|
||||
if isKnownType(elemType, knownTypes) {
|
||||
prop.Items.Ref = "#/components/schemas/" + elemType
|
||||
} else {
|
||||
prop.Items.Type = goTypeToXTPType(elemType)
|
||||
}
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle primitive types
|
||||
prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType)
|
||||
return prop
|
||||
}
|
||||
|
||||
// addToMap adds a key-value pair to a yaml.Node map, preserving insertion order.
|
||||
func addToMap[T any](node *yaml.Node, key string, value T) {
|
||||
var valNode yaml.Node
|
||||
_ = valNode.Encode(value)
|
||||
node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, &valNode)
|
||||
}
|
||||
|
||||
func getJSONFieldName(field FieldDef) string {
|
||||
propName := field.JSONTag
|
||||
if idx := strings.Index(propName, ","); idx >= 0 {
|
||||
propName = propName[:idx]
|
||||
}
|
||||
if propName == "" {
|
||||
propName = field.Name
|
||||
}
|
||||
return propName
|
||||
}
|
||||
|
||||
// isKnownType checks if a type is a known struct or type alias.
|
||||
func isKnownType(typeName string, knownTypes map[string]bool) bool {
|
||||
return knownTypes[typeName]
|
||||
}
|
||||
|
||||
// goTypeToXTPType converts a Go type to an XTP schema type.
|
||||
func goTypeToXTPType(goType string) string {
|
||||
typ, _ := goTypeToXTPTypeAndFormat(goType)
|
||||
return typ
|
||||
}
|
||||
|
||||
// goTypeToXTPTypeAndFormat converts a Go type to XTP type and format.
|
||||
func goTypeToXTPTypeAndFormat(goType string) (typ, format string) {
|
||||
switch goType {
|
||||
case "string":
|
||||
return "string", ""
|
||||
case "int", "int32":
|
||||
return "integer", "int32"
|
||||
case "int64":
|
||||
return "integer", "int64"
|
||||
case "float32":
|
||||
return "number", "float"
|
||||
case "float64":
|
||||
return "number", "float"
|
||||
case "bool":
|
||||
return "boolean", ""
|
||||
case "[]byte":
|
||||
return "string", "byte"
|
||||
default:
|
||||
return "object", ""
|
||||
}
|
||||
}
|
||||
|
||||
// cleanDocForYAML cleans documentation for YAML output.
|
||||
func cleanDocForYAML(doc string) string {
|
||||
doc = strings.TrimSpace(doc)
|
||||
// Remove leading "// " from each line if present
|
||||
lines := strings.Split(doc, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimPrefix(strings.TrimSpace(line), "// ")
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
549
plugins/cmd/ndpgen/internal/xtp_schema.json
Normal file
549
plugins/cmd/ndpgen/internal/xtp_schema.json
Normal file
@@ -0,0 +1,549 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"$ref": "#/$defs/XtpVersion"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"version"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"version": {
|
||||
"const": "v0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"exports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z_$][a-zA-Z0-9_$]*$"
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
"const": "v0"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exports"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"version": {
|
||||
"const": "v1-draft"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"version": {
|
||||
"$ref": "#/$defs/XtpVersion"
|
||||
},
|
||||
"exports": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z_$][a-zA-Z0-9_$]*$": {
|
||||
"$ref": "#/$defs/Export"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"imports": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z_$][a-zA-Z0-9_$]*$": {
|
||||
"$ref": "#/$defs/Import"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"components": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schemas": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z_$][a-zA-Z0-9_$]*$": {
|
||||
"$ref": "#/$defs/Schema"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schemas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exports"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"XtpVersion": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"v0",
|
||||
"v1-draft"
|
||||
]
|
||||
},
|
||||
"Export": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"codeSamples": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/CodeSample"
|
||||
}
|
||||
},
|
||||
"input": {
|
||||
"$ref": "#/$defs/Parameter"
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/$defs/Parameter"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CodeSample": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lang": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"typescript",
|
||||
"csharp",
|
||||
"zig",
|
||||
"rust",
|
||||
"go",
|
||||
"python",
|
||||
"c++"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lang",
|
||||
"source"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Import": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"input": {
|
||||
"$ref": "#/$defs/Parameter"
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/$defs/Parameter"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ObjectSchema"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/EnumSchema"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ObjectSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z_$][a-zA-Z0-9_$]*$": {
|
||||
"$ref": "#/$defs/Property"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"properties"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EnumSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z_$][a-zA-Z0-9_$]*$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enum"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Parameter": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ValueParameter"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RefParameter"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/MapParameter"
|
||||
}
|
||||
]
|
||||
},
|
||||
"RefParameter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$ref": {
|
||||
"$ref": "#/$defs/SchemaReference"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"contentType": {
|
||||
"$ref": "#/$defs/ContentType"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"$ref",
|
||||
"contentType"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ValueParameter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contentType": {
|
||||
"$ref": "#/$defs/ContentType"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/$defs/XtpType"
|
||||
},
|
||||
"format": {
|
||||
"$ref": "#/$defs/XtpFormat"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/$defs/ArrayItem"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"contentType"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MapParameter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/NonMapProperty"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": false
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"contentType": {
|
||||
"$ref": "#/$defs/ContentType"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"additionalProperties",
|
||||
"contentType"
|
||||
]
|
||||
},
|
||||
"NonMapProperty": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ValueProperty"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RefProperty"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Property": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ValueProperty"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RefProperty"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/MapProperty"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ValueProperty": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"$ref": "#/$defs/XtpType"
|
||||
},
|
||||
"format": {
|
||||
"$ref": "#/$defs/XtpFormat"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/$defs/ArrayItem"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MapProperty": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/NonMapProperty"
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"type": "object",
|
||||
"required": ["description"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"additionalProperties"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RefProperty": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$ref": {
|
||||
"$ref": "#/$defs/SchemaReference"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"$ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ContentType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"application/json",
|
||||
"application/x-binary",
|
||||
"text/plain; charset=utf-8"
|
||||
]
|
||||
},
|
||||
"SchemaReference": {
|
||||
"type": "string",
|
||||
"pattern": "^#/components/schemas/[^/]+$"
|
||||
},
|
||||
"XtpType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"integer",
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"buffer"
|
||||
]
|
||||
},
|
||||
"XtpFormat": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"int32",
|
||||
"int64",
|
||||
"float",
|
||||
"double",
|
||||
"date-time",
|
||||
"byte"
|
||||
]
|
||||
},
|
||||
"ArrayItem": {
|
||||
"type": "object",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ValueArrayItem"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RefArrayItem"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/MapArrayItem"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ValueArrayItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"$ref": "#/$defs/XtpType"
|
||||
},
|
||||
"format": {
|
||||
"$ref": "#/$defs/XtpFormat"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RefArrayItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$ref": {
|
||||
"$ref": "#/$defs/SchemaReference"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"$ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MapArrayItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "object"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/NonMapProperty"
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"type": "object",
|
||||
"required": ["description"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"additionalProperties"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
722
plugins/cmd/ndpgen/internal/xtp_schema_test.go
Normal file
722
plugins/cmd/ndpgen/internal/xtp_schema_test.go
Normal file
@@ -0,0 +1,722 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var _ = Describe("XTP Schema Generation", func() {
|
||||
parseSchema := func(schema []byte) map[string]any {
|
||||
var doc map[string]any
|
||||
Expect(yaml.Unmarshal(schema, &doc)).To(Succeed())
|
||||
return doc
|
||||
}
|
||||
|
||||
Describe("GenerateSchema", func() {
|
||||
Context("basic capability with one export", func() {
|
||||
var schema []byte
|
||||
|
||||
BeforeEach(func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
Doc: "Test capability",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{
|
||||
ExportName: "test_method",
|
||||
Doc: "Test method does something",
|
||||
Input: NewParam("input", "TestInput"),
|
||||
Output: NewParam("output", "TestOutput"),
|
||||
},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "TestInput",
|
||||
Doc: "Input for test",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Name", Type: "string", JSONTag: "name", Doc: "The name"},
|
||||
{Name: "Count", Type: "int", JSONTag: "count", Doc: "The count"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "TestOutput",
|
||||
Doc: "Output for test",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Result", Type: "string", JSONTag: "result", Doc: "The result"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
var err error
|
||||
schema, err = GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(schema).NotTo(BeEmpty())
|
||||
})
|
||||
|
||||
It("should validate against XTP JSONSchema", func() {
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
})
|
||||
|
||||
It("should have correct version", func() {
|
||||
doc := parseSchema(schema)
|
||||
Expect(doc["version"]).To(Equal("v1-draft"))
|
||||
})
|
||||
|
||||
It("should include exports with description", func() {
|
||||
doc := parseSchema(schema)
|
||||
exports := doc["exports"].(map[string]any)
|
||||
Expect(exports).To(HaveKey("test_method"))
|
||||
method := exports["test_method"].(map[string]any)
|
||||
Expect(method["description"]).To(Equal("Test method does something"))
|
||||
})
|
||||
|
||||
It("should include schemas for input and output types", func() {
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
Expect(schemas).To(HaveKey("TestInput"))
|
||||
Expect(schemas).To(HaveKey("TestOutput"))
|
||||
})
|
||||
|
||||
It("should define input schema with correct properties", func() {
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["TestInput"].(map[string]any)
|
||||
// Per XTP spec, ObjectSchema does NOT have a type field - only properties, required, description
|
||||
Expect(input).NotTo(HaveKey("type"))
|
||||
props := input["properties"].(map[string]any)
|
||||
Expect(props).To(HaveKey("name"))
|
||||
Expect(props).To(HaveKey("count"))
|
||||
})
|
||||
|
||||
It("should mark non-pointer, non-omitempty fields as required", func() {
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["TestInput"].(map[string]any)
|
||||
required := input["required"].([]any)
|
||||
Expect(required).To(ContainElement("name"))
|
||||
Expect(required).To(ContainElement("count"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("capability with pointer fields (nullable)", func() {
|
||||
var schema []byte
|
||||
|
||||
BeforeEach(func() {
|
||||
capability := Capability{
|
||||
Name: "nullable_test",
|
||||
SourceFile: "nullable_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Required", Type: "string", JSONTag: "required"},
|
||||
{Name: "Optional", Type: "*string", JSONTag: "optional,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
var err error
|
||||
schema, err = GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should validate against XTP JSONSchema", func() {
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
})
|
||||
|
||||
It("should not mark required field as nullable", func() {
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
requiredField := props["required"].(map[string]any)
|
||||
Expect(requiredField).NotTo(HaveKey("nullable"))
|
||||
})
|
||||
|
||||
It("should mark optional pointer field as nullable", func() {
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
optionalField := props["optional"].(map[string]any)
|
||||
Expect(optionalField["nullable"]).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should only include non-pointer fields in required array", func() {
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
required := input["required"].([]any)
|
||||
Expect(required).To(ContainElement("required"))
|
||||
Expect(required).NotTo(ContainElement("optional"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("capability with enum", func() {
|
||||
var schema []byte
|
||||
|
||||
BeforeEach(func() {
|
||||
capability := Capability{
|
||||
Name: "enum_test",
|
||||
SourceFile: "enum_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Status", Type: "Status", JSONTag: "status"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
TypeAliases: []TypeAlias{
|
||||
{Name: "Status", Type: "string", Doc: "Status type"},
|
||||
},
|
||||
Consts: []ConstGroup{
|
||||
{
|
||||
Type: "Status",
|
||||
Values: []ConstDef{
|
||||
{Name: "StatusPending", Value: `"pending"`},
|
||||
{Name: "StatusActive", Value: `"active"`},
|
||||
{Name: "StatusDone", Value: `"done"`},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
var err error
|
||||
schema, err = GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should validate against XTP JSONSchema", func() {
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
})
|
||||
|
||||
It("should define enum type with correct values", func() {
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
Expect(schemas).To(HaveKey("Status"))
|
||||
status := schemas["Status"].(map[string]any)
|
||||
Expect(status["type"]).To(Equal("string"))
|
||||
enum := status["enum"].([]any)
|
||||
Expect(enum).To(ConsistOf("pending", "active", "done"))
|
||||
})
|
||||
|
||||
It("should use $ref for enum field in struct", func() {
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
statusRef := props["status"].(map[string]any)
|
||||
Expect(statusRef["$ref"]).To(Equal("#/components/schemas/Status"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("capability with array types", func() {
|
||||
var schema []byte
|
||||
|
||||
BeforeEach(func() {
|
||||
capability := Capability{
|
||||
Name: "array_test",
|
||||
SourceFile: "array_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Tags", Type: "[]string", JSONTag: "tags"},
|
||||
{Name: "Items", Type: "[]Item", JSONTag: "items"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Item",
|
||||
Fields: []FieldDef{
|
||||
{Name: "ID", Type: "string", JSONTag: "id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
var err error
|
||||
schema, err = GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should validate against XTP JSONSchema", func() {
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
})
|
||||
|
||||
It("should define string array with primitive type", func() {
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
tags := props["tags"].(map[string]any)
|
||||
Expect(tags["type"]).To(Equal("array"))
|
||||
tagItems := tags["items"].(map[string]any)
|
||||
Expect(tagItems["type"]).To(Equal("string"))
|
||||
})
|
||||
|
||||
It("should define struct array with $ref", func() {
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
items := props["items"].(map[string]any)
|
||||
Expect(items["type"]).To(Equal("array"))
|
||||
itemItems := items["items"].(map[string]any)
|
||||
Expect(itemItems["$ref"]).To(Equal("#/components/schemas/Item"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("capability with nullable ref", func() {
|
||||
It("should mark pointer to enum as nullable with $ref", func() {
|
||||
capability := Capability{
|
||||
Name: "nullable_ref_test",
|
||||
SourceFile: "nullable_ref_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Status", Type: "*ErrorType", JSONTag: "status,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
TypeAliases: []TypeAlias{
|
||||
{Name: "ErrorType", Type: "string"},
|
||||
},
|
||||
Consts: []ConstGroup{
|
||||
{
|
||||
Type: "ErrorType",
|
||||
Values: []ConstDef{
|
||||
{Name: "ErrorNone", Value: `"none"`},
|
||||
{Name: "ErrorFatal", Value: `"fatal"`},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Validate against XTP JSONSchema
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
output := schemas["Output"].(map[string]any)
|
||||
props := output["properties"].(map[string]any)
|
||||
status := props["status"].(map[string]any)
|
||||
Expect(status["$ref"]).To(Equal("#/components/schemas/ErrorType"))
|
||||
Expect(status["nullable"]).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("goTypeToXTPTypeAndFormat", func() {
|
||||
DescribeTable("should convert Go types to XTP types",
|
||||
func(goType, wantType, wantFormat string) {
|
||||
gotType, gotFormat := goTypeToXTPTypeAndFormat(goType)
|
||||
Expect(gotType).To(Equal(wantType))
|
||||
Expect(gotFormat).To(Equal(wantFormat))
|
||||
},
|
||||
Entry("string", "string", "string", ""),
|
||||
Entry("int", "int", "integer", "int32"),
|
||||
Entry("int32", "int32", "integer", "int32"),
|
||||
Entry("int64", "int64", "integer", "int64"),
|
||||
Entry("float32", "float32", "number", "float"),
|
||||
Entry("float64", "float64", "number", "float"),
|
||||
Entry("bool", "bool", "boolean", ""),
|
||||
Entry("[]byte", "[]byte", "string", "byte"),
|
||||
Entry("unknown types default to object", "CustomType", "object", ""),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("cleanDocForYAML", func() {
|
||||
DescribeTable("should clean documentation strings",
|
||||
func(doc, want string) {
|
||||
Expect(cleanDocForYAML(doc)).To(Equal(want))
|
||||
},
|
||||
Entry("empty", "", ""),
|
||||
Entry("single line", "Simple description", "Simple description"),
|
||||
Entry("multiline", "First line\nSecond line", "First line\nSecond line"),
|
||||
Entry("trailing newline", "Description\n", "Description"),
|
||||
Entry("whitespace", " Description ", "Description"),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("isPrimitiveGoType", func() {
|
||||
DescribeTable("should identify primitive Go types",
|
||||
func(goType string, want bool) {
|
||||
Expect(isPrimitiveGoType(goType)).To(Equal(want))
|
||||
},
|
||||
Entry("bool", "bool", true),
|
||||
Entry("string", "string", true),
|
||||
Entry("int", "int", true),
|
||||
Entry("int32", "int32", true),
|
||||
Entry("int64", "int64", true),
|
||||
Entry("float32", "float32", true),
|
||||
Entry("float64", "float64", true),
|
||||
Entry("[]byte", "[]byte", true),
|
||||
Entry("custom type", "CustomType", false),
|
||||
Entry("struct type", "MyStruct", false),
|
||||
Entry("slice of string", "[]string", false),
|
||||
Entry("map type", "map[string]int", false),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("GenerateSchema with primitive output types", func() {
|
||||
inputStruct := StructDef{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}},
|
||||
}
|
||||
|
||||
Context("export with primitive string output", func() {
|
||||
It("should use type instead of $ref and validate against XTP JSONSchema", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "get_name", Input: NewParam("input", "Input"), Output: NewParam("output", "string")},
|
||||
},
|
||||
Structs: []StructDef{inputStruct},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(schema).NotTo(BeEmpty())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
exports := doc["exports"].(map[string]any)
|
||||
method := exports["get_name"].(map[string]any)
|
||||
output := method["output"].(map[string]any)
|
||||
Expect(output["type"]).To(Equal("string"))
|
||||
Expect(output).NotTo(HaveKey("$ref"))
|
||||
Expect(output["contentType"]).To(Equal("application/json"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("export with primitive bool output", func() {
|
||||
It("should use boolean type and validate against XTP JSONSchema", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "is_valid", Input: NewParam("input", "Input"), Output: NewParam("output", "bool")},
|
||||
},
|
||||
Structs: []StructDef{inputStruct},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
exports := doc["exports"].(map[string]any)
|
||||
method := exports["is_valid"].(map[string]any)
|
||||
output := method["output"].(map[string]any)
|
||||
Expect(output["type"]).To(Equal("boolean"))
|
||||
Expect(output).NotTo(HaveKey("$ref"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("export with primitive int output", func() {
|
||||
It("should use integer type and validate against XTP JSONSchema", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "get_count", Input: NewParam("input", "Input"), Output: NewParam("output", "int32")},
|
||||
},
|
||||
Structs: []StructDef{inputStruct},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
exports := doc["exports"].(map[string]any)
|
||||
method := exports["get_count"].(map[string]any)
|
||||
output := method["output"].(map[string]any)
|
||||
Expect(output["type"]).To(Equal("integer"))
|
||||
Expect(output).NotTo(HaveKey("$ref"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("export with pointer to primitive output", func() {
|
||||
It("should strip pointer and use primitive type and validate against XTP JSONSchema", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "get_optional_string", Input: NewParam("input", "Input"), Output: NewParam("output", "*string")},
|
||||
},
|
||||
Structs: []StructDef{inputStruct},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
exports := doc["exports"].(map[string]any)
|
||||
method := exports["get_optional_string"].(map[string]any)
|
||||
output := method["output"].(map[string]any)
|
||||
Expect(output["type"]).To(Equal("string"))
|
||||
Expect(output).NotTo(HaveKey("$ref"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("export with struct output", func() {
|
||||
It("should still use $ref and validate against XTP JSONSchema", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "get_result", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
inputStruct,
|
||||
{Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
exports := doc["exports"].(map[string]any)
|
||||
method := exports["get_result"].(map[string]any)
|
||||
output := method["output"].(map[string]any)
|
||||
Expect(output["$ref"]).To(Equal("#/components/schemas/Output"))
|
||||
Expect(output).NotTo(HaveKey("type"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("collectUsedTypes", func() {
|
||||
getSchemas := func(schema []byte) map[string]any {
|
||||
doc := parseSchema(schema)
|
||||
components, hasComponents := doc["components"].(map[string]any)
|
||||
if !hasComponents {
|
||||
return make(map[string]any)
|
||||
}
|
||||
schemas, ok := components["schemas"].(map[string]any)
|
||||
if !ok {
|
||||
return make(map[string]any)
|
||||
}
|
||||
return schemas
|
||||
}
|
||||
|
||||
It("should only include types referenced by exports", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "UsedInput"), Output: NewParam("output", "UsedOutput")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{Name: "UsedInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
|
||||
{Name: "UsedOutput", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
|
||||
{Name: "UnusedStruct", Fields: []FieldDef{{Name: "Foo", Type: "string", JSONTag: "foo"}}},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
schemas := getSchemas(schema)
|
||||
Expect(schemas).To(HaveKey("UsedInput"))
|
||||
Expect(schemas).To(HaveKey("UsedOutput"))
|
||||
Expect(schemas).NotTo(HaveKey("UnusedStruct"))
|
||||
})
|
||||
|
||||
It("should include transitively referenced types", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
|
||||
{Name: "Output", Fields: []FieldDef{{Name: "Nested", Type: "NestedType", JSONTag: "nested"}}},
|
||||
{Name: "NestedType", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
schemas := getSchemas(schema)
|
||||
Expect(schemas).To(HaveKey("Input"))
|
||||
Expect(schemas).To(HaveKey("Output"))
|
||||
Expect(schemas).To(HaveKey("NestedType"))
|
||||
})
|
||||
|
||||
It("should include array element types", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
|
||||
{Name: "Output", Fields: []FieldDef{{Name: "Items", Type: "[]Item", JSONTag: "items"}}},
|
||||
{Name: "Item", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
schemas := getSchemas(schema)
|
||||
Expect(schemas).To(HaveKey("Input"))
|
||||
Expect(schemas).To(HaveKey("Output"))
|
||||
Expect(schemas).To(HaveKey("Item"))
|
||||
})
|
||||
|
||||
It("should include pointer types", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
|
||||
{Name: "Output", Fields: []FieldDef{{Name: "Optional", Type: "*OptionalType", JSONTag: "optional"}}},
|
||||
{Name: "OptionalType", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
schemas := getSchemas(schema)
|
||||
Expect(schemas).To(HaveKey("Input"))
|
||||
Expect(schemas).To(HaveKey("Output"))
|
||||
Expect(schemas).To(HaveKey("OptionalType"))
|
||||
})
|
||||
|
||||
It("should exclude primitive output types from schema", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "string")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
schemas := getSchemas(schema)
|
||||
Expect(schemas).To(HaveKey("Input"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateSchema enum filtering", func() {
|
||||
It("should only include enums that are actually used by exports", func() {
|
||||
capability := Capability{
|
||||
Name: "test",
|
||||
SourceFile: "test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{{Name: "Status", Type: "UsedStatus", JSONTag: "status"}},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}},
|
||||
},
|
||||
},
|
||||
TypeAliases: []TypeAlias{
|
||||
{Name: "UsedStatus", Type: "string"},
|
||||
{Name: "UnusedStatus", Type: "string"},
|
||||
},
|
||||
Consts: []ConstGroup{
|
||||
{
|
||||
Type: "UsedStatus",
|
||||
Values: []ConstDef{
|
||||
{Name: "StatusActive", Value: `"active"`},
|
||||
{Name: "StatusInactive", Value: `"inactive"`},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "UnusedStatus",
|
||||
Values: []ConstDef{
|
||||
{Name: "UnusedPending", Value: `"pending"`},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
|
||||
// UsedStatus should be included because it's referenced by Input
|
||||
Expect(schemas).To(HaveKey("UsedStatus"))
|
||||
usedStatus := schemas["UsedStatus"].(map[string]any)
|
||||
Expect(usedStatus["type"]).To(Equal("string"))
|
||||
enum := usedStatus["enum"].([]any)
|
||||
Expect(enum).To(ConsistOf("active", "inactive"))
|
||||
|
||||
// UnusedStatus should NOT be included
|
||||
Expect(schemas).NotTo(HaveKey("UnusedStatus"))
|
||||
})
|
||||
})
|
||||
})
|
||||
51
plugins/cmd/ndpgen/internal/xtp_schema_validate.go
Normal file
51
plugins/cmd/ndpgen/internal/xtp_schema_validate.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/xeipuuv/gojsonschema"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// XTP JSONSchema specification, from
|
||||
// https://raw.githubusercontent.com/dylibso/xtp-bindgen/5090518dd86ba5e734dc225a33066ecc0ed2e12d/plugin/schema.json
|
||||
//
|
||||
//go:embed xtp_schema.json
|
||||
var xtpSchemaJSON string
|
||||
|
||||
// ValidateXTPSchema validates that the generated schema conforms to the XTP JSONSchema specification.
|
||||
// Returns nil if valid, or an error with validation details if invalid.
|
||||
func ValidateXTPSchema(generatedSchema []byte) error {
|
||||
// Parse the YAML schema to JSON for validation
|
||||
var schemaDoc map[string]any
|
||||
if err := yaml.Unmarshal(generatedSchema, &schemaDoc); err != nil {
|
||||
return fmt.Errorf("failed to parse generated schema as YAML: %w", err)
|
||||
}
|
||||
|
||||
// Convert to JSON for the validator
|
||||
jsonBytes, err := json.Marshal(schemaDoc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert schema to JSON: %w", err)
|
||||
}
|
||||
|
||||
schemaLoader := gojsonschema.NewStringLoader(xtpSchemaJSON)
|
||||
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
|
||||
|
||||
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema validation failed: %w", err)
|
||||
}
|
||||
|
||||
if !result.Valid() {
|
||||
var errs []string
|
||||
for _, desc := range result.Errors() {
|
||||
errs = append(errs, fmt.Sprintf("- %s", desc))
|
||||
}
|
||||
return fmt.Errorf("schema validation errors:\n%s", strings.Join(errs, "\n"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
816
plugins/cmd/ndpgen/main.go
Normal file
816
plugins/cmd/ndpgen/main.go
Normal file
@@ -0,0 +1,816 @@
|
||||
// ndpgen generates Navidrome Plugin Development Kit (PDK) code from annotated Go interfaces.
|
||||
//
|
||||
// This is the unified code generator that handles both host function wrappers
|
||||
// and capability export wrappers.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// # Generate host wrappers for Navidrome server (output to input directory)
|
||||
// ndpgen -host-wrappers -input=./plugins/host -package=host
|
||||
//
|
||||
// # Generate PDK client wrappers (from plugins/host to plugins/pdk)
|
||||
// ndpgen -host-only -input=./plugins/host -output=./plugins/pdk
|
||||
//
|
||||
// # Generate capability wrappers (from plugins/capabilities to plugins/pdk)
|
||||
// ndpgen -capability-only -input=./plugins/capabilities -output=./plugins/pdk
|
||||
//
|
||||
// # Generate XTP schemas from capabilities (output to input directory)
|
||||
// ndpgen -schemas -input=./plugins/capabilities
|
||||
//
|
||||
// Output directories:
|
||||
// - Host wrappers: $input/<servicename>_gen.go (server-side, used by Navidrome)
|
||||
// - Host functions: $output/go/host/, $output/python/host/, $output/rust/host/
|
||||
// - Capabilities: $output/go/<capability>/ (e.g., $output/go/metadata/)
|
||||
// - Schemas: $input/<capability>.yaml (co-located with Go sources)
|
||||
//
|
||||
// Flags:
|
||||
//
|
||||
// -input Input directory containing Go source files with annotated interfaces
|
||||
// -output Output directory base for generated files (default: same as input)
|
||||
// -package Output package name for Go (default: host for host-only, auto for capabilities)
|
||||
// -host-wrappers Generate server-side host wrappers (used by Navidrome, output to input directory)
|
||||
// -host-only Generate PDK client wrappers for calling host functions
|
||||
// -capability-only Generate only capability export wrappers
|
||||
// -schemas Generate XTP YAML schemas from capabilities
|
||||
// -go Generate Go client wrappers (default: true when not using -python/-rust)
|
||||
// -python Generate Python client wrappers (default: false)
|
||||
// -rust Generate Rust client wrappers (default: false)
|
||||
// -v Verbose output
|
||||
// -dry-run Preview generated code without writing files
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/cmd/ndpgen/internal"
|
||||
)
|
||||
|
||||
// config holds the parsed command-line configuration.
|
||||
type config struct {
|
||||
inputDir string
|
||||
outputDir string // Base output directory (e.g., plugins/pdk)
|
||||
goOutputDir string // Go output: $outputDir/go/host (for host-only)
|
||||
pythonOutputDir string // Python output: $outputDir/python/host
|
||||
rustOutputDir string // Rust output: $outputDir/rust/host
|
||||
pkgName string
|
||||
hostOnly bool
|
||||
hostWrappers bool // Generate host wrappers (used by Navidrome server)
|
||||
capabilityOnly bool
|
||||
schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir)
|
||||
generateGoClient bool
|
||||
generatePyClient bool
|
||||
generateRsClient bool
|
||||
verbose bool
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := parseConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if cfg.schemasOnly {
|
||||
if err := runSchemaGeneration(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.capabilityOnly {
|
||||
if err := runCapabilityGeneration(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.hostWrappers {
|
||||
if err := runHostWrapperGeneration(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Default: host-only mode
|
||||
services, err := parseServices(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(services) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := generateAllCode(cfg, services); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runCapabilityGeneration handles capability-only code generation.
|
||||
func runCapabilityGeneration(cfg *config) error {
|
||||
capabilities, err := parseCapabilities(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(capabilities) == 0 {
|
||||
if cfg.verbose {
|
||||
fmt.Println("No capabilities found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return generateCapabilityCode(cfg, capabilities)
|
||||
}
|
||||
|
||||
// runSchemaGeneration handles XTP schema generation from capabilities.
|
||||
func runSchemaGeneration(cfg *config) error {
|
||||
capabilities, err := parseCapabilities(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(capabilities) == 0 {
|
||||
if cfg.verbose {
|
||||
fmt.Println("No capabilities found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return generateSchemas(cfg, capabilities)
|
||||
}
|
||||
|
||||
// runHostWrapperGeneration handles host wrapper code generation.
|
||||
// This generates the *_gen.go files in the input directory that are used
|
||||
// by Navidrome server to expose host functions to plugins.
|
||||
func runHostWrapperGeneration(cfg *config) error {
|
||||
services, err := parseServices(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(services) == 0 {
|
||||
if cfg.verbose {
|
||||
fmt.Println("No host services found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate host wrappers for each service
|
||||
for _, svc := range services {
|
||||
if err := generateHostWrapperCode(svc, cfg.inputDir, cfg.pkgName, cfg.dryRun, cfg.verbose); err != nil {
|
||||
return fmt.Errorf("generating host wrapper for %s: %w", svc.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseConfig parses command-line flags and returns the configuration.
|
||||
func parseConfig() (*config, error) {
|
||||
var (
|
||||
inputDir = flag.String("input", ".", "Input directory containing Go source files")
|
||||
outputDir = flag.String("output", "", "Base output directory for generated files (default: same as input)")
|
||||
pkgName = flag.String("package", "", "Output package name for Go (default: host for host-only, auto for capabilities)")
|
||||
hostOnly = flag.Bool("host-only", false, "Generate only host function wrappers")
|
||||
hostWrappers = flag.Bool("host-wrappers", false, "Generate host wrappers (used by Navidrome server, output to input directory)")
|
||||
capabilityOnly = flag.Bool("capability-only", false, "Generate only capability export wrappers")
|
||||
schemasOnly = flag.Bool("schemas", false, "Generate XTP YAML schemas from capabilities (output to input directory)")
|
||||
goClient = flag.Bool("go", false, "Generate Go client wrappers")
|
||||
pyClient = flag.Bool("python", false, "Generate Python client wrappers")
|
||||
rsClient = flag.Bool("rust", false, "Generate Rust client wrappers")
|
||||
verbose = flag.Bool("v", false, "Verbose output")
|
||||
dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
// Count how many mode flags are specified
|
||||
modeCount := 0
|
||||
if *hostOnly {
|
||||
modeCount++
|
||||
}
|
||||
if *hostWrappers {
|
||||
modeCount++
|
||||
}
|
||||
if *capabilityOnly {
|
||||
modeCount++
|
||||
}
|
||||
if *schemasOnly {
|
||||
modeCount++
|
||||
}
|
||||
|
||||
// Default to host-only if no mode is specified
|
||||
if modeCount == 0 {
|
||||
*hostOnly = true
|
||||
}
|
||||
|
||||
// Cannot specify multiple modes
|
||||
if modeCount > 1 {
|
||||
return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas)")
|
||||
}
|
||||
|
||||
if *outputDir == "" {
|
||||
*outputDir = *inputDir
|
||||
}
|
||||
|
||||
// Default package name based on mode
|
||||
if *pkgName == "" {
|
||||
if *hostOnly {
|
||||
*pkgName = "host"
|
||||
}
|
||||
// For capability-only, package name is derived from capability annotation
|
||||
}
|
||||
|
||||
absInput, err := filepath.Abs(*inputDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving input path: %w", err)
|
||||
}
|
||||
absOutput, err := filepath.Abs(*outputDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving output path: %w", err)
|
||||
}
|
||||
|
||||
// Set output directories for each language
|
||||
// Go host wrappers: $output/go/host/
|
||||
// Python host wrappers: $output/python/host/
|
||||
// Rust host wrappers: $output/rust/nd-pdk-host/ (renamed crate)
|
||||
absGoOutput := filepath.Join(absOutput, "go", "host")
|
||||
absPythonOutput := filepath.Join(absOutput, "python", "host")
|
||||
absRustOutput := filepath.Join(absOutput, "rust", "nd-pdk-host")
|
||||
|
||||
// Determine what to generate
|
||||
// Default: generate Go clients if no language flag is specified
|
||||
anyLangFlag := *goClient || *pyClient || *rsClient
|
||||
|
||||
return &config{
|
||||
inputDir: absInput,
|
||||
outputDir: absOutput,
|
||||
goOutputDir: absGoOutput,
|
||||
pythonOutputDir: absPythonOutput,
|
||||
rustOutputDir: absRustOutput,
|
||||
pkgName: *pkgName,
|
||||
hostOnly: *hostOnly,
|
||||
hostWrappers: *hostWrappers,
|
||||
capabilityOnly: *capabilityOnly,
|
||||
schemasOnly: *schemasOnly,
|
||||
generateGoClient: *goClient || !anyLangFlag,
|
||||
generatePyClient: *pyClient,
|
||||
generateRsClient: *rsClient,
|
||||
verbose: *verbose,
|
||||
dryRun: *dryRun,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseServices parses source files and returns discovered services.
|
||||
func parseServices(cfg *config) ([]internal.Service, error) {
|
||||
if cfg.verbose {
|
||||
fmt.Printf("Input directory: %s\n", cfg.inputDir)
|
||||
fmt.Printf("Base output directory: %s\n", cfg.outputDir)
|
||||
if cfg.generateGoClient {
|
||||
fmt.Printf("Go output directory: %s\n", cfg.goOutputDir)
|
||||
}
|
||||
if cfg.generatePyClient {
|
||||
fmt.Printf("Python output directory: %s\n", cfg.pythonOutputDir)
|
||||
}
|
||||
if cfg.generateRsClient {
|
||||
fmt.Printf("Rust output directory: %s\n", cfg.rustOutputDir)
|
||||
}
|
||||
fmt.Printf("Package name: %s\n", cfg.pkgName)
|
||||
fmt.Printf("Host-only mode: %v\n", cfg.hostOnly)
|
||||
fmt.Printf("Generate Go client code: %v\n", cfg.generateGoClient)
|
||||
fmt.Printf("Generate Python client code: %v\n", cfg.generatePyClient)
|
||||
fmt.Printf("Generate Rust client code: %v\n", cfg.generateRsClient)
|
||||
}
|
||||
|
||||
services, err := internal.ParseDirectory(cfg.inputDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing source files: %w", err)
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
if cfg.verbose {
|
||||
fmt.Println("No host services found")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if cfg.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))
|
||||
}
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// parseCapabilities parses source files and returns discovered capabilities.
|
||||
func parseCapabilities(cfg *config) ([]internal.Capability, error) {
|
||||
if cfg.verbose {
|
||||
fmt.Printf("Input directory: %s\n", cfg.inputDir)
|
||||
fmt.Printf("Base output directory: %s\n", cfg.outputDir)
|
||||
fmt.Printf("Capability-only mode: %v\n", cfg.capabilityOnly)
|
||||
}
|
||||
|
||||
capabilities, err := internal.ParseCapabilities(cfg.inputDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing capability files: %w", err)
|
||||
}
|
||||
|
||||
if len(capabilities) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if cfg.verbose {
|
||||
fmt.Printf("Found %d capability(ies)\n", len(capabilities))
|
||||
for _, cap := range capabilities {
|
||||
fmt.Printf(" - %s (%d exports, required=%v)\n", cap.Name, len(cap.Methods), cap.Required)
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities, nil
|
||||
}
|
||||
|
||||
// generateCapabilityCode generates export wrappers for all capabilities.
|
||||
func generateCapabilityCode(cfg *config, capabilities []internal.Capability) error {
|
||||
// Generate Go capability wrappers (always, for now)
|
||||
for _, cap := range capabilities {
|
||||
// Output directory is $output/go/<capability_name>/
|
||||
outputDir := filepath.Join(cfg.outputDir, "go", cap.Name)
|
||||
|
||||
if err := generateCapabilityGoCode(cap, outputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||
return fmt.Errorf("generating Go capability code for %s: %w", cap.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Rust capability wrappers if -rust flag is set
|
||||
if cfg.generateRsClient {
|
||||
rustOutputDir := filepath.Join(cfg.outputDir, "rust", "nd-pdk-capabilities", "src")
|
||||
if err := generateCapabilityRustCode(capabilities, rustOutputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||
return fmt.Errorf("generating Rust capability code: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateCapabilityGoCode generates Go export wrapper code for a capability.
|
||||
func generateCapabilityGoCode(cap internal.Capability, outputDir string, dryRun, verbose bool) error {
|
||||
// Use the capability name as the package name
|
||||
pkgName := cap.Name
|
||||
|
||||
// Generate the main WASM code
|
||||
code, err := internal.GenerateCapabilityGo(cap, 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)
|
||||
}
|
||||
|
||||
mainFile := filepath.Join(outputDir, cap.Name+".go")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", mainFile, formatted)
|
||||
} else {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(mainFile, formatted, 0600); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated capability code: %s\n", mainFile)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the stub code for non-WASM platforms
|
||||
stubCode, err := internal.GenerateCapabilityGoStub(cap, pkgName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating stub code: %w", err)
|
||||
}
|
||||
|
||||
formattedStub, err := format.Source(stubCode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("formatting stub code: %w\nRaw code:\n%s", err, stubCode)
|
||||
}
|
||||
|
||||
stubFile := filepath.Join(outputDir, cap.Name+"_stub.go")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", stubFile, formattedStub)
|
||||
} else {
|
||||
if err := os.WriteFile(stubFile, formattedStub, 0600); err != nil {
|
||||
return fmt.Errorf("writing stub file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated capability stub: %s\n", stubFile)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateCapabilityRustCode generates Rust export wrapper code for all capabilities.
|
||||
func generateCapabilityRustCode(capabilities []internal.Capability, outputDir string, dryRun, verbose bool) error {
|
||||
// Generate individual capability modules
|
||||
for _, cap := range capabilities {
|
||||
code, err := internal.GenerateCapabilityRust(cap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating Rust code for %s: %w", cap.Name, err)
|
||||
}
|
||||
|
||||
fileName := internal.ToSnakeCase(cap.Name) + ".rs"
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", filePath, code)
|
||||
} else {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, code, 0600); err != nil {
|
||||
return fmt.Errorf("writing file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated Rust capability code: %s\n", filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate lib.rs
|
||||
libCode, err := internal.GenerateCapabilityRustLib(capabilities)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating lib.rs: %w", err)
|
||||
}
|
||||
|
||||
libPath := filepath.Join(outputDir, "lib.rs")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", libPath, libCode)
|
||||
} else {
|
||||
if err := os.WriteFile(libPath, libCode, 0600); err != nil {
|
||||
return fmt.Errorf("writing lib.rs: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated Rust lib.rs: %s\n", libPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateAllCode generates all requested code for the services.
|
||||
func generateAllCode(cfg *config, services []internal.Service) error {
|
||||
for _, svc := range services {
|
||||
if cfg.generateGoClient {
|
||||
if err := generateGoClientCode(svc, cfg.goOutputDir, cfg.pkgName, cfg.dryRun, cfg.verbose); err != nil {
|
||||
return fmt.Errorf("generating Go client code for %s: %w", svc.Name, err)
|
||||
}
|
||||
}
|
||||
if cfg.generatePyClient {
|
||||
if err := generatePythonClientCode(svc, cfg.pythonOutputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||
return fmt.Errorf("generating Python client code for %s: %w", svc.Name, err)
|
||||
}
|
||||
}
|
||||
if cfg.generateRsClient {
|
||||
if err := generateRustClientCode(svc, cfg.rustOutputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||
return fmt.Errorf("generating Rust client code for %s: %w", svc.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.generateRsClient && len(services) > 0 {
|
||||
if err := generateRustLibFile(services, cfg.rustOutputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||
return fmt.Errorf("generating Rust lib.rs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.generateGoClient && len(services) > 0 {
|
||||
if err := generateGoDocFile(services, cfg.goOutputDir, cfg.pkgName, cfg.dryRun, cfg.verbose); err != nil {
|
||||
return fmt.Errorf("generating Go doc.go: %w", err)
|
||||
}
|
||||
if err := generateGoModFile(cfg.goOutputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||
return fmt.Errorf("generating Go go.mod: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateHostWrapperCode generates host wrapper code for a service.
|
||||
// This generates the *_gen.go files that are used by Navidrome server
|
||||
// to expose host functions to plugins via Extism.
|
||||
func generateHostWrapperCode(svc internal.Service, outputDir, pkgName 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)
|
||||
}
|
||||
|
||||
// Host wrapper file follows the pattern <servicename>_gen.go
|
||||
hostFile := filepath.Join(outputDir, strings.ToLower(svc.Name)+"_gen.go")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", hostFile, formatted)
|
||||
} else {
|
||||
if err := os.WriteFile(hostFile, formatted, 0600); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated host wrapper: %s\n", hostFile)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateGoClientCode generates Go client-side code for a service.
|
||||
func generateGoClientCode(svc internal.Service, outputDir, pkgName string, dryRun, verbose bool) error {
|
||||
code, err := internal.GenerateClientGo(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)
|
||||
}
|
||||
|
||||
// Client code goes directly in the output directory
|
||||
clientFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+".go")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", clientFile, formatted)
|
||||
} else {
|
||||
// Create output directory if needed
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(clientFile, formatted, 0600); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated Go client code: %s\n", clientFile)
|
||||
}
|
||||
}
|
||||
|
||||
// Also generate stub file for non-WASM platforms
|
||||
return generateGoClientStubCode(svc, outputDir, pkgName, dryRun, verbose)
|
||||
}
|
||||
|
||||
// generateGoClientStubCode generates stub code for non-WASM platforms.
|
||||
func generateGoClientStubCode(svc internal.Service, outputDir, pkgName string, dryRun, verbose bool) error {
|
||||
code, err := internal.GenerateClientGoStub(svc, pkgName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating stub code: %w", err)
|
||||
}
|
||||
|
||||
formatted, err := format.Source(code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("formatting stub code: %w\nRaw code:\n%s", err, code)
|
||||
}
|
||||
|
||||
// Stub code goes directly in output directory with _stub suffix
|
||||
stubFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+"_stub.go")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", stubFile, formatted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create output directory if needed
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(stubFile, formatted, 0600); err != nil {
|
||||
return fmt.Errorf("writing stub file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated Go client stub: %s\n", stubFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generatePythonClientCode generates Python client-side code for a service.
|
||||
func generatePythonClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
|
||||
code, err := internal.GenerateClientPython(svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating code: %w", err)
|
||||
}
|
||||
|
||||
// Python code goes directly in the output directory
|
||||
clientFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+".py")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", clientFile, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create output directory if needed
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating python client directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(clientFile, code, 0600); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated Python client code: %s\n", clientFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateRustClientCode generates Rust client-side code for a service.
|
||||
func generateRustClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
|
||||
code, err := internal.GenerateClientRust(svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating code: %w", err)
|
||||
}
|
||||
|
||||
// Rust code goes in src/ subdirectory (standard Rust convention)
|
||||
srcDir := filepath.Join(outputDir, "src")
|
||||
clientFile := filepath.Join(srcDir, "nd_host_"+strings.ToLower(svc.Name)+".rs")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", clientFile, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create src directory if needed
|
||||
if err := os.MkdirAll(srcDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating rust src directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(clientFile, code, 0600); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated Rust client code: %s\n", clientFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateRustLibFile generates the lib.rs file that exposes all Rust modules.
|
||||
func generateRustLibFile(services []internal.Service, outputDir string, dryRun, verbose bool) error {
|
||||
code, err := internal.GenerateRustLib(services)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating lib.rs: %w", err)
|
||||
}
|
||||
|
||||
// lib.rs goes in src/ subdirectory (standard Rust convention)
|
||||
srcDir := filepath.Join(outputDir, "src")
|
||||
libFile := filepath.Join(srcDir, "lib.rs")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", libFile, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create src directory if needed
|
||||
if err := os.MkdirAll(srcDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating rust src directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(libFile, code, 0600); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated Rust lib.rs: %s\n", libFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateGoDocFile generates the doc.go file for the Go library.
|
||||
func generateGoDocFile(services []internal.Service, outputDir, pkgName string, dryRun, verbose bool) error {
|
||||
code, err := internal.GenerateGoDoc(services, pkgName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating doc.go: %w", err)
|
||||
}
|
||||
|
||||
formatted, err := format.Source(code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("formatting doc.go: %w\nRaw code:\n%s", err, code)
|
||||
}
|
||||
|
||||
docFile := filepath.Join(outputDir, "doc.go")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", docFile, formatted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create output directory if needed
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(docFile, formatted, 0600); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated Go doc.go: %s\n", docFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateGoModFile generates the go.mod file for the Go library.
|
||||
// The go.mod is placed at the parent directory ($output/go/) to create a unified
|
||||
// module that includes both host wrappers and capabilities.
|
||||
func generateGoModFile(outputDir string, dryRun, verbose bool) error {
|
||||
code, err := internal.GenerateGoMod()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating go.mod: %w", err)
|
||||
}
|
||||
|
||||
// Output to parent directory ($output/go/) instead of host directory
|
||||
parentDir := filepath.Dir(outputDir)
|
||||
modFile := filepath.Join(parentDir, "go.mod")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", modFile, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create parent directory if needed
|
||||
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(modFile, code, 0600); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated Go go.mod: %s\n", modFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSchemas generates XTP YAML schemas from capabilities.
|
||||
func generateSchemas(cfg *config, capabilities []internal.Capability) error {
|
||||
for _, cap := range capabilities {
|
||||
if err := generateSchemaFile(cap, cfg.inputDir, cfg.dryRun, cfg.verbose); err != nil {
|
||||
return fmt.Errorf("generating schema for %s: %w", cap.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSchemaFile generates an XTP YAML schema file for a capability.
|
||||
func generateSchemaFile(cap internal.Capability, outputDir string, dryRun, verbose bool) error {
|
||||
schema, err := internal.GenerateSchema(cap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating schema: %w", err)
|
||||
}
|
||||
|
||||
// Validate the generated schema against XTP JSONSchema spec
|
||||
if err := internal.ValidateXTPSchema(schema); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Schema validation for %s:\n%s\n", cap.Name, err)
|
||||
}
|
||||
|
||||
// Use the source file name: websocket_callback.go -> websocket_callback.yaml
|
||||
schemaFile := filepath.Join(outputDir, cap.SourceFile+".yaml")
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("=== %s ===\n%s\n", schemaFile, schema)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(schemaFile, schema, 0600); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf("Generated XTP schema: %s\n", schemaFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
13
plugins/cmd/ndpgen/ndpgen_suite_test.go
Normal file
13
plugins/cmd/ndpgen/ndpgen_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestNdpgen(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "NDPGen CLI Suite")
|
||||
}
|
||||
63
plugins/cmd/ndpgen/testdata/codec_client_expected.go.txt
vendored
Normal file
63
plugins/cmd/ndpgen/testdata/codec_client_expected.go.txt
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Codec host service.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package ndhost
|
||||
|
||||
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
|
||||
|
||||
type codecEncodeRequest struct {
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
type codecEncodeResponse struct {
|
||||
Result []byte `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CodecEncode calls the codec_encode host function.
|
||||
func CodecEncode(data []byte) ([]byte, error) {
|
||||
// Marshal request to JSON
|
||||
req := codecEncodeRequest{
|
||||
Data: data,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := codec_encode(reqMem.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
|
||||
}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return nil, errors.New(response.Error)
|
||||
}
|
||||
|
||||
return response.Result, nil
|
||||
}
|
||||
52
plugins/cmd/ndpgen/testdata/codec_client_expected.py
vendored
Normal file
52
plugins/cmd/ndpgen/testdata/codec_client_expected.py
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Code generated by ndpgen. DO NOT EDIT.
|
||||
#
|
||||
# This file contains client wrappers for the Codec host service.
|
||||
# It is intended for use in Navidrome plugins built with extism-py.
|
||||
#
|
||||
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
"""Raised when a host function returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "codec_encode")
|
||||
def _codec_encode(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
def codec_encode(data: bytes) -> bytes:
|
||||
"""Call the codec_encode host function.
|
||||
|
||||
Args:
|
||||
data: bytes parameter.
|
||||
|
||||
Returns:
|
||||
bytes: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"data": data,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _codec_encode(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", b"")
|
||||
51
plugins/cmd/ndpgen/testdata/codec_client_expected.rs
vendored
Normal file
51
plugins/cmd/ndpgen/testdata/codec_client_expected.rs
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Codec host service.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodecEncodeRequest {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodecEncodeResponse {
|
||||
#[serde(default)]
|
||||
result: Vec<u8>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn codec_encode(input: Json<CodecEncodeRequest>) -> Json<CodecEncodeResponse>;
|
||||
}
|
||||
|
||||
/// Calls the codec_encode host function.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Vec<u8> parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn encode(data: Vec<u8>) -> Result<Vec<u8>, Error> {
|
||||
let response = unsafe {
|
||||
codec_encode(Json(CodecEncodeRequest {
|
||||
data: data,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
88
plugins/cmd/ndpgen/testdata/codec_expected.go.txt
vendored
Normal file
88
plugins/cmd/ndpgen/testdata/codec_expected.go.txt
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
|
||||
package testpkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// CodecEncodeRequest is the request type for Codec.Encode.
|
||||
type CodecEncodeRequest struct {
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
// 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 JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
codecWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req CodecEncodeRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
codecWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
result, svcErr := service.Encode(ctx, req.Data)
|
||||
if svcErr != nil {
|
||||
codecWriteError(p, stack, svcErr)
|
||||
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user