diff --git a/Makefile b/Makefile
index e303017c7..90a405de8 100644
--- a/Makefile
+++ b/Makefile
@@ -113,10 +113,11 @@ wire: check_go_env ##@Development Update Dependency Injection
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
+ cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
+ cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
+ cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types
+ cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types
+ cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types
go mod tidy -C plugins/pdk/go
.PHONY: gen
diff --git a/plugins/README.md b/plugins/README.md
index 048cf549d..b9118d36f 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -1030,8 +1030,6 @@ extism-py plugin.wasm -o plugin.wasm *.py
zip -j my-plugin.ndp manifest.json plugin.wasm
```
-**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations).
-
### Using XTP CLI (Scaffolding)
Bootstrap a new plugin from a schema:
diff --git a/plugins/capabilities/README.md b/plugins/capabilities/README.md
index fca3cbd31..2ad4a82da 100644
--- a/plugins/capabilities/README.md
+++ b/plugins/capabilities/README.md
@@ -76,7 +76,7 @@ The YAML schemas in this package are automatically generated from the capability
To regenerate the schemas after modifying the interfaces, run:
```bash
-cd plugins/cmd/ndpgen && go run . -schemas -input=./plugins/capabilities
+cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types
```
## Resources
diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml
index 04dd283dd..a64897509 100644
--- a/plugins/capabilities/lyrics.yaml
+++ b/plugins/capabilities/lyrics.yaml
@@ -9,20 +9,6 @@ exports:
contentType: application/json
components:
schemas:
- ArtistRef:
- description: ArtistRef is a reference to an artist with name and optional MBID.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome artist ID (if known).
- name:
- type: string
- description: Name is the artist name.
- mbid:
- type: string
- description: MBID is the MusicBrainz ID for the artist.
- required:
- - name
GetLyricsRequest:
description: GetLyricsRequest contains the track information for lyrics lookup.
properties:
@@ -124,3 +110,21 @@ components:
- duration
- trackNumber
- discNumber
+ ArtistRef:
+ description: |-
+ ArtistRef is the minimal information a plugin returns for Navidrome to match an
+ artist against the library. It is a reference, not a full artist entity: it
+ carries only matching keys (name and optional internal/MusicBrainz IDs), never
+ descriptive data such as biographies or images.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID (if known).
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist.
+ required:
+ - name
diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go
index 935594abd..f856562c6 100644
--- a/plugins/capabilities/metadata_agent.go
+++ b/plugins/capabilities/metadata_agent.go
@@ -1,5 +1,7 @@
package capabilities
+import "github.com/navidrome/navidrome/plugins/types"
+
// 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.
@@ -102,10 +104,13 @@ type SimilarArtistsRequest struct {
Limit int32 `json:"limit"`
}
+// Deprecated: use types.ArtistRef.
+type ArtistRef = types.ArtistRef
+
// SimilarArtistsResponse is the response for GetSimilarArtists.
type SimilarArtistsResponse struct {
// Artists is the list of similar artists.
- Artists []ArtistRef `json:"artists"`
+ Artists []types.ArtistRef `json:"artists"`
}
// ImageInfo represents an image with URL and size.
@@ -134,34 +139,13 @@ type TopSongsRequest struct {
Count int32 `json:"count"`
}
-// SongRef is a reference to a song with metadata for matching.
-type SongRef struct {
- // ID is the internal Navidrome mediafile ID (if known).
- ID string `json:"id,omitempty"`
- // Name is the song name.
- Name string `json:"name"`
- // MBID is the MusicBrainz ID for the song.
- MBID string `json:"mbid,omitempty"`
- // ISRC is the International Standard Recording Code for the song.
- ISRC string `json:"isrc,omitempty"`
- // Artist is the artist name.
- Artist string `json:"artist,omitempty"`
- // ArtistMBID is the MusicBrainz artist ID.
- ArtistMBID string `json:"artistMbid,omitempty"`
- // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching.
- Artists []ArtistRef `json:"artists,omitempty"`
- // Album is the album name.
- Album string `json:"album,omitempty"`
- // AlbumMBID is the MusicBrainz release ID.
- AlbumMBID string `json:"albumMbid,omitempty"`
- // Duration is the song duration in seconds.
- Duration float32 `json:"duration,omitempty"`
-}
+// Deprecated: use types.SongRef.
+type SongRef = types.SongRef
// TopSongsResponse is the response for GetArtistTopSongs.
type TopSongsResponse struct {
// Songs is the list of top songs.
- Songs []SongRef `json:"songs"`
+ Songs []types.SongRef `json:"songs"`
}
// AlbumRequest is the common request for album-related functions.
@@ -235,5 +219,5 @@ type SimilarSongsByArtistRequest struct {
// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
type SimilarSongsResponse struct {
// Songs is the list of similar songs.
- Songs []SongRef `json:"songs"`
+ Songs []types.SongRef `json:"songs"`
}
diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml
index c90ac1ce0..cae23eadc 100644
--- a/plugins/capabilities/metadata_agent.yaml
+++ b/plugins/capabilities/metadata_agent.yaml
@@ -173,20 +173,6 @@ components:
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:
- id:
- type: string
- description: ID is the internal Navidrome artist ID (if known).
- 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:
@@ -331,8 +317,61 @@ components:
$ref: '#/components/schemas/SongRef'
required:
- songs
+ 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
+ ArtistRef:
+ description: |-
+ ArtistRef is the minimal information a plugin returns for Navidrome to match an
+ artist against the library. It is a reference, not a full artist entity: it
+ carries only matching keys (name and optional internal/MusicBrainz IDs), never
+ descriptive data such as biographies or images.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID (if known).
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist.
+ required:
+ - name
SongRef:
- description: SongRef is a reference to a song with metadata for matching.
+ description: |-
+ SongRef is the minimal information exchanged between a plugin and Navidrome to
+ match a song. It is used both as input (a song Navidrome already has) and as
+ output (a song a plugin suggests, which may not be in the library yet). Unlike
+ Track, it is an abstract recording reference carrying only matching keys (IDs,
+ ISRC, and title/artist/album/duration) that Navidrome resolves to a library track.
properties:
id:
type: string
@@ -369,33 +408,3 @@ components:
description: Duration is the song duration in seconds.
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
diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go
index 4918d5e8f..c1c05839a 100644
--- a/plugins/capabilities/scrobbler.go
+++ b/plugins/capabilities/scrobbler.go
@@ -1,5 +1,7 @@
package capabilities
+import "github.com/navidrome/navidrome/plugins/types"
+
// 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.
@@ -32,16 +34,6 @@ type IsAuthorizedRequest struct {
Username string `json:"username"`
}
-// ArtistRef is a reference to an artist with name and optional MBID.
-type ArtistRef struct {
- // ID is the internal Navidrome artist ID (if known).
- ID string `json:"id,omitempty"`
- // Name is the artist name.
- Name string `json:"name"`
- // MBID is the MusicBrainz ID for the artist.
- MBID string `json:"mbid,omitempty"`
-}
-
// TrackInfo contains track metadata.
type TrackInfo struct {
// ID is the internal Navidrome track ID.
@@ -55,9 +47,9 @@ type TrackInfo struct {
// AlbumArtist is the formatted album artist name for display.
AlbumArtist string `json:"albumArtist"`
// Artists is the list of track artists.
- Artists []ArtistRef `json:"artists"`
+ Artists []types.ArtistRef `json:"artists"`
// AlbumArtists is the list of album artists.
- AlbumArtists []ArtistRef `json:"albumArtists"`
+ AlbumArtists []types.ArtistRef `json:"albumArtists"`
// Duration is the track duration in seconds.
Duration float32 `json:"duration"`
// TrackNumber is the track number on the album.
diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml
index 9d5cfed30..07af8ec7a 100644
--- a/plugins/capabilities/scrobbler.yaml
+++ b/plugins/capabilities/scrobbler.yaml
@@ -25,20 +25,6 @@ exports:
contentType: application/json
components:
schemas:
- ArtistRef:
- description: ArtistRef is a reference to an artist with name and optional MBID.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome artist ID (if known).
- name:
- type: string
- description: Name is the artist name.
- mbid:
- type: string
- description: MBID is the MusicBrainz ID for the artist.
- required:
- - name
IsAuthorizedRequest:
description: IsAuthorizedRequest is the request for authorization check.
properties:
@@ -194,3 +180,21 @@ components:
- duration
- trackNumber
- discNumber
+ ArtistRef:
+ description: |-
+ ArtistRef is the minimal information a plugin returns for Navidrome to match an
+ artist against the library. It is a reference, not a full artist entity: it
+ carries only matching keys (name and optional internal/MusicBrainz IDs), never
+ descriptive data such as biographies or images.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID (if known).
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist.
+ required:
+ - name
diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go
index aadb9396e..a35d9d923 100644
--- a/plugins/capabilities/sonic_similarity.go
+++ b/plugins/capabilities/sonic_similarity.go
@@ -1,5 +1,7 @@
package capabilities
+import "github.com/navidrome/navidrome/plugins/types"
+
// SonicSimilarity provides audio-similarity based track discovery.
//
//nd:capability name=sonicsimilarity required=true
@@ -12,14 +14,14 @@ type SonicSimilarity interface {
}
type GetSonicSimilarTracksRequest struct {
- Song SongRef `json:"song"`
- Count int32 `json:"count"`
+ Song types.SongRef `json:"song"`
+ Count int32 `json:"count"`
}
type FindSonicPathRequest struct {
- StartSong SongRef `json:"startSong"`
- EndSong SongRef `json:"endSong"`
- Count int32 `json:"count"`
+ StartSong types.SongRef `json:"startSong"`
+ EndSong types.SongRef `json:"endSong"`
+ Count int32 `json:"count"`
}
type SonicSimilarityResponse struct {
@@ -27,6 +29,6 @@ type SonicSimilarityResponse struct {
}
type SonicMatch struct {
- Song SongRef `json:"song"`
- Similarity float64 `json:"similarity"`
+ Song types.SongRef `json:"song"`
+ Similarity float64 `json:"similarity"`
}
diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml
index 1d35e1e92..f0951fcec 100644
--- a/plugins/capabilities/sonic_similarity.yaml
+++ b/plugins/capabilities/sonic_similarity.yaml
@@ -16,20 +16,6 @@ exports:
contentType: application/json
components:
schemas:
- ArtistRef:
- description: ArtistRef is a reference to an artist with name and optional MBID.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome artist ID (if known).
- name:
- type: string
- description: Name is the artist name.
- mbid:
- type: string
- description: MBID is the MusicBrainz ID for the artist.
- required:
- - name
FindSonicPathRequest:
properties:
startSong:
@@ -53,8 +39,49 @@ components:
required:
- song
- count
+ SonicMatch:
+ properties:
+ song:
+ $ref: '#/components/schemas/SongRef'
+ similarity:
+ type: number
+ format: float
+ required:
+ - song
+ - similarity
+ SonicSimilarityResponse:
+ properties:
+ matches:
+ type: array
+ items:
+ $ref: '#/components/schemas/SonicMatch'
+ required:
+ - matches
+ ArtistRef:
+ description: |-
+ ArtistRef is the minimal information a plugin returns for Navidrome to match an
+ artist against the library. It is a reference, not a full artist entity: it
+ carries only matching keys (name and optional internal/MusicBrainz IDs), never
+ descriptive data such as biographies or images.
+ properties:
+ id:
+ type: string
+ description: ID is the internal Navidrome artist ID (if known).
+ name:
+ type: string
+ description: Name is the artist name.
+ mbid:
+ type: string
+ description: MBID is the MusicBrainz ID for the artist.
+ required:
+ - name
SongRef:
- description: SongRef is a reference to a song with metadata for matching.
+ description: |-
+ SongRef is the minimal information exchanged between a plugin and Navidrome to
+ match a song. It is used both as input (a song Navidrome already has) and as
+ output (a song a plugin suggests, which may not be in the library yet). Unlike
+ Track, it is an abstract recording reference carrying only matching keys (IDs,
+ ISRC, and title/artist/album/duration) that Navidrome resolves to a library track.
properties:
id:
type: string
@@ -91,21 +118,3 @@ components:
description: Duration is the song duration in seconds.
required:
- name
- SonicMatch:
- properties:
- song:
- $ref: '#/components/schemas/SongRef'
- similarity:
- type: number
- format: float
- required:
- - song
- - similarity
- SonicSimilarityResponse:
- properties:
- matches:
- type: array
- items:
- $ref: '#/components/schemas/SonicMatch'
- required:
- - matches
diff --git a/plugins/cmd/ndpgen/README.md b/plugins/cmd/ndpgen/README.md
index d2f67a60c..7487db892 100644
--- a/plugins/cmd/ndpgen/README.md
+++ b/plugins/cmd/ndpgen/README.md
@@ -7,7 +7,7 @@ This tool is the unified code generator that handle both host function wrappers
## Usage
```bash
-ndpgen -input
-output [-package ] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust]
+ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-rust]
```
### Flags
@@ -21,10 +21,9 @@ ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only]
| `-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.
+\* `-go` is enabled by default when `-rust` is not specified. Use `-go -rust` to generate both languages.
### Example
@@ -150,10 +149,6 @@ func TestMyPluginFunction(t *testing.T) {
If you need to reset mock state between tests, testify's mock doesn't have a built-in reset. Either use separate test functions (testify automatically resets between test runs), or create a helper to set up fresh expectations.
-### 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.
diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go
index db500c1fc..d8bc5859d 100644
--- a/plugins/cmd/ndpgen/integration_test.go
+++ b/plugins/cmd/ndpgen/integration_test.go
@@ -176,16 +176,15 @@ type ServiceB interface {
Describe("code generation", func() {
DescribeTable("generates correct client output",
- func(serviceFile, goClientExpectedFile, pyClientExpectedFile, rsClientExpectedFile string) {
+ func(serviceFile, goClientExpectedFile, 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")
+ // Generate all client code (Go, Rust)
+ cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-rust")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
@@ -217,17 +216,6 @@ type ServiceB interface {
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)
@@ -251,39 +239,59 @@ type ServiceB interface {
},
Entry("simple string params",
- "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.py", "echo_client_expected.rs"),
+ "echo_service.go.txt", "echo_client_expected.go.txt", "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"),
+ "math_service.go.txt", "math_client_expected.go.txt", "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"),
+ "store_service.go.txt", "store_client_expected.go.txt", "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"),
+ "list_service.go.txt", "list_client_expected.go.txt", "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"),
+ "counter_service.go.txt", "counter_client_expected.go.txt", "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"),
+ "ping_service.go.txt", "ping_client_expected.go.txt", "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"),
+ "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.rs"),
Entry("pointer types",
- "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.py", "users_client_expected.rs"),
+ "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.rs"),
Entry("multiple returns",
- "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.py", "search_client_expected.rs"),
+ "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.rs"),
Entry("bytes",
- "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.py", "codec_client_expected.rs"),
+ "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.rs"),
Entry("option pattern (value, exists bool)",
- "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"),
+ "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.rs"),
)
+ It("generates the shared Go types package with -shared-types", func() {
+ typesSrc := `package types
+
+// ArtistRef references an artist.
+type ArtistRef struct {
+ ID string ` + "`json:\"id,omitempty\"`" + `
+ Name string ` + "`json:\"name\"`" + `
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "types.go"), []byte(typesSrc), 0600)).To(Succeed())
+ cmd := exec.Command(ndpgenBin, "-shared-types", "-input", testDir, "-output", outputDir, "-go")
+ out, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", out)
+
+ content, err := os.ReadFile(filepath.Join(outputDir, "go", "types", "types.go"))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(content)).To(ContainSubstring("package types"))
+ Expect(string(content)).To(ContainSubstring("type ArtistRef struct {"))
+ })
+
It("generates compilable client code for comprehensive service", func() {
serviceCode := readTestdata("comprehensive_service.go.txt")
@@ -386,119 +394,6 @@ var _ = ndpdk.ComprehensiveNoParams
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"{}"`))
- })
})
})
diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go
index 514da578b..50f53cd37 100644
--- a/plugins/cmd/ndpgen/internal/generator.go
+++ b/plugins/cmd/ndpgen/internal/generator.go
@@ -4,6 +4,7 @@ import (
"bytes"
"embed"
"fmt"
+ "slices"
"strings"
"text/template"
)
@@ -79,17 +80,6 @@ func mockAccessor(typ string, idx int) string {
}
}
-// 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")
@@ -186,51 +176,13 @@ func formatDoc(doc string) string {
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()
+ shared := make(map[string]string)
+ for _, a := range svc.SharedAliases {
+ shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix)
+ }
return template.FuncMap{
"lower": strings.ToLower,
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
@@ -238,9 +190,9 @@ func rustFuncMap(svc Service) template.FuncMap {
"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) },
+ "rustType": func(p Param) string { return p.RustTypeWithShared(knownStructs, shared) },
+ "rustParamType": func(p Param) string { return p.RustParamTypeWithShared(knownStructs, shared) },
+ "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) },
}
}
@@ -388,6 +340,18 @@ func indentText(n int, s string) string {
return strings.Join(lines, "\n")
}
+// indentSpaces adds n spaces to each non-empty line of text.
+func indentSpaces(spaces int, s string) string {
+ ind := strings.Repeat(" ", spaces)
+ lines := strings.Split(s, "\n")
+ for i, line := range lines {
+ if line != "" {
+ lines[i] = ind + 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 {
@@ -459,6 +423,10 @@ func GenerateCapabilityGoStub(cap Capability, pkgName string) ([]byte, error) {
// rustCapabilityFuncMap returns template functions for Rust capability code generation.
func rustCapabilityFuncMap(cap Capability) template.FuncMap {
knownStructs := cap.KnownStructs()
+ shared := make(map[string]string)
+ for _, a := range cap.SharedAliases {
+ shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix)
+ }
return template.FuncMap{
"rustDocComment": RustDocComment,
"rustTypeAlias": rustTypeAlias,
@@ -466,25 +434,25 @@ func rustCapabilityFuncMap(cap Capability) template.FuncMap {
"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,
+ "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) },
+ "rustOutputType": func(goType string) string { return rustTraitType(goType, shared) },
+ "rustMethodType": func(goType string) string { return rustMethodType(goType, cap.Name, shared) },
"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")
+ "rustSharedTarget": func(target string) string {
+ return "nd_pdk_types::" + strings.TrimPrefix(target, sharedTypesPrefix)
},
+ // rustSharedNote is the human-facing path for deprecation notes: plugin
+ // authors depend on the nd-pdk umbrella crate, which re-exports nd_pdk_types
+ // as `types`, so they reference these via nd_pdk::types::X.
+ "rustSharedNote": func(target string) string {
+ return "nd_pdk::types::" + strings.TrimPrefix(target, sharedTypesPrefix)
+ },
+ "snakeCase": ToSnakeCase,
+ "indent": indentSpaces,
}
}
@@ -526,6 +494,46 @@ func rustConstType(goType string) string {
// 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.
+// rustMethodType returns the fully-qualified Rust type for a capability method
+// input/output as referenced inside the generated export macro. The macro expands
+// in the downstream plugin crate, which depends on the umbrella nd-pdk crate and
+// not on nd-pdk-types directly, so shared types must be reached through $crate
+// (the defining nd-pdk-capabilities crate, which re-exports nd_pdk_types as
+// `types`) rather than by naming the transitive crate. Primitives map to their
+// Rust name; any other named type is a capability-local struct, qualified as
+// $crate::::X. This is used instead of hand-assembling
+// "$crate::::" + rustOutputType, which produced invalid paths like
+// "$crate::demo::types.SongRef" for shared types used directly in a signature.
+func rustMethodType(goType, pkg string, shared map[string]string) string {
+ goType = strings.TrimPrefix(goType, "*")
+ if isPrimitiveRustType(goType) {
+ return rustOutputType(goType)
+ }
+ if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok {
+ return "$crate::types::" + rest
+ }
+ if t, ok := shared[goType]; ok {
+ return "$crate::types::" + strings.TrimPrefix(t, "nd_pdk_types::")
+ }
+ return "$crate::" + ToSnakeCase(pkg) + "::" + goType
+}
+
+// rustTraitType returns the Rust type for a capability trait method signature.
+// The trait lives in the capability module alongside its local structs, so those
+// stay bare; shared types must still resolve to their nd_pdk_types::X crate path
+// (a shared type used directly in a signature would otherwise pass through as the
+// invalid Go selector "types.SongRef").
+func rustTraitType(goType string, shared map[string]string) string {
+ stripped := strings.TrimPrefix(goType, "*")
+ if rest, ok := strings.CutPrefix(stripped, sharedTypesPrefix); ok {
+ return "nd_pdk_types::" + rest
+ }
+ if t, ok := shared[stripped]; ok {
+ return t
+ }
+ return rustOutputType(goType)
+}
+
func rustOutputType(goType string) string {
// Strip pointer prefix - capability outputs use Result for optionality
if strings.HasPrefix(goType, "*") {
@@ -601,9 +609,9 @@ func skipSerializingFunc(goType string) string {
}
}
-// hasHashMap returns true if any struct in the capability uses HashMap.
-func hasHashMap(cap Capability) bool {
- for _, st := range cap.Structs {
+// anyFieldUsesHashMap returns true if any field in the given structs uses a map type.
+func anyFieldUsesHashMap(structs []StructDef) bool {
+ for _, st := range structs {
for _, f := range st.Fields {
if strings.HasPrefix(f.Type, "map[") {
return true
@@ -613,6 +621,32 @@ func hasHashMap(cap Capability) bool {
return false
}
+// anyFieldIsByteSlice reports whether any field across the given structs is a
+// []byte, which Go's JSON encoder serializes as a base64 string. The Rust
+// shared-types crate must match that with a base64_bytes serde override.
+func anyFieldIsByteSlice(structs []StructDef) bool {
+ for _, st := range structs {
+ for _, f := range st.Fields {
+ if f.IsByteSlice() {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// hasHashMap returns true if any struct in the capability uses HashMap.
+func hasHashMap(cap Capability) bool {
+ return anyFieldUsesHashMap(cap.Structs)
+}
+
+// sortedStructs returns a sorted copy of structs, ordered by name.
+func sortedStructs(structs []StructDef) []StructDef {
+ sorted := append([]StructDef(nil), structs...)
+ slices.SortFunc(sorted, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) })
+ return sorted
+}
+
// 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 {
@@ -669,6 +703,11 @@ func GenerateCapabilityRustLib(capabilities []Capability) ([]byte, error) {
buf.WriteString("//! This crate provides type definitions, traits, and registration macros\n")
buf.WriteString("//! for implementing Navidrome plugin capabilities in Rust.\n\n")
+ // Re-export the shared types so generated registration macros can reference them
+ // via $crate::types::X. The macro expands in the downstream plugin crate, which
+ // depends on the umbrella nd-pdk crate and not on nd-pdk-types directly.
+ buf.WriteString("pub use nd_pdk_types as types;\n\n")
+
// Module declarations
for _, cap := range capabilities {
moduleName := ToSnakeCase(cap.Name)
@@ -894,3 +933,70 @@ func GeneratePDKTypesStub(symbols *PDKSymbols) ([]byte, error) {
return buf.Bytes(), nil
}
+
+// GenerateSharedTypesRust generates the nd-pdk-types crate root (lib.rs).
+func GenerateSharedTypesRust(structs []StructDef) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/types.rs.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading types rust template: %w", err)
+ }
+ sorted := sortedStructs(structs)
+ known := map[string]bool{}
+ for _, s := range sorted {
+ known[s.Name] = true
+ }
+ tmpl, err := template.New("types_rs").Funcs(template.FuncMap{
+ "rustDocComment": RustDocComment,
+ "rustFieldName": func(n string) string { return ToSnakeCase(n) },
+ "fieldRustType": func(f FieldDef) string { return f.RustType(known) },
+ "skipSerializingFunc": skipSerializingFunc,
+ "indent": indentSpaces,
+ }).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading base64_bytes partial: %w", err)
+ }
+ tmpl, err = tmpl.Parse(string(partialContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing base64_bytes partial: %w", err)
+ }
+
+ data := struct {
+ Structs []StructDef
+ HasHashMap bool
+ HasByteFields bool
+ }{Structs: sorted, HasHashMap: anyFieldUsesHashMap(sorted), HasByteFields: anyFieldIsByteSlice(sorted)}
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+ return buf.Bytes(), nil
+}
+
+// GenerateSharedTypesGo generates the shared `types` package (plain data structs).
+func GenerateSharedTypesGo(structs []StructDef, pkgName string) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/types.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading types template: %w", err)
+ }
+ tmpl, err := template.New("types").Funcs(template.FuncMap{
+ "formatDoc": formatDoc,
+ "indent": indentText,
+ }).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+ data := struct {
+ Package string
+ Structs []StructDef
+ }{Package: pkgName, Structs: sortedStructs(structs)}
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+ return buf.Bytes(), nil
+}
diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go
index 052aa146c..d52d6dcbf 100644
--- a/plugins/cmd/ndpgen/internal/generator_test.go
+++ b/plugins/cmd/ndpgen/internal/generator_test.go
@@ -418,266 +418,19 @@ var _ = Describe("Generator", func() {
})
})
- Describe("Python type and name helpers", func() {
- Describe("ToPythonType", func() {
- It("should map Go types to Python types", func() {
- Expect(ToPythonType("string")).To(Equal("str"))
- Expect(ToPythonType("int")).To(Equal("int"))
- Expect(ToPythonType("int32")).To(Equal("int"))
- Expect(ToPythonType("int64")).To(Equal("int"))
- Expect(ToPythonType("float32")).To(Equal("float"))
- Expect(ToPythonType("float64")).To(Equal("float"))
- Expect(ToPythonType("bool")).To(Equal("bool"))
- Expect(ToPythonType("[]byte")).To(Equal("bytes"))
- Expect(ToPythonType("unknown")).To(Equal("Any"))
- })
+ Describe("ToSnakeCase", func() {
+ It("should convert PascalCase to snake_case", func() {
+ Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring"))
+ Expect(ToSnakeCase("GetString")).To(Equal("get_string"))
+ Expect(ToSnakeCase("simple")).To(Equal("simple"))
})
- Describe("ToSnakeCase", func() {
- It("should convert PascalCase to snake_case", func() {
- Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring"))
- Expect(ToSnakeCase("GetString")).To(Equal("get_string"))
- Expect(ToSnakeCase("simple")).To(Equal("simple"))
- })
-
- It("should handle acronyms correctly", func() {
- Expect(ToSnakeCase("ID")).To(Equal("id"))
- Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id"))
- Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id"))
- Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser"))
- Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response"))
- })
- })
-
- Describe("Method.PythonFunctionName", func() {
- It("should generate snake_case function name with service prefix", func() {
- m := Method{Name: "GetString"}
- Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string"))
- })
- })
-
- Describe("Param.PythonType", func() {
- It("should return Python type for parameter", func() {
- p := NewParam("value", "string")
- Expect(p.PythonType()).To(Equal("str"))
- })
- })
-
- Describe("Param.PythonName", func() {
- It("should return snake_case name for parameter", func() {
- p := NewParam("ttlSeconds", "int64")
- Expect(p.PythonName()).To(Equal("ttl_seconds"))
- })
- })
- })
-
- Describe("GenerateClientPython", func() {
- It("should generate valid Python code for a simple service", func() {
- svc := Service{
- Name: "SubsonicAPI",
- Permission: "subsonicapi",
- Interface: "SubsonicAPIService",
- Methods: []Method{
- {
- Name: "Call",
- HasError: true,
- Params: []Param{NewParam("uri", "string")},
- Returns: []Param{NewParam("responseJSON", "string")},
- },
- },
- }
-
- code, err := GenerateClientPython(svc)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check for generated header
- Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
-
- // Check for imports
- Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass"))
- Expect(codeStr).To(ContainSubstring("import extism"))
- Expect(codeStr).To(ContainSubstring("import json"))
-
- // Check for exception class
- Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):"))
-
- // Check for raw import function
- Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`))
- Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:"))
-
- // Check for wrapper function with type hints
- Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:"))
-
- // Check for error handling
- Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response["))
- })
-
- It("should generate dataclass for multi-value returns", func() {
- svc := Service{
- Name: "Cache",
- Permission: "cache",
- Interface: "CacheService",
- Methods: []Method{
- {
- Name: "GetString",
- HasError: true,
- Params: []Param{NewParam("key", "string")},
- Returns: []Param{
- NewParam("value", "string"),
- NewParam("exists", "bool"),
- },
- },
- },
- }
-
- code, err := GenerateClientPython(svc)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check for dataclass
- Expect(codeStr).To(ContainSubstring("@dataclass"))
- Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:"))
- Expect(codeStr).To(ContainSubstring("value: str"))
- Expect(codeStr).To(ContainSubstring("exists: bool"))
-
- // Check that function returns dataclass
- Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
- Expect(codeStr).To(ContainSubstring("return CacheGetStringResult("))
- })
-
- It("should handle methods with no parameters", func() {
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "NoParams",
- HasError: true,
- Returns: []Param{NewParam("result", "string")},
- },
- },
- }
-
- code, err := GenerateClientPython(svc)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Function with no params
- Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:"))
- // Empty request
- Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`))
- })
-
- It("should handle methods with no return values", func() {
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "NoReturn",
- HasError: true,
- Params: []Param{NewParam("input", "string")},
- },
- },
- }
-
- code, err := GenerateClientPython(svc)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Function returns None
- Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:"))
- })
-
- It("should generate correct Python defaults for different types", func() {
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "AllTypes",
- HasError: true,
- Returns: []Param{
- NewParam("strVal", "string"),
- NewParam("intVal", "int64"),
- NewParam("floatVal", "float64"),
- NewParam("boolVal", "bool"),
- },
- },
- },
- }
-
- code, err := GenerateClientPython(svc)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check defaults in response.get() calls
- Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`))
- Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`))
- Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
- Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
- })
-
- It("should not import base64 for non-byte services", func() {
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "Call",
- HasError: true,
- Params: []Param{NewParam("uri", "string")},
- Returns: []Param{NewParam("response", "string")},
- },
- },
- }
-
- code, err := GenerateClientPython(svc)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- Expect(codeStr).NotTo(ContainSubstring("import base64"))
- })
-
- It("should generate base64 encoding/decoding for byte fields", func() {
- svc := Service{
- Name: "Codec",
- Permission: "codec",
- Interface: "CodecService",
- Methods: []Method{
- {
- Name: "Encode",
- HasError: true,
- Params: []Param{NewParam("data", "[]byte")},
- Returns: []Param{NewParam("result", "[]byte")},
- },
- },
- }
-
- code, err := GenerateClientPython(svc)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Should import base64
- Expect(codeStr).To(ContainSubstring("import base64"))
-
- // Should base64-encode byte params in request
- Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`))
-
- // Should base64-decode byte returns in response
- Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`))
+ It("should handle acronyms correctly", func() {
+ Expect(ToSnakeCase("ID")).To(Equal("id"))
+ Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id"))
+ Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id"))
+ Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser"))
+ Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response"))
})
})
@@ -1186,6 +939,73 @@ type TestService interface {
Expect(codeStr).To(ContainSubstring("ScrobblerErrorRetry ScrobblerErrorType ="))
Expect(codeStr).To(ContainSubstring(`"retry"`))
})
+
+ It("emits a deprecated alias and types import for a shared-aliased capability", func() {
+ cap := Capability{
+ Name: "scrobbler",
+ Interface: "Scrobbler",
+ Required: true,
+ Methods: []Export{{
+ Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing",
+ Input: Param{Name: "input", Type: "NowPlayingRequest"},
+ }},
+ Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{
+ {Name: "Track", Type: "TrackInfo", JSONTag: "track"},
+ }}},
+ SharedAliases: []SharedAlias{{
+ Name: "TrackInfo", Target: "types.TrackInfo",
+ Doc: "Deprecated: use types.TrackInfo.",
+ Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{{Name: "Title", Type: "string", JSONTag: "title"}}},
+ }},
+ }
+ code, err := GenerateCapabilityGo(cap, "scrobbler")
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`))
+ Expect(out).To(ContainSubstring("// Deprecated: use types.TrackInfo."))
+ Expect(out).To(ContainSubstring("type TrackInfo = types.TrackInfo"))
+ Expect(out).NotTo(ContainSubstring("type TrackInfo struct"))
+ })
+
+ It("emits the types import for a direct types.X field with no deprecated alias", func() {
+ cap := Capability{
+ Name: "scrobbler",
+ Interface: "Scrobbler",
+ Required: true,
+ Methods: []Export{{
+ Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing",
+ Input: Param{Name: "input", Type: "NowPlayingRequest"},
+ }},
+ Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{
+ {Name: "Song", Type: "types.SongRef", JSONTag: "song"},
+ }}},
+ // No SharedAliases: the field references the canonical type directly.
+ }
+ code, err := GenerateCapabilityGo(cap, "scrobbler")
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`))
+ Expect(out).To(ContainSubstring("types.SongRef"))
+ })
+
+ It("emits the types import for a direct types.X method input/output", func() {
+ cap := Capability{
+ Name: "demo",
+ Interface: "Demo",
+ Required: true,
+ Methods: []Export{{
+ Name: "Lookup", ExportName: "nd_demo_lookup",
+ Input: Param{Name: "input", Type: "types.SongRef"},
+ Output: Param{Name: "output", Type: "types.SongRef"},
+ }},
+ // No structs, no aliases: the method signature references the shared type directly.
+ }
+ code, err := GenerateCapabilityGo(cap, "demo")
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`))
+ Expect(out).To(ContainSubstring("types.SongRef"))
+ })
})
Describe("GenerateCapabilityGoStub", func() {
@@ -1287,6 +1107,55 @@ type OnInitOutput struct {
})
})
+var _ = Describe("Shared Types Generation", func() {
+ It("emits a Rust types crate root with serde derives", func() {
+ structs := []StructDef{
+ {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{
+ {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true},
+ {Name: "Name", Type: "string", JSONTag: "name"},
+ }},
+ }
+ code, err := GenerateSharedTypesRust(structs)
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ Expect(out).To(ContainSubstring("use serde::{Deserialize, Serialize};"))
+ Expect(out).To(ContainSubstring("pub struct ArtistRef {"))
+ Expect(out).To(ContainSubstring(`#[serde(rename_all = "camelCase")]`))
+ Expect(out).To(ContainSubstring("pub name: String,"))
+ })
+
+ It("emits a flat Go types package with no imports", func() {
+ structs := []StructDef{
+ {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{
+ {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true},
+ {Name: "Name", Type: "string", JSONTag: "name"},
+ }},
+ }
+ code, err := GenerateSharedTypesGo(structs, "types")
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ Expect(out).To(ContainSubstring("package types"))
+ Expect(out).To(ContainSubstring("type ArtistRef struct {"))
+ Expect(out).To(ContainSubstring("ID string `json:\"id,omitempty\"`"))
+ Expect(out).To(ContainSubstring("Name string `json:\"name\"`"))
+ Expect(out).NotTo(ContainSubstring("import"))
+ })
+
+ It("emits base64 serde for Vec fields in the Rust types crate", func() {
+ structs := []StructDef{
+ {Name: "Payload", Doc: "Payload carries raw bytes.", Fields: []FieldDef{
+ {Name: "Data", Type: "[]byte", JSONTag: "data"},
+ }},
+ }
+ code, err := GenerateSharedTypesRust(structs)
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ Expect(out).To(ContainSubstring("mod base64_bytes"))
+ Expect(out).To(ContainSubstring("use base64::Engine as _"))
+ Expect(out).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
+ })
+})
+
var _ = Describe("Rust Generation", func() {
Describe("skipSerializingFunc", func() {
It("should return Option::is_none for pointer types", func() {
@@ -1486,6 +1355,109 @@ var _ = Describe("Rust Generation", func() {
Expect(codeStr).NotTo(ContainSubstring("Option<"))
})
+ It("translates a shared type used directly as a method input/output", func() {
+ cap := Capability{
+ Name: "demo", Interface: "Demo", Required: true,
+ Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo",
+ Input: Param{Name: "input", Type: "types.SongRef"},
+ Output: Param{Name: "output", Type: "types.SongRef"}}},
+ // No structs, no aliases: the method signature references the shared type directly.
+ }
+ code, err := GenerateCapabilityRust(cap)
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ // The shared type must resolve to the canonical crate path, not pass through
+ // as the invalid Go selector `types.SongRef`.
+ Expect(out).To(ContainSubstring("nd_pdk_types::SongRef"))
+ Expect(out).NotTo(ContainSubstring("types.SongRef"))
+ })
+
+ It("routes shared macro types through $crate so umbrella-crate plugins resolve them", func() {
+ cap := Capability{
+ Name: "demo", Interface: "Demo", Required: true,
+ Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo",
+ Input: Param{Name: "input", Type: "types.SongRef"},
+ Output: Param{Name: "output", Type: "types.SongRef"}}},
+ }
+ code, err := GenerateCapabilityRust(cap)
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ // Inside the export macro (expanded in the downstream plugin crate, which depends
+ // on the umbrella nd-pdk only), the shared type must be reachable via $crate, not
+ // by naming the transitive nd_pdk_types crate directly.
+ Expect(out).To(ContainSubstring("extism_pdk::Json<$crate::types::SongRef>"))
+ Expect(out).NotTo(ContainSubstring("extism_pdk::Json"))
+ })
+
+ It("emits a deprecated Rust type alias for shared types", func() {
+ cap := Capability{
+ Name: "scrobbler", Interface: "Scrobbler", Required: true,
+ Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing",
+ Input: Param{Name: "input", Type: "NowPlayingRequest"}}},
+ Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{
+ {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}},
+ SharedAliases: []SharedAlias{{
+ Name: "TrackInfo", Target: "types.TrackInfo",
+ Doc: "Deprecated: use types.TrackInfo.",
+ }},
+ }
+ code, err := GenerateCapabilityRust(cap)
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ // Note points authors at the umbrella path (nd-pdk re-exports nd_pdk_types as `types`);
+ // the alias target stays the real crate path so it resolves inside nd-pdk-capabilities.
+ Expect(out).To(ContainSubstring(`#[deprecated(note = "use nd_pdk::types::TrackInfo")]`))
+ Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::TrackInfo;"))
+ })
+
+ It("keeps struct-field type when the type name is a shared alias (regression: was serde_json::Value)", func() {
+ // Wrapper has a field whose type is only in SharedAliases, not Structs.
+ // The field must render as `pub track: nd_pdk_types::TrackInfo` (canonical
+ // path), not as the local deprecated alias and not as serde_json::Value.
+ cap := Capability{
+ Name: "test", Interface: "TestAgent", Required: true,
+ Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit",
+ Input: Param{Name: "req", Type: "Wrapper"}}},
+ Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{
+ {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}},
+ SharedAliases: []SharedAlias{{
+ Name: "TrackInfo", Target: "types.TrackInfo",
+ Doc: "Deprecated: use types.TrackInfo.",
+ }},
+ }
+ code, err := GenerateCapabilityRust(cap)
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ // Field must use the canonical nd_pdk_types:: path, not the local alias.
+ Expect(out).To(ContainSubstring("nd_pdk_types::TrackInfo"))
+ Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value"))
+ Expect(out).NotTo(ContainSubstring("pub track: TrackInfo,"))
+ })
+
+ It("renders a qualified types.X field as nd_pdk_types::X and keeps the renamed re-export", func() {
+ // The capability references the shared type by its canonical qualified
+ // name (types.Track) while the deprecated alias keeps the old name.
+ cap := Capability{
+ Name: "test", Interface: "TestAgent", Required: true,
+ Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit",
+ Input: Param{Name: "req", Type: "Wrapper"}}},
+ Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{
+ {Name: "Track", Type: "types.Track", JSONTag: "track"}}}},
+ SharedAliases: []SharedAlias{{
+ Name: "TrackInfo", Target: "types.Track",
+ Doc: "Deprecated: use types.Track.",
+ }},
+ }
+ code, err := GenerateCapabilityRust(cap)
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ // Field uses the canonical qualified path (resolved from the types. prefix).
+ Expect(out).To(ContainSubstring("pub track: nd_pdk_types::Track,"))
+ // The deprecated alias is still re-exported under its kept name.
+ Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::Track;"))
+ Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value"))
+ })
+
It("should include all float types correctly", func() {
cap := Capability{
Name: "test",
@@ -1637,6 +1609,36 @@ var _ = Describe("Rust Generation", func() {
Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
})
+ It("resolves a shared alias used in a method param/return to its canonical crate path", func() {
+ svc := Service{
+ Name: "Matcher",
+ Permission: "matcher",
+ Interface: "MatcherService",
+ Methods: []Method{
+ {
+ Name: "MatchSongs",
+ HasError: true,
+ Params: []Param{NewParam("query", "string")},
+ // Return uses the deprecated alias name directly.
+ Returns: []Param{NewParam("matches", "[]Track")},
+ },
+ },
+ SharedAliases: []SharedAlias{{
+ Name: "Track", Target: "types.Track",
+ Def: StructDef{Name: "Track", Fields: []FieldDef{
+ {Name: "Title", Type: "string", JSONTag: "title"}}},
+ }},
+ }
+
+ code, err := GenerateClientRust(svc)
+ Expect(err).NotTo(HaveOccurred())
+ out := string(code)
+ // The alias must resolve to the shared crate type; a bare `Track` is undefined
+ // in nd-pdk-host and would not compile.
+ Expect(out).To(ContainSubstring("nd_pdk_types::Track"))
+ Expect(out).NotTo(ContainSubstring("Vec