feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 4

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-30 00:08:13 -05:00
parent 2e716ed780
commit 8a453cb22c
12 changed files with 1496 additions and 22 deletions

View File

@ -0,0 +1,85 @@
# 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. **XTP YAML schemas** (`*.yaml`) - Schema files for non-Go plugin developers
## For Go Plugin Developers
Go developers should use the generated PDK packages in `plugins/pdk/go/`. See the example 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.
## Schema Generation
The YAML schemas are automatically generated from the Go interfaces using `ndpgen`:
```bash
go run ./plugins/cmd/ndpgen -schemas -input=./plugins/capabilities
```
### Technical Note: XTP Schema Compatibility
The generated schemas include `type: object` on object schemas. While this is technically not valid according to the [XTP JSON Schema specification](https://raw.githubusercontent.com/dylibso/xtp-bindgen/5090518dd86ba5e734dc225a33066ecc0ed2e12d/plugin/schema.json), it is **required** as a workaround for XTP's code generator to properly resolve type information (especially for structs with empty properties). XTP tolerates this with a validation warning but generates correct code.
## 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)

View File

@ -0,0 +1,33 @@
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.
The output can contain an error string if initialization failed, which will be
logged but will not prevent the plugin from being loaded.
input:
$ref: '#/components/schemas/OnInitInput'
contentType: application/json
output:
$ref: '#/components/schemas/OnInitOutput'
contentType: application/json
components:
schemas:
OnInitInput:
description: |-
OnInitInput is the input provided to the init callback.
Currently empty, reserved for future use.
type: object
properties: {}
OnInitOutput:
description: OnInitOutput is the output from the init callback.
type: object
properties:
error:
type: string
description: |-
Error is the error message if initialization failed.
Empty or null indicates success.
The error is logged but does not prevent the plugin from being loaded.
nullable: true

View File

@ -0,0 +1,291 @@
version: v1-draft
exports:
nd_get_artist_mbid:
description: GetArtistMBID retrieves the MusicBrainz ID for an artist.
input:
$ref: '#/components/schemas/ArtistMBIDInput'
contentType: application/json
output:
$ref: '#/components/schemas/ArtistMBIDOutput'
contentType: application/json
nd_get_artist_url:
description: GetArtistURL retrieves the external URL for an artist.
input:
$ref: '#/components/schemas/ArtistInput'
contentType: application/json
output:
$ref: '#/components/schemas/ArtistURLOutput'
contentType: application/json
nd_get_artist_biography:
description: GetArtistBiography retrieves the biography for an artist.
input:
$ref: '#/components/schemas/ArtistInput'
contentType: application/json
output:
$ref: '#/components/schemas/ArtistBiographyOutput'
contentType: application/json
nd_get_similar_artists:
description: GetSimilarArtists retrieves similar artists for a given artist.
input:
$ref: '#/components/schemas/SimilarArtistsInput'
contentType: application/json
output:
$ref: '#/components/schemas/SimilarArtistsOutput'
contentType: application/json
nd_get_artist_images:
description: GetArtistImages retrieves images for an artist.
input:
$ref: '#/components/schemas/ArtistInput'
contentType: application/json
output:
$ref: '#/components/schemas/ArtistImagesOutput'
contentType: application/json
nd_get_artist_top_songs:
description: GetArtistTopSongs retrieves top songs for an artist.
input:
$ref: '#/components/schemas/TopSongsInput'
contentType: application/json
output:
$ref: '#/components/schemas/TopSongsOutput'
contentType: application/json
nd_get_album_info:
description: GetAlbumInfo retrieves album information.
input:
$ref: '#/components/schemas/AlbumInput'
contentType: application/json
output:
$ref: '#/components/schemas/AlbumInfoOutput'
contentType: application/json
nd_get_album_images:
description: GetAlbumImages retrieves images for an album.
input:
$ref: '#/components/schemas/AlbumInput'
contentType: application/json
output:
$ref: '#/components/schemas/AlbumImagesOutput'
contentType: application/json
components:
schemas:
AlbumImagesOutput:
description: AlbumImagesOutput is the output for GetAlbumImages.
type: object
properties:
images:
type: array
description: Images is the list of album images.
items:
$ref: '#/components/schemas/ImageInfo'
required:
- images
AlbumInfoOutput:
description: AlbumInfoOutput is the output for GetAlbumInfo.
type: object
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
AlbumInput:
description: AlbumInput is the common input for album-related functions.
type: object
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).
nullable: true
required:
- name
- artist
ArtistBiographyOutput:
description: ArtistBiographyOutput is the output for GetArtistBiography.
type: object
properties:
biography:
type: string
description: Biography is the artist biography text.
required:
- biography
ArtistImagesOutput:
description: ArtistImagesOutput is the output for GetArtistImages.
type: object
properties:
images:
type: array
description: Images is the list of artist images.
items:
$ref: '#/components/schemas/ImageInfo'
required:
- images
ArtistInput:
description: ArtistInput is the common input for artist-related functions.
type: object
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).
nullable: true
required:
- id
- name
ArtistMBIDInput:
description: ArtistMBIDInput is the input for GetArtistMBID.
type: object
properties:
id:
type: string
description: ID is the internal Navidrome artist ID.
name:
type: string
description: Name is the artist name.
required:
- id
- name
ArtistMBIDOutput:
description: ArtistMBIDOutput is the output for GetArtistMBID.
type: object
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.
type: object
properties:
name:
type: string
description: Name is the artist name.
mbid:
type: string
description: MBID is the MusicBrainz ID for the artist.
nullable: true
required:
- name
ArtistURLOutput:
description: ArtistURLOutput is the output for GetArtistURL.
type: object
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.
type: object
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
SimilarArtistsInput:
description: SimilarArtistsInput is the input for GetSimilarArtists.
type: object
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).
nullable: true
limit:
type: integer
format: int32
description: Limit is the maximum number of similar artists to return.
required:
- id
- name
- limit
SimilarArtistsOutput:
description: SimilarArtistsOutput is the output for GetSimilarArtists.
type: object
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.
type: object
properties:
name:
type: string
description: Name is the song name.
mbid:
type: string
description: MBID is the MusicBrainz ID for the song.
nullable: true
required:
- name
TopSongsInput:
description: TopSongsInput is the input for GetArtistTopSongs.
type: object
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).
nullable: true
count:
type: integer
format: int32
description: Count is the maximum number of top songs to return.
required:
- id
- name
- count
TopSongsOutput:
description: TopSongsOutput is the output for GetArtistTopSongs.
type: object
properties:
songs:
type: array
description: Songs is the list of top songs.
items:
$ref: '#/components/schemas/SongRef'
required:
- songs

