feat(plugins): implement XTP JSONSchema validation for generated schemas

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-31 13:31:26 -05:00
parent e3bfcff8c4
commit ad9cda9d57
16 changed files with 727 additions and 50 deletions

2
.gitignore vendored
View File

@ -26,7 +26,7 @@ docker-compose.yml
!contrib/docker-compose.yml
binaries
navidrome-*
ndpgen
/ndpgen
AGENTS.md
.github/prompts
.github/instructions

View File

@ -105,6 +105,11 @@ wire: check_go_env ##@Development Update Dependency Injection
gen: check_go_env ##@Development Run go generate for code generation
go generate ./...
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities
go mod tidy -C plugins/pdk/go
.PHONY: gen
snapshots: ##@Development Update (GoLang) Snapshot tests

View File

@ -53,7 +53,4 @@
// }
//
// func Register(impl Scrobbler) { ... }
//
//go:generate go run ../cmd/ndpgen -capability-only -input=. -output=../pdk -go -rust
//go:generate go run ../cmd/ndpgen -schemas -input=.
package capabilities

View File

@ -68,7 +68,6 @@ components:
schemas:
AlbumImagesResponse:
description: AlbumImagesResponse is the response for GetAlbumImages.
type: object
properties:
images:
type: array
@ -79,7 +78,6 @@ components:
- images
AlbumInfoResponse:
description: AlbumInfoResponse is the response for GetAlbumInfo.
type: object
properties:
name:
type: string
@ -100,7 +98,6 @@ components:
- url
AlbumRequest:
description: AlbumRequest is the common request for album-related functions.
type: object
properties:
name:
type: string
@ -116,7 +113,6 @@ components:
- artist
ArtistBiographyResponse:
description: ArtistBiographyResponse is the response for GetArtistBiography.
type: object
properties:
biography:
type: string
@ -125,7 +121,6 @@ components:
- biography
ArtistImagesResponse:
description: ArtistImagesResponse is the response for GetArtistImages.
type: object
properties:
images:
type: array
@ -136,7 +131,6 @@ components:
- images
ArtistMBIDRequest:
description: ArtistMBIDRequest is the request for GetArtistMBID.
type: object
properties:
id:
type: string
@ -149,7 +143,6 @@ components:
- name
ArtistMBIDResponse:
description: ArtistMBIDResponse is the response for GetArtistMBID.
type: object
properties:
mbid:
type: string
@ -158,7 +151,6 @@ components:
- mbid
ArtistRef:
description: ArtistRef is a reference to an artist with name and optional MBID.
type: object
properties:
name:
type: string
@ -170,7 +162,6 @@ components:
- name
ArtistRequest:
description: ArtistRequest is the common request for artist-related functions.
type: object
properties:
id:
type: string
@ -186,7 +177,6 @@ components:
- name
ArtistURLResponse:
description: ArtistURLResponse is the response for GetArtistURL.
type: object
properties:
url:
type: string
@ -195,7 +185,6 @@ components:
- url
ImageInfo:
description: ImageInfo represents an image with URL and size.
type: object
properties:
url:
type: string
@ -209,7 +198,6 @@ components:
- size
SimilarArtistsRequest:
description: SimilarArtistsRequest is the request for GetSimilarArtists.
type: object
properties:
id:
type: string
@ -230,7 +218,6 @@ components:
- limit
SimilarArtistsResponse:
description: SimilarArtistsResponse is the response for GetSimilarArtists.
type: object
properties:
artists:
type: array
@ -241,7 +228,6 @@ components:
- artists
SongRef:
description: SongRef is a reference to a song with name and optional MBID.
type: object
properties:
name:
type: string
@ -253,7 +239,6 @@ components:
- name
TopSongsRequest:
description: TopSongsRequest is the request for GetArtistTopSongs.
type: object
properties:
id:
type: string
@ -274,7 +259,6 @@ components:
- count
TopSongsResponse:
description: TopSongsResponse is the response for GetArtistTopSongs.
type: object
properties:
songs:
type: array

