mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-03 06:15:22 +00:00
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:
parent
2e716ed780
commit
8a453cb22c
85
plugins/capabilities/README.md
Normal file
85
plugins/capabilities/README.md
Normal 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)
|
||||
33
plugins/capabilities/lifecycle.yaml
Normal file
33
plugins/capabilities/lifecycle.yaml
Normal 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
|
||||
291
plugins/capabilities/metadata_agent.yaml
Normal file
291
plugins/capabilities/metadata_agent.yaml
Normal 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
|
||||
46
plugins/capabilities/scheduler_callback.yaml
Normal file
46
plugins/capabilities/scheduler_callback.yaml
Normal 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
|
||||
178
plugins/capabilities/scrobbler.yaml
Normal file
178
plugins/capabilities/scrobbler.yaml
Normal 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
|
||||
135
plugins/capabilities/websocket_callback.yaml
Normal file
135
plugins/capabilities/websocket_callback.yaml
Normal 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
|
||||
@ -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")
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
261
plugins/cmd/ndpgen/internal/xtp_schema.go
Normal file
261
plugins/cmd/ndpgen/internal/xtp_schema.go
Normal 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"))
|
||||
}
|
||||
373
plugins/cmd/ndpgen/internal/xtp_schema_test.go
Normal file
373
plugins/cmd/ndpgen/internal/xtp_schema_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user