View File

@ -0,0 +1,46 @@
version: v1-draft
exports:
nd_scheduler_callback:
description: OnSchedulerCallback is called when a scheduled task fires.
input:
$ref: '#/components/schemas/SchedulerCallbackInput'
contentType: application/json
output:
$ref: '#/components/schemas/SchedulerCallbackOutput'
contentType: application/json
components:
schemas:
SchedulerCallbackInput:
description: SchedulerCallbackInput is the input provided when a scheduled task fires.
type: object
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
SchedulerCallbackOutput:
description: SchedulerCallbackOutput is the output from the scheduler callback.
type: object
properties:
error:
type: string
description: |-
Error is the error message if the callback failed to process the scheduled task.
Empty or null indicates success. The error is logged but does not
affect the scheduling system.
nullable: true

View File

@ -0,0 +1,178 @@
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/AuthInput'
contentType: application/json
output:
$ref: '#/components/schemas/AuthOutput'
contentType: application/json
nd_scrobbler_now_playing:
description: NowPlaying sends a now playing notification to the scrobbling service.
input:
$ref: '#/components/schemas/NowPlayingInput'
contentType: application/json
output:
$ref: '#/components/schemas/ScrobblerOutput'
contentType: application/json
nd_scrobbler_scrobble:
description: Scrobble submits a completed scrobble to the scrobbling service.
input:
$ref: '#/components/schemas/ScrobbleInput'
contentType: application/json
output:
$ref: '#/components/schemas/ScrobblerOutput'
contentType: application/json
components:
schemas:
AuthInput:
description: AuthInput is the input for authorization check.
type: object
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
AuthOutput:
description: AuthOutput is the output for authorization check.
type: object
properties:
authorized:
type: boolean
description: Authorized indicates whether the user is authorized to scrobble.
required:
- authorized
NowPlayingInput:
description: NowPlayingInput is the input for now playing notification.
type: object
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
ScrobbleInput:
description: ScrobbleInput is the input for submitting a scrobble.
type: object
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
ScrobblerOutput:
description: ScrobblerOutput is the output for scrobbler operations.
type: object
properties:
error:
type: string
description: Error is the error message if the operation failed.
nullable: true
errorType:
$ref: '#/components/schemas/ScrobblerErrorType'
description: ErrorType indicates how Navidrome should handle the error.
nullable: true
TrackInfo:
description: TrackInfo contains track metadata for scrobbling.
type: object
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.
nullable: true
mbzAlbumId:
type: string
description: MBZAlbumID is the MusicBrainz album/release ID.
nullable: true
mbzArtistId:
type: string
description: MBZArtistID is the MusicBrainz artist ID.
nullable: true
mbzReleaseGroupId:
type: string
description: MBZReleaseGroupID is the MusicBrainz release group ID.
nullable: true
mbzAlbumArtistId:
type: string
description: MBZAlbumArtistID is the MusicBrainz album artist ID.
nullable: true
mbzReleaseTrackId:
type: string
description: MBZReleaseTrackID is the MusicBrainz release track ID.
nullable: true
required:
- id
- title
- album
- artist
- albumArtist
- duration
- trackNumber
- discNumber
ScrobblerErrorType:
description: ScrobblerErrorType indicates how Navidrome should handle scrobbler errors.
type: string
enum:
- none
- not_authorized
- retry_later
- unrecoverable