View File

@ -11,7 +11,6 @@ components:
schemas:
SchedulerCallbackRequest:
description: SchedulerCallbackRequest is the request provided when a scheduled task fires.
type: object
properties:
scheduleId:
type: string

View File

@ -22,7 +22,6 @@ components:
schemas:
IsAuthorizedRequest:
description: IsAuthorizedRequest is the request for authorization check.
type: object
properties:
userId:
type: string
@ -35,7 +34,6 @@ components:
- username
NowPlayingRequest:
description: NowPlayingRequest is the request for now playing notification.
type: object
properties:
userId:
type: string
@ -57,7 +55,6 @@ components:
- position
ScrobbleRequest:
description: ScrobbleRequest is the request for submitting a scrobble.
type: object
properties:
userId:
type: string
@ -79,7 +76,6 @@ components:
- timestamp
TrackInfo:
description: TrackInfo contains track metadata for scrobbling.
type: object
properties:
id:
type: string

View File

@ -24,7 +24,6 @@ components:
schemas:
OnBinaryMessageRequest:
description: OnBinaryMessageRequest is the request provided when a binary message is received.
type: object
properties:
connectionId:
type: string
@ -37,7 +36,6 @@ components:
- data
OnCloseRequest:
description: OnCloseRequest is the request provided when a WebSocket connection is closed.
type: object
properties:
connectionId:
type: string
@ -57,7 +55,6 @@ components:
- reason
OnErrorRequest:
description: OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
type: object
properties:
connectionId:
type: string
@ -70,7 +67,6 @@ components:
- error
OnTextMessageRequest:
description: OnTextMessageRequest is the request provided when a text message is received.
type: object
properties:
connectionId:
type: string

23
plugins/cmd/ndpgen/go.mod Normal file
View File

@ -0,0 +1,23 @@
module github.com/navidrome/navidrome/plugins/cmd/ndpgen
go 1.25
require (
github.com/onsi/ginkgo/v2 v2.22.2
github.com/onsi/gomega v1.36.2
github.com/xeipuuv/gojsonschema v1.2.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.28.0 // indirect
)

42
plugins/cmd/ndpgen/go.sum Normal file
View File

