diff --git a/plugins/capabilities/README.md b/plugins/capabilities/README.md new file mode 100644 index 000000000..40743a17f --- /dev/null +++ b/plugins/capabilities/README.md @@ -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) diff --git a/plugins/capabilities/lifecycle.yaml b/plugins/capabilities/lifecycle.yaml new file mode 100644 index 000000000..0ac0da1e9 --- /dev/null +++ b/plugins/capabilities/lifecycle.yaml @@ -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 diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml new file mode 100644 index 000000000..31a61a3ab --- /dev/null +++ b/plugins/capabilities/metadata_agent.yaml @@ -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 diff --git a/plugins/capabilities/scheduler_callback.yaml b/plugins/capabilities/scheduler_callback.yaml new file mode 100644 index 000000000..d818739f2 --- /dev/null +++ b/plugins/capabilities/scheduler_callback.yaml @@ -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 diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml new file mode 100644 index 000000000..6fd088d47 --- /dev/null +++ b/plugins/capabilities/scrobbler.yaml @@ -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 diff --git a/plugins/capabilities/websocket_callback.yaml b/plugins/capabilities/websocket_callback.yaml new file mode 100644 index 000000000..b8d740a4a --- /dev/null +++ b/plugins/capabilities/websocket_callback.yaml @@ -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 diff --git a/plugins/cmd/hostgen/hostgen_suite_test.go b/plugins/cmd/hostgen/hostgen_suite_test.go deleted file mode 100644 index a5ab094cd..000000000 --- a/plugins/cmd/hostgen/hostgen_suite_test.go +++ /dev/null @@ -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") -} diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 9f420b32d..e83407475 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -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 diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index ddf5e4acb..0ac4f6852 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -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). diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go new file mode 100644 index 000000000..ced42dcb1 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -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")) +} diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go new file mode 100644 index 000000000..c1115b6c7 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -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) + }) + } +} diff --git a/plugins/cmd/ndpgen/main.go b/plugins/cmd/ndpgen/main.go index 2908f10c5..61c9b8879 100644 --- a/plugins/cmd/ndpgen/main.go +++ b/plugins/cmd/ndpgen/main.go @@ -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// (e.g., $output/go/metadata/) +// - Schemas: $input/.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 +}