View File

@ -0,0 +1,135 @@
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/OnTextMessageInput'
contentType: application/json
output:
$ref: '#/components/schemas/OnTextMessageOutput'
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/OnBinaryMessageInput'
contentType: application/json
output:
$ref: '#/components/schemas/OnBinaryMessageOutput'
contentType: application/json
nd_websocket_on_error:
description: OnError is called when an error occurs on a WebSocket connection.
input:
$ref: '#/components/schemas/OnErrorInput'
contentType: application/json
output:
$ref: '#/components/schemas/OnErrorOutput'
contentType: application/json
nd_websocket_on_close:
description: OnClose is called when a WebSocket connection is closed.
input:
$ref: '#/components/schemas/OnCloseInput'
contentType: application/json
output:
$ref: '#/components/schemas/OnCloseOutput'
contentType: application/json
components:
schemas:
OnBinaryMessageInput:
description: OnBinaryMessageInput is the input provided when a binary message is received.
type: object
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
OnBinaryMessageOutput:
description: OnBinaryMessageOutput is the output from the binary message handler.
type: object
properties:
error:
type: string
description: |-
Error is the error message if the callback failed.
Empty or null indicates success.
nullable: true
OnCloseInput:
description: OnCloseInput is the input provided when a WebSocket connection is closed.
type: object
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
OnCloseOutput:
description: OnCloseOutput is the output from the close handler.
type: object
properties:
error:
type: string
description: |-
Error is the error message if the callback failed.
Empty or null indicates success.
nullable: true
OnErrorInput:
description: OnErrorInput is the input provided when an error occurs on a WebSocket connection.
type: object
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
OnErrorOutput:
description: OnErrorOutput is the output from the error handler.
type: object
properties:
error:
type: string
description: |-
Error is the error message if the callback failed.
Empty or null indicates success.
nullable: true
OnTextMessageInput:
description: OnTextMessageInput is the input provided when a text message is received.
type: object
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
OnTextMessageOutput:
description: OnTextMessageOutput is the output from the text message handler.
type: object
properties:
error:
type: string
description: |-
Error is the error message if the callback failed.
Empty or null indicates success.
nullable: true

View File

@ -1,13 +0,0 @@
package main
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestHostgen(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Hostgen CLI Suite")
}

View File

@ -135,11 +135,16 @@ func parseCapabilityFile(fset *token.FileSet, path string) ([]Capability, error)
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),
Name: capAnnotation["name"],
Interface: typeSpec.Name.Name,
Required: capAnnotation["required"] == "true",
Doc: cleanDoc(docText),
SourceFile: sourceFile,
}
// Parse methods and collect referenced types

View File

@ -25,6 +25,7 @@ type Capability struct {
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).

View File