@ -0,0 +1,42 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -27,13 +27,13 @@ var _ = Describe("ndpgen CLI", Ordered, func() {
)
BeforeAll(func() {
// Set testdata directory
testdataDir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "ndpgen", "testdata")
// Set testdata directory (relative to ndpgen root)
testdataDir = filepath.Join(mustGetWd(GinkgoT()), "testdata")
// Build the ndpgen binary
ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test")
cmd := exec.Command("go", "build", "-o", ndpgenBin, ".")
cmd.Dir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "ndpgen")
cmd.Dir = mustGetWd(GinkgoT())
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Failed to build ndpgen: %s", output)
DeferCleanup(func() {
@ -512,13 +512,19 @@ func mustGetWd(t FullGinkgoTInterface) string {
if err != nil {
t.Fatal(err)
}
// Look for ndpgen's own go.mod (the subproject root)
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
goModPath := filepath.Join(dir, "go.mod")
if _, err := os.Stat(goModPath); err == nil {
// Check if this is the ndpgen go.mod by reading it
content, err := os.ReadFile(goModPath)
if err == nil && strings.Contains(string(content), "plugins/cmd/ndpgen") {
return dir
}
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not find project root")
t.Fatal("could not find ndpgen project root")
}
dir = parent
}

View File

@ -32,12 +32,10 @@ type (
}
// 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).
// Per the XTP JSON Schema, ObjectSchema has properties, required, and description
// but NOT a type field.
xtpObjectSchema struct {
Description string `yaml:"description,omitempty"`
Type string `yaml:"type"`
Properties yaml.Node `yaml:"properties"`
Required []string `yaml:"required,omitempty"`
}
@ -203,7 +201,6 @@ func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool,
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},
}

View File

@ -0,0 +1,549 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"version": {
"$ref": "#/$defs/XtpVersion"
}
},
"required": [
"version"
],
"allOf": [
{
"if": {
"properties": {
"version": {
"const": "v0"
}
}
},
"then": {
"properties": {
"exports": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-zA-Z_$][a-zA-Z0-9_$]*$"
}
},
"version": {
"const": "v0"
}
},
"required": [
"exports"
],
"additionalProperties": false
}
},
{
"if": {
"properties": {
"version": {
"const": "v1-draft"
}
}
},
"then": {
"properties": {
"version": {
"$ref": "#/$defs/XtpVersion"
},
"exports": {
"type": "object",
"patternProperties": {
"^[a-zA-Z_$][a-zA-Z0-9_$]*$": {
"$ref": "#/$defs/Export"
}
},
"additionalProperties": false
},
"imports": {
"type": "object",
"patternProperties": {
"^[a-zA-Z_$][a-zA-Z0-9_$]*$": {
"$ref": "#/$defs/Import"
}
},
"additionalProperties": false
},
"components": {
"type": "object",
"properties": {
"schemas": {
"type": "object",
"patternProperties": {
"^[a-zA-Z_$][a-zA-Z0-9_$]*$": {
"$ref": "#/$defs/Schema"
}
},
"additionalProperties": false
}
},
"required": [
"schemas"
],
"additionalProperties": false
}
},
"required": [
"exports"
],
"additionalProperties": false
}
}
],
"$defs": {
"XtpVersion": {
"type": "string",
"enum": [
"v0",
"v1-draft"
]
},
"Export": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"codeSamples": {
"type": "array",
"items": {
"$ref": "#/$defs/CodeSample"
}
},
"input": {
"$ref": "#/$defs/Parameter"
},
"output": {
"$ref": "#/$defs/Parameter"
}
},
"additionalProperties": false
},
"CodeSample": {
"type": "object",
"properties": {
"lang": {
"anyOf": [
{
"type": "string",
"enum": [
"typescript",
"csharp",
"zig",
"rust",
"go",
"python",
"c++"
]
},
{
"type": "string"
}
]
},
"source": {
"type": "string"
},
"label": {
"type": "string"
}
},
"required": [
"lang",
"source"
],
"additionalProperties": false
},
"Import": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"input": {
"$ref": "#/$defs/Parameter"
},
"output": {
"$ref": "#/$defs/Parameter"
}
},
"additionalProperties": false
},
"Schema": {
"oneOf": [
{
"$ref": "#/$defs/ObjectSchema"
},
{
"$ref": "#/$defs/EnumSchema"
}
]
},
"ObjectSchema": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"properties": {
"type": "object",
"patternProperties": {
"^[a-zA-Z_$][a-zA-Z0-9_$]*$": {
"$ref": "#/$defs/Property"
}
},
"additionalProperties": false
},
"required": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"properties"
],
"additionalProperties": false
},
"EnumSchema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"string"
]
},
"description": {
"type": "string"
},
"enum": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-zA-Z_$][a-zA-Z0-9_$]*$"
}
}
},
"required": [
"enum"
],
"additionalProperties": false
},
"Parameter": {
"oneOf": [
{
"$ref": "#/$defs/ValueParameter"
},
{
"$ref": "#/$defs/RefParameter"
},
{
"$ref": "#/$defs/MapParameter"
}
]
},
"RefParameter": {
"type": "object",
"properties": {
"$ref": {
"$ref": "#/$defs/SchemaReference"
},
"description": {
"type": "string"
},
"nullable": {
"type": "boolean",
"default": false
},
"contentType": {
"$ref": "#/$defs/ContentType"
}
},
"required": [
"$ref",
"contentType"
],
"additionalProperties": false
},
"ValueParameter": {
"type": "object",
"properties": {
"contentType": {
"$ref": "#/$defs/ContentType"
},
"type": {
"$ref": "#/$defs/XtpType"
},
"format": {
"$ref": "#/$defs/XtpFormat"
},
"nullable": {
"type": "boolean",
"default": false
},
"description": {
"type": "string"
},
"items": {
"type": "object",
"$ref": "#/$defs/ArrayItem"
}
},
"required": [
"type",
"contentType"
],
"additionalProperties": false
},
"MapParameter": {
"type": "object",
"properties": {
"type": {
"const": "object"
},
"description": {
"type": "string"
},
"additionalProperties": {
"allOf": [
{
"$ref": "#/$defs/NonMapProperty"
},
{
"type": "object",
"properties": {
"description": false
},
"additionalProperties": false
}
]
},
"nullable": {
"type": "boolean",
"default": false
},
"contentType": {
"$ref": "#/$defs/ContentType"
}
},
"required": [
"additionalProperties",
"contentType"
]
},
"NonMapProperty": {
"oneOf": [
{
"$ref": "#/$defs/ValueProperty"
},
{
"$ref": "#/$defs/RefProperty"
}
]
},
"Property": {
"oneOf": [
{
"$ref": "#/$defs/ValueProperty"
},
{
"$ref": "#/$defs/RefProperty"
},
{
"$ref": "#/$defs/MapProperty"
}
]
},
"ValueProperty": {
"type": "object",
"properties": {
"type": {
"$ref": "#/$defs/XtpType"
},
"format": {
"$ref": "#/$defs/XtpFormat"
},
"nullable": {
"type": "boolean",
"default": false
},
"description": {
"type": "string"
},
"items": {
"type": "object",
"$ref": "#/$defs/ArrayItem"
}
},
"required": [
"type"
],
"additionalProperties": false
},
"MapProperty": {
"type": "object",
"properties": {
"type": {
"const": "object"
},
"description": {
"type": "string"
},
"additionalProperties": {
"allOf": [
{
"$ref": "#/$defs/NonMapProperty"
},
{
"not": {
"type": "object",
"required": ["description"]
}
}
]
},
"nullable": {
"type": "boolean",
"default": false
}
},
"required": [
"additionalProperties"
],
"additionalProperties": false
},
"RefProperty": {
"type": "object",
"properties": {
"$ref": {
"$ref": "#/$defs/SchemaReference"
},
"description": {
"type": "string"
},
"nullable": {
"type": "boolean",
"default": false
}
},
"required": [
"$ref"
],
"additionalProperties": false
},
"ContentType": {
"type": "string",
"enum": [
"application/json",
"application/x-binary",
"text/plain; charset=utf-8"
]
},
"SchemaReference": {
"type": "string",
"pattern": "^#/components/schemas/[^/]+$"
},
"XtpType": {
"type": "string",
"enum": [
"integer",
"string",
"number",
"boolean",
"object",
"array",
"buffer"
]
},
"XtpFormat": {
"type": "string",
"enum": [
"int32",
"int64",
"float",
"double",
"date-time",
"byte"
]
},
"ArrayItem": {
"type": "object",
"oneOf": [
{
"$ref": "#/$defs/ValueArrayItem"
},
{
"$ref": "#/$defs/RefArrayItem"
},
{
"$ref": "#/$defs/MapArrayItem"
}
]
},
"ValueArrayItem": {
"type": "object",
"properties": {
"type": {
"$ref": "#/$defs/XtpType"
},
"format": {
"$ref": "#/$defs/XtpFormat"
},
"nullable": {
"type": "boolean"
}
},
"required": [
"type"
],
"additionalProperties": false
},
"RefArrayItem": {
"type": "object",
"properties": {
"$ref": {
"$ref": "#/$defs/SchemaReference"
},
"nullable": {
"type": "boolean",
"default": false
}
},
"required": [
"$ref"
],
"additionalProperties": false
},
"MapArrayItem": {
"type": "object",
"properties": {
"type": {
"const": "object"
},
"additionalProperties": {
"allOf": [
{
"$ref": "#/$defs/NonMapProperty"
},
{
"not": {
"type": "object",
"required": ["description"]
}
}
]
}
},
"required": [
"additionalProperties"
],
"additionalProperties": false
}
}
}

