From ad9cda9d576279a972e284f01c8a5062401228e0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 31 Dec 2025 13:31:26 -0500 Subject: [PATCH] feat(plugins): implement XTP JSONSchema validation for generated schemas Signed-off-by: Deluan --- .gitignore | 2 +- Makefile | 5 + plugins/capabilities/doc.go | 3 - plugins/capabilities/metadata_agent.yaml | 16 - plugins/capabilities/scheduler_callback.yaml | 1 - plugins/capabilities/scrobbler.yaml | 4 - plugins/capabilities/websocket_callback.yaml | 4 - plugins/cmd/ndpgen/go.mod | 23 + plugins/cmd/ndpgen/go.sum | 42 ++ plugins/cmd/ndpgen/integration_test.go | 18 +- plugins/cmd/ndpgen/internal/xtp_schema.go | 7 +- plugins/cmd/ndpgen/internal/xtp_schema.json | 549 ++++++++++++++++++ .../cmd/ndpgen/internal/xtp_schema_test.go | 43 +- .../ndpgen/internal/xtp_schema_validate.go | 51 ++ plugins/cmd/ndpgen/main.go | 5 + plugins/host/doc.go | 4 - 16 files changed, 727 insertions(+), 50 deletions(-) create mode 100644 plugins/cmd/ndpgen/go.mod create mode 100644 plugins/cmd/ndpgen/go.sum create mode 100644 plugins/cmd/ndpgen/internal/xtp_schema.json create mode 100644 plugins/cmd/ndpgen/internal/xtp_schema_validate.go diff --git a/.gitignore b/.gitignore index dcd3eaf45..8f9bbdae9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ docker-compose.yml !contrib/docker-compose.yml binaries navidrome-* -ndpgen +/ndpgen AGENTS.md .github/prompts .github/instructions diff --git a/Makefile b/Makefile index 32c36dadf..78a7c33bb 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/plugins/capabilities/doc.go b/plugins/capabilities/doc.go index 27e9e1a98..fa9b7eb5d 100644 --- a/plugins/capabilities/doc.go +++ b/plugins/capabilities/doc.go @@ -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 diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index 79da13e93..969afc4e8 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -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 diff --git a/plugins/capabilities/scheduler_callback.yaml b/plugins/capabilities/scheduler_callback.yaml index a7a73cf00..9a081cd08 100644 --- a/plugins/capabilities/scheduler_callback.yaml +++ b/plugins/capabilities/scheduler_callback.yaml @@ -11,7 +11,6 @@ components: schemas: SchedulerCallbackRequest: description: SchedulerCallbackRequest is the request provided when a scheduled task fires. - type: object properties: scheduleId: type: string diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index f50c2c665..d708c44e6 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -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 diff --git a/plugins/capabilities/websocket_callback.yaml b/plugins/capabilities/websocket_callback.yaml index e80c792c6..401c77bb4 100644 --- a/plugins/capabilities/websocket_callback.yaml +++ b/plugins/capabilities/websocket_callback.yaml @@ -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 diff --git a/plugins/cmd/ndpgen/go.mod b/plugins/cmd/ndpgen/go.mod new file mode 100644 index 000000000..ff5fb77ee --- /dev/null +++ b/plugins/cmd/ndpgen/go.mod @@ -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 +) diff --git a/plugins/cmd/ndpgen/go.sum b/plugins/cmd/ndpgen/go.sum new file mode 100644 index 000000000..5a011b24d --- /dev/null +++ b/plugins/cmd/ndpgen/go.sum @@ -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= diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index e0a18d65f..a491fdfc1 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -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 } diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index 6bef85f45..200e72adb 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -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}, } diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.json b/plugins/cmd/ndpgen/internal/xtp_schema.json new file mode 100644 index 000000000..8b3fbe0b9 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/xtp_schema.json @@ -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 + } + } +} diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go index 2328050d3..5e8a132f2 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -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) diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_validate.go b/plugins/cmd/ndpgen/internal/xtp_schema_validate.go new file mode 100644 index 000000000..ac0e69b53 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/xtp_schema_validate.go @@ -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 +} diff --git a/plugins/cmd/ndpgen/main.go b/plugins/cmd/ndpgen/main.go index b1dd27cc5..a1fbf6a19 100644 --- a/plugins/cmd/ndpgen/main.go +++ b/plugins/cmd/ndpgen/main.go @@ -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") diff --git a/plugins/host/doc.go b/plugins/host/doc.go index ec64c65a8..10e2b846b 100644 --- a/plugins/host/doc.go +++ b/plugins/host/doc.go @@ -36,8 +36,4 @@ // // Generated files follow the pattern _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