@ -0,0 +1,261 @@
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"`
ContentType string `yaml:"contentType"`
}
// xtpObjectSchema represents an object schema in XTP.
// Note: The Type field is technically not valid per the XTP JSON Schema,
// but is required as a workaround for XTP's code generator to properly
// resolve type information (especially for empty structs).
xtpObjectSchema struct {
Description string `yaml:"description,omitempty"`
Type string `yaml:"type"`
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/" + export.Input.Type,
ContentType: "application/json",
}
}
if export.Output.Type != "" {
e.Output = &xtpIOParam{
Ref: "#/components/schemas/" + export.Output.Type,
ContentType: "application/json",
}
}
return e
}
func buildSchemas(cap Capability) yaml.Node {
schemas := yaml.Node{Kind: yaml.MappingNode}
knownTypes := cap.KnownStructs()
for _, alias := range cap.TypeAliases {
knownTypes[alias.Name] = true
}
// Sort structs by name for consistent output
structNames := make([]string, 0, len(cap.Structs))
structMap := make(map[string]StructDef)
for _, st := range cap.Structs {
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
for _, alias := range cap.TypeAliases {
if alias.Type == "string" {
for _, cg := range cap.Consts {
if cg.Type == alias.Name {
addToMap(&schemas, alias.Name, buildEnumSchema(alias, cg))
break
}
}
}
}
return schemas
}
func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema {
schema := xtpObjectSchema{
Description: cleanDocForYAML(st.Doc),
Type: "object", // Required workaround for XTP code generator
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"))
}

View File

@ -0,0 +1,373 @@
package internal
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestGenerateSchema(t *testing.T) {
tests := []struct {
name string
capability Capability
wantErr bool
validate func(t *testing.T, schema []byte)
}{
{
name: "basic capability with one export",
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"},
},
},
},
},
validate: func(t *testing.T, schema []byte) {
var doc map[string]any
require.NoError(t, yaml.Unmarshal(schema, &doc))
// Check version
assert.Equal(t, "v1-draft", doc["version"])
// Check exports
exports := doc["exports"].(map[string]any)
assert.Contains(t, exports, "test_method")
method := exports["test_method"].(map[string]any)
assert.Equal(t, "Test method does something", method["description"])
// Check schemas
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
assert.Contains(t, schemas, "TestInput")
assert.Contains(t, schemas, "TestOutput")
// Check TestInput schema
input := schemas["TestInput"].(map[string]any)
assert.Equal(t, "object", input["type"]) // Workaround for XTP code generator
props := input["properties"].(map[string]any)
assert.Contains(t, props, "name")
assert.Contains(t, props, "count")
// Check required fields (non-pointer, non-omitempty)
required := input["required"].([]any)
assert.Contains(t, required, "name")
assert.Contains(t, required, "count")
},
},
{
name: "capability with pointer fields (nullable)",
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"},
},
},
},
},
validate: func(t *testing.T, schema []byte) {
var doc map[string]any
require.NoError(t, yaml.Unmarshal(schema, &doc))
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
// Required field is not nullable
requiredField := props["required"].(map[string]any)
assert.NotContains(t, requiredField, "nullable")
// Optional pointer field is nullable
optionalField := props["optional"].(map[string]any)
assert.Equal(t, true, optionalField["nullable"])
// Check required array only has non-pointer fields
required := input["required"].([]any)
assert.Contains(t, required, "required")
assert.NotContains(t, required, "optional")
},
},
{
name: "capability with enum",
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"`},
},
},
},
},
validate: func(t *testing.T, schema []byte) {
var doc map[string]any
require.NoError(t, yaml.Unmarshal(schema, &doc))
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
// Check enum is defined
assert.Contains(t, schemas, "Status")
status := schemas["Status"].(map[string]any)
assert.Equal(t, "string", status["type"])
enum := status["enum"].([]any)
assert.ElementsMatch(t, []any{"pending", "active", "done"}, enum)
// Check $ref in Input
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
statusRef := props["status"].(map[string]any)
assert.Equal(t, "#/components/schemas/Status", statusRef["$ref"])
},
},
{
name: "capability with array types",
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"},
},
},
},
},
validate: func(t *testing.T, schema []byte) {
var doc map[string]any
require.NoError(t, yaml.Unmarshal(schema, &doc))
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
// Check string array
tags := props["tags"].(map[string]any)
assert.Equal(t, "array", tags["type"])
tagItems := tags["items"].(map[string]any)
assert.Equal(t, "string", tagItems["type"])
// Check struct array (uses $ref)
items := props["items"].(map[string]any)
assert.Equal(t, "array", items["type"])
itemItems := items["items"].(map[string]any)
assert.Equal(t, "#/components/schemas/Item", itemItems["$ref"])
},
},
{
name: "capability with nullable ref",
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"`},
},
},
},
},
validate: func(t *testing.T, schema []byte) {
var doc map[string]any
require.NoError(t, yaml.Unmarshal(schema, &doc))
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
output := schemas["Output"].(map[string]any)
props := output["properties"].(map[string]any)
// Pointer to enum type should have $ref AND nullable
status := props["status"].(map[string]any)
assert.Equal(t, "#/components/schemas/ErrorType", status["$ref"])
assert.Equal(t, true, status["nullable"])
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema, err := GenerateSchema(tt.capability)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.NotEmpty(t, schema)
if tt.validate != nil {
tt.validate(t, schema)
}
})
}
}
func TestGoTypeToXTPTypeAndFormat(t *testing.T) {
tests := []struct {
goType string
wantType string
wantFormat string
}{
{"string", "string", ""},
{"int", "integer", "int32"},
{"int32", "integer", "int32"},
{"int64", "integer", "int64"},
{"float32", "number", "float"},
{"float64", "number", "float"},
{"bool", "boolean", ""},
{"[]byte", "string", "byte"},
// Unknown types default to object
{"CustomType", "object", ""},
}
for _, tt := range tests {
t.Run(tt.goType, func(t *testing.T) {
gotType, gotFormat := goTypeToXTPTypeAndFormat(tt.goType)
assert.Equal(t, tt.wantType, gotType)
assert.Equal(t, tt.wantFormat, gotFormat)
})
}
}
func TestCleanDocForYAML(t *testing.T) {
tests := []struct {
name string
doc string
want string
}{
{
name: "empty",
doc: "",
want: "",
},
{
name: "single line",
doc: "Simple description",
want: "Simple description",
},
{
name: "multiline",
doc: "First line\nSecond line",
want: "First line\nSecond line",
},
{
name: "trailing newline",
doc: "Description\n",
want: "Description",
},
{
name: "whitespace",
doc: " Description ",
want: "Description",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cleanDocForYAML(tt.doc)
assert.Equal(t, tt.want, got)
})
}
}