View File

@ -54,6 +54,10 @@ var _ = Describe("XTP Schema Generation", func() {
Expect(schema).NotTo(BeEmpty())
})
It("should validate against XTP JSONSchema", func() {
Expect(ValidateXTPSchema(schema)).To(Succeed())
})
It("should have correct version", func() {
doc := parseSchema(schema)
Expect(doc["version"]).To(Equal("v1-draft"))
@ -80,7 +84,8 @@ var _ = Describe("XTP Schema Generation", func() {
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["TestInput"].(map[string]any)
Expect(input["type"]).To(Equal("object")) // Workaround for XTP code generator
// Per XTP spec, ObjectSchema does NOT have a type field - only properties, required, description
Expect(input).NotTo(HaveKey("type"))
props := input["properties"].(map[string]any)
Expect(props).To(HaveKey("name"))
Expect(props).To(HaveKey("count"))
@ -128,6 +133,10 @@ var _ = Describe("XTP Schema Generation", func() {
Expect(err).NotTo(HaveOccurred())
})
It("should validate against XTP JSONSchema", func() {
Expect(ValidateXTPSchema(schema)).To(Succeed())
})
It("should not mark required field as nullable", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
@ -202,6 +211,10 @@ var _ = Describe("XTP Schema Generation", func() {
Expect(err).NotTo(HaveOccurred())
})
It("should validate against XTP JSONSchema", func() {
Expect(ValidateXTPSchema(schema)).To(Succeed())
})
It("should define enum type with correct values", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
@ -261,6 +274,10 @@ var _ = Describe("XTP Schema Generation", func() {
Expect(err).NotTo(HaveOccurred())
})
It("should validate against XTP JSONSchema", func() {
Expect(ValidateXTPSchema(schema)).To(Succeed())
})
It("should define string array with primitive type", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
@ -324,6 +341,9 @@ var _ = Describe("XTP Schema Generation", func() {
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
// Validate against XTP JSONSchema
Expect(ValidateXTPSchema(schema)).To(Succeed())
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
@ -395,7 +415,7 @@ var _ = Describe("XTP Schema Generation", func() {
}
Context("export with primitive string output", func() {
It("should use type instead of $ref", func() {
It("should use type instead of $ref and validate against XTP JSONSchema", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
@ -407,6 +427,7 @@ var _ = Describe("XTP Schema Generation", func() {
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(schema).NotTo(BeEmpty())
Expect(ValidateXTPSchema(schema)).To(Succeed())
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
@ -419,7 +440,7 @@ var _ = Describe("XTP Schema Generation", func() {
})
Context("export with primitive bool output", func() {
It("should use boolean type", func() {
It("should use boolean type and validate against XTP JSONSchema", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
@ -430,6 +451,7 @@ var _ = Describe("XTP Schema Generation", func() {
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
@ -441,7 +463,7 @@ var _ = Describe("XTP Schema Generation", func() {
})
Context("export with primitive int output", func() {
It("should use integer type", func() {
It("should use integer type and validate against XTP JSONSchema", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
@ -452,6 +474,7 @@ var _ = Describe("XTP Schema Generation", func() {
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
@ -463,7 +486,7 @@ var _ = Describe("XTP Schema Generation", func() {
})
Context("export with pointer to primitive output", func() {
It("should strip pointer and use primitive type", func() {
It("should strip pointer and use primitive type and validate against XTP JSONSchema", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
@ -474,6 +497,7 @@ var _ = Describe("XTP Schema Generation", func() {
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
@ -485,7 +509,7 @@ var _ = Describe("XTP Schema Generation", func() {
})
Context("export with struct output", func() {
It("should still use $ref", func() {
It("should still use $ref and validate against XTP JSONSchema", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
@ -499,6 +523,7 @@ var _ = Describe("XTP Schema Generation", func() {
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
@ -539,6 +564,7 @@ var _ = Describe("XTP Schema Generation", func() {
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
schemas := getSchemas(schema)
Expect(schemas).To(HaveKey("UsedInput"))
@ -561,6 +587,7 @@ var _ = Describe("XTP Schema Generation", func() {
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
schemas := getSchemas(schema)
Expect(schemas).To(HaveKey("Input"))
@ -583,6 +610,7 @@ var _ = Describe("XTP Schema Generation", func() {
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
schemas := getSchemas(schema)
Expect(schemas).To(HaveKey("Input"))
@ -605,6 +633,7 @@ var _ = Describe("XTP Schema Generation", func() {
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
schemas := getSchemas(schema)
Expect(schemas).To(HaveKey("Input"))
@ -625,6 +654,7 @@ var _ = Describe("XTP Schema Generation", func() {
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
schemas := getSchemas(schema)
Expect(schemas).To(HaveKey("Input"))
@ -672,6 +702,7 @@ var _ = Describe("XTP Schema Generation", func() {
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
doc := parseSchema(schema)
components := doc["components"].(map[string]any)

View File

@ -0,0 +1,51 @@
package internal
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
"github.com/xeipuuv/gojsonschema"
"gopkg.in/yaml.v3"
)
// XTP JSONSchema specification, from
// https://raw.githubusercontent.com/dylibso/xtp-bindgen/5090518dd86ba5e734dc225a33066ecc0ed2e12d/plugin/schema.json
//
//go:embed xtp_schema.json
var xtpSchemaJSON string
// ValidateXTPSchema validates that the generated schema conforms to the XTP JSONSchema specification.
// Returns nil if valid, or an error with validation details if invalid.
func ValidateXTPSchema(generatedSchema []byte) error {
// Parse the YAML schema to JSON for validation
var schemaDoc map[string]any
if err := yaml.Unmarshal(generatedSchema, &schemaDoc); err != nil {
return fmt.Errorf("failed to parse generated schema as YAML: %w", err)
}
// Convert to JSON for the validator
jsonBytes, err := json.Marshal(schemaDoc)
if err != nil {
return fmt.Errorf("failed to convert schema to JSON: %w", err)
}
schemaLoader := gojsonschema.NewStringLoader(xtpSchemaJSON)
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
if err != nil {
return fmt.Errorf("schema validation failed: %w", err)
}
if !result.Valid() {
var errs []string
for _, desc := range result.Errors() {
errs = append(errs, fmt.Sprintf("- %s", desc))
}
return fmt.Errorf("schema validation errors:\n%s", strings.Join(errs, "\n"))
}
return nil
}

View File

@ -792,6 +792,11 @@ func generateSchemaFile(cap internal.Capability, outputDir string, dryRun, verbo
return fmt.Errorf("generating schema: %w", err)
}
// Validate the generated schema against XTP JSONSchema spec
if err := internal.ValidateXTPSchema(schema); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Schema validation for %s:\n%s\n", cap.Name, err)
}
// Use the source file name: websocket_callback.go -> websocket_callback.yaml
schemaFile := filepath.Join(outputDir, cap.SourceFile+".yaml")

View File

@ -36,8 +36,4 @@
//
// Generated files follow the pattern <servicename>_gen.go and include a header comment
// indicating they should not be edited manually.
//
//go:generate go run ../cmd/ndpgen -host-wrappers -input=. -package=host
//go:generate go run ../cmd/ndpgen -input=. -output=../pdk -go -python -rust
//go:generate go mod tidy -C ../pdk/go
package host