View File

@ -11,9 +11,13 @@
// # 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 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:
//
@ -22,6 +26,7 @@
// -package Output package name for Go (default: host for host-only, auto for capabilities)
// -host-only Generate only host function wrappers
// -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)
@ -50,6 +55,7 @@ type config struct {
pkgName string
hostOnly bool
capabilityOnly bool
schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir)
generateGoClient bool
generatePyClient bool
generateRsClient bool
@ -64,6 +70,14 @@ func main() {
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)
@ -104,6 +118,22 @@ func runCapabilityGeneration(cfg *config) error {
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)
}
// parseConfig parses command-line flags and returns the configuration.
func parseConfig() (*config, error) {
var (
@ -112,6 +142,7 @@ func parseConfig() (*config, error) {
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")
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")
@ -120,14 +151,26 @@ func parseConfig() (*config, error) {
)
flag.Parse()
// Default to host-only if neither mode is specified
if !*hostOnly && !*capabilityOnly {
// Count how many mode flags are specified
modeCount := 0
if *hostOnly {
modeCount++
}
if *capabilityOnly {
modeCount++
}
if *schemasOnly {
modeCount++
}
// Default to host-only if no mode is specified
if modeCount == 0 {
*hostOnly = true
}
// Cannot specify both modes
if *hostOnly && *capabilityOnly {
return nil, fmt.Errorf("cannot specify both -host-only and -capability-only")
// Cannot specify multiple modes
if modeCount > 1 {
return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -capability-only, -schemas)")
}
if *outputDir == "" {
@ -169,6 +212,7 @@ func parseConfig() (*config, error) {
pkgName: *pkgName,
hostOnly: *hostOnly,
capabilityOnly: *capabilityOnly,
schemasOnly: *schemasOnly,
generateGoClient: *goClient || !anyLangFlag,
generatePyClient: *pyClient,
generateRsClient: *rsClient,
@ -584,3 +628,38 @@ func generateGoModFile(outputDir string, dryRun, verbose bool) error {
}
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)
}
// 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
}