From 8c71652921f5cdef4113ad325498d7d3d084e175 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Tue, 7 Jul 2026 14:21:31 +0300 Subject: [PATCH] feat(registry/types): add Plugin catalog type (Phase 5a, THV-0077) Add the registry Plugin type to toolhive-core so toolhive and toolhive-registry-server can surface plugins in the catalog. This is GATE-C3 for the plugin lifecycle epic (stacklok/toolhive#5525). - registry/types/plugins_types.go: Plugin struct mirroring Skill (drops skill-only Compatibility/AllowedTools), reuses SkillPackage/SkillIcon/SkillRepository. - registry/types/data/plugin.schema.json: JSON schema mirroring skill.schema.json with plugin_* $defs. - registry/types/schema_validation.go: embed + preload plugin schema; add (*Plugin).Validate() and ValidatePluginBytes(). - registry/types/upstream_registry.go: add Plugins []Plugin (omitempty) to UpstreamData. - registry/types/data/upstream-registry.schema.json: optional plugins array $ref-ing plugin.schema.json. - Tests: marshal round-trip, Validate, ValidatePluginBytes, upstream-registry-with-plugins validation, boundary cases. Package coverage: 81.1%. Part of stacklok/toolhive#5525. Refs RFC stacklok/toolhive-rfcs#77. --- CLAUDE.md | 1 + registry/types/data/plugin.schema.json | 174 ++++++ .../types/data/upstream-registry.schema.json | 7 + registry/types/plugins_types.go | 39 ++ registry/types/plugins_types_test.go | 507 ++++++++++++++++++ registry/types/schema_validation.go | 17 +- registry/types/schema_validation_test.go | 60 ++- registry/types/test_constants_test.go | 28 +- registry/types/upstream_registry.go | 5 +- registry/types/upstream_registry_test.go | 62 ++- 10 files changed, 878 insertions(+), 22 deletions(-) create mode 100644 registry/types/data/plugin.schema.json create mode 100644 registry/types/plugins_types.go create mode 100644 registry/types/plugins_types_test.go diff --git a/CLAUDE.md b/CLAUDE.md index ba24efe..b355b56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ task license-fix # Add missing license headers | `recovery` | HTTP panic recovery middleware (Beta) | | `validation/http` | RFC 7230/8707 compliant HTTP header and URI validation | | `validation/group` | Group name validation (lowercase alphanumeric, underscore, dash, space) | +| `registry/types` | Skill/Server/Plugin catalog types + JSON-schema validation (Alpha) | ### Mock Generation diff --git a/registry/types/data/plugin.schema.json b/registry/types/data/plugin.schema.json new file mode 100644 index 0000000..b631e2f --- /dev/null +++ b/registry/types/data/plugin.schema.json @@ -0,0 +1,174 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stacklok/toolhive-core/main/registry/types/data/plugin.schema.json", + "title": "ToolHive Plugin Schema", + "description": "Schema for a ToolHive plugin entry in the registry", + "type": "object", + "required": [ + "namespace", + "name", + "description", + "version" + ], + "properties": { + "namespace": { + "type": "string", + "description": "Ownership scope using reverse-DNS pattern (e.g. io.github.stacklok)", + "minLength": 3, + "maxLength": 128 + }, + "name": { + "type": "string", + "description": "Plugin identifier in lowercase alphanumeric with hyphens", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$" + }, + "description": { + "type": "string", + "description": "Human-readable explanation of the plugin", + "minLength": 1, + "maxLength": 1024 + }, + "version": { + "type": "string", + "description": "Version identifier (semantic version or commit hash)", + "minLength": 1 + }, + "status": { + "type": "string", + "description": "Current status of the plugin", + "enum": [ + "active", + "deprecated", + "archived" + ], + "default": "active" + }, + "title": { + "type": "string", + "description": "Human-readable display title", + "maxLength": 100 + }, + "license": { + "type": "string", + "description": "SPDX license identifier", + "maxLength": 256 + }, + "repository": { + "$ref": "#/$defs/plugin_repository" + }, + "icons": { + "type": "array", + "description": "Display icons for the plugin", + "items": { + "$ref": "#/$defs/plugin_icon" + } + }, + "packages": { + "type": "array", + "description": "Distribution packages (OCI or Git)", + "items": { + "$ref": "#/$defs/plugin_package" + } + }, + "metadata": { + "type": "object", + "description": "Official metadata from the plugin manifest file", + "additionalProperties": true + }, + "_meta": { + "type": "object", + "description": "Extended metadata with reverse-DNS namespaced keys", + "additionalProperties": true + } + }, + "$defs": { + "plugin_package": { + "type": "object", + "description": "Distribution package reference (OCI or Git)", + "required": [ + "registryType" + ], + "properties": { + "registryType": { + "type": "string", + "description": "Package registry type", + "enum": [ + "oci", + "git" + ] + }, + "identifier": { + "type": "string", + "description": "OCI image reference" + }, + "digest": { + "type": "string", + "description": "Content digest (sha256 format)" + }, + "mediaType": { + "type": "string", + "description": "Media type of the package" + }, + "url": { + "type": "string", + "description": "Git repository URL", + "format": "uri" + }, + "ref": { + "type": "string", + "description": "Git branch or tag reference" + }, + "commit": { + "type": "string", + "description": "Git commit SHA" + }, + "subfolder": { + "type": "string", + "description": "Path within the repository" + } + } + }, + "plugin_icon": { + "type": "object", + "description": "Display icon for the plugin", + "required": [ + "src" + ], + "properties": { + "src": { + "type": "string", + "description": "Icon source URL or path" + }, + "size": { + "type": "string", + "description": "Icon dimensions" + }, + "type": { + "type": "string", + "description": "Icon media type" + }, + "label": { + "type": "string", + "description": "Accessibility label for the icon" + } + } + }, + "plugin_repository": { + "type": "object", + "description": "Source repository metadata", + "properties": { + "url": { + "type": "string", + "description": "Repository URL", + "format": "uri" + }, + "type": { + "type": "string", + "description": "Repository type (e.g. git)" + } + } + } + } +} diff --git a/registry/types/data/upstream-registry.schema.json b/registry/types/data/upstream-registry.schema.json index a794cc7..b564c60 100644 --- a/registry/types/data/upstream-registry.schema.json +++ b/registry/types/data/upstream-registry.schema.json @@ -54,6 +54,13 @@ "items": { "$ref": "https://raw.githubusercontent.com/stacklok/toolhive-core/main/registry/types/data/skill.schema.json" } + }, + "plugins": { + "type": "array", + "description": "Array of plugins in the registry", + "items": { + "$ref": "https://raw.githubusercontent.com/stacklok/toolhive-core/main/registry/types/data/plugin.schema.json" + } } } } diff --git a/registry/types/plugins_types.go b/registry/types/plugins_types.go new file mode 100644 index 0000000..3db3cfa --- /dev/null +++ b/registry/types/plugins_types.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +// Plugin is a single plugin in list and get responses or publish requests. +type Plugin struct { + // Namespace is the namespace of the plugin. + // The format is reverse-DNS, e.g. "io.github.user". + Namespace string `json:"namespace"` + // Name is the name of the plugin. + // The format is that of identifiers, e.g. "my-plugin". + Name string `json:"name"` + // Description is the description of the plugin. + Description string `json:"description"` + // Version is the version of the plugin. + // Any non-empty string is valid, but ideally it should be either a + // semantic version or a commit hash. + Version string `json:"version"` + // Status is the status of the plugin. + // Can be one of "active", "deprecated", or "archived". + Status string `json:"status,omitempty"` // active, deprecated, archived + // Title is the title of the plugin. + // This is for human consumption, not an identifier. + Title string `json:"title,omitempty"` + // License is the SPDX license identifier of the plugin. + License string `json:"license,omitempty"` + // Repository is the source repository of the plugin. + Repository *SkillRepository `json:"repository,omitempty"` + // Icons is the list of icons for the plugin. + Icons []SkillIcon `json:"icons,omitempty"` + // Packages is the list of packages for the plugin. + Packages []SkillPackage `json:"packages,omitempty"` + // Metadata is the official metadata of the plugin as reported in the + // plugin manifest file. + Metadata map[string]any `json:"metadata,omitempty"` + // Meta is an opaque payload with extended meta data details of the plugin. + Meta map[string]any `json:"_meta,omitempty"` +} diff --git a/registry/types/plugins_types_test.go b/registry/types/plugins_types_test.go new file mode 100644 index 0000000..e02066f --- /dev/null +++ b/registry/types/plugins_types_test.go @@ -0,0 +1,507 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPlugin_MarshalRoundTrip verifies that a Plugin marshals to JSON and back +// preserving all fields, and that omitempty fields are omitted when unset. +func TestPlugin_MarshalRoundTrip(t *testing.T) { + t.Parallel() + + t.Run("full plugin round trip", func(t *testing.T) { + t.Parallel() + plugin := &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Description: testLongDesc, + Version: testVersion, + Status: testStatusActive, + Title: "PDF Processor", + License: "Apache-2.0", + Repository: &SkillRepository{ + URL: "https://github.com/stacklok/plugins/pdf-processor", + Type: testRegistryTypeGit, + }, + Icons: []SkillIcon{ + { + Src: "https://example.com/icon.png", + Size: "64x64", + Type: "image/png", + Label: "PDF icon", + }, + }, + Packages: []SkillPackage{ + { + RegistryType: testRegistryType, + Identifier: testPluginIdentifier, + Digest: "sha256:abc123", + MediaType: "application/vnd.stacklok.plugin.v1", + }, + }, + Metadata: map[string]any{ + "author": "Stacklok", + }, + Meta: map[string]any{ + testNamespace: map[string]any{}, + }, + } + + jsonData, err := json.Marshal(plugin) + require.NoError(t, err) + + var decoded Plugin + require.NoError(t, json.Unmarshal(jsonData, &decoded)) + assert.Equal(t, plugin.Namespace, decoded.Namespace) + assert.Equal(t, plugin.Name, decoded.Name) + assert.Equal(t, plugin.Description, decoded.Description) + assert.Equal(t, plugin.Version, decoded.Version) + assert.Equal(t, plugin.Status, decoded.Status) + assert.Equal(t, plugin.Title, decoded.Title) + assert.Equal(t, plugin.License, decoded.License) + require.NotNil(t, decoded.Repository) + assert.Equal(t, plugin.Repository.URL, decoded.Repository.URL) + assert.Equal(t, plugin.Repository.Type, decoded.Repository.Type) + require.Len(t, decoded.Icons, 1) + assert.Equal(t, plugin.Icons[0].Src, decoded.Icons[0].Src) + assert.Equal(t, plugin.Icons[0].Size, decoded.Icons[0].Size) + assert.Equal(t, plugin.Icons[0].Type, decoded.Icons[0].Type) + assert.Equal(t, plugin.Icons[0].Label, decoded.Icons[0].Label) + require.Len(t, decoded.Packages, 1) + assert.Equal(t, plugin.Packages[0].RegistryType, decoded.Packages[0].RegistryType) + assert.Equal(t, plugin.Packages[0].Identifier, decoded.Packages[0].Identifier) + assert.Equal(t, plugin.Packages[0].Digest, decoded.Packages[0].Digest) + assert.Equal(t, plugin.Packages[0].MediaType, decoded.Packages[0].MediaType) + assert.Equal(t, plugin.Metadata["author"], decoded.Metadata["author"]) + assert.Contains(t, decoded.Meta, testNamespace) + }) + + t.Run("omitempty omits optional fields", func(t *testing.T) { + t.Parallel() + plugin := &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Description: testLongDesc, + Version: testVersion, + } + + jsonData, err := json.Marshal(plugin) + require.NoError(t, err) + str := string(jsonData) + assert.NotContains(t, str, errKeyStatus) + assert.NotContains(t, str, "title") + assert.NotContains(t, str, "license") + assert.NotContains(t, str, "repository") + assert.NotContains(t, str, "icons") + assert.NotContains(t, str, "packages") + assert.NotContains(t, str, "metadata") + assert.NotContains(t, str, "_meta") + }) +} + +// TestPlugin_Validate tests the Validate method on the Plugin struct. +func TestPlugin_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + plugin *Plugin + wantErr bool + errorContains string + }{ + { + name: "valid minimal plugin", + plugin: &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Description: testLongDesc, + Version: testVersion, + }, + wantErr: false, + }, + { + name: "valid full plugin", + plugin: &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Description: testLongDesc, + Version: testVersion, + Status: testStatusActive, + Title: "PDF Processor", + License: "Apache-2.0", + Repository: &SkillRepository{ + URL: "https://github.com/stacklok/plugins/pdf-processor", + Type: testRegistryTypeGit, + }, + Icons: []SkillIcon{ + {Src: "https://example.com/icon.png"}, + }, + Packages: []SkillPackage{ + { + RegistryType: testRegistryType, + Identifier: testPluginIdentifier, + }, + }, + Metadata: map[string]any{"author": "Stacklok"}, + Meta: map[string]any{testNamespace: map[string]any{}}, + }, + wantErr: false, + }, + { + name: "empty struct", + plugin: &Plugin{}, + wantErr: true, + errorContains: errKeyNamespace, + }, + { + name: "missing namespace", + plugin: &Plugin{ + Name: testPluginName, + Description: testShortDesc, + Version: testVersion, + }, + wantErr: true, + errorContains: errKeyNamespace, + }, + { + name: "missing name", + plugin: &Plugin{ + Namespace: testNamespace, + Description: testShortDesc, + Version: testVersion, + }, + wantErr: true, + errorContains: errKeyName, + }, + { + name: "missing description", + plugin: &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Version: testVersion, + }, + wantErr: true, + errorContains: errKeyDescription, + }, + { + name: "missing version", + plugin: &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Description: testShortDesc, + }, + wantErr: true, + errorContains: errKeyVersion, + }, + { + name: "invalid status enum", + plugin: &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Description: testShortDesc, + Version: testVersion, + Status: "invalid-status", + }, + wantErr: true, + errorContains: errKeyStatus, + }, + { + name: "invalid package registryType", + plugin: &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Description: testShortDesc, + Version: testVersion, + Packages: []SkillPackage{ + {RegistryType: "invalid"}, + }, + }, + wantErr: true, + errorContains: errKeyRegistryType, + }, + { + name: "invalid name pattern - uppercase", + plugin: &Plugin{ + Namespace: testNamespace, + Name: "MyPlugin", + Description: testShortDesc, + Version: testVersion, + }, + wantErr: true, + errorContains: errKeyName, + }, + { + name: "invalid name pattern - leading dash", + plugin: &Plugin{ + Namespace: testNamespace, + Name: "-plugin", + Description: testShortDesc, + Version: testVersion, + }, + wantErr: true, + errorContains: errKeyName, + }, + { + name: "valid status deprecated", + plugin: &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Description: testShortDesc, + Version: testVersion, + Status: "deprecated", + }, + wantErr: false, + }, + { + name: "valid status archived", + plugin: &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Description: testShortDesc, + Version: testVersion, + Status: "archived", + }, + wantErr: false, + }, + { + name: "package missing registryType", + plugin: &Plugin{ + Namespace: testNamespace, + Name: testPluginName, + Description: testShortDesc, + Version: testVersion, + Packages: []SkillPackage{ + {Identifier: "x"}, + }, + }, + wantErr: true, + errorContains: errKeyRegistryType, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.plugin.Validate() + if tt.wantErr { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestValidatePluginBytes tests the ValidatePluginBytes function. +func TestValidatePluginBytes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantErr bool + errorContains string + }{ + { + name: "valid minimal plugin", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables from PDF files", + "version": "1.0.0" + }`, + wantErr: false, + }, + { + name: "valid full plugin with oci and git packages", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables from PDF files", + "version": "1.0.0", + "status": "active", + "title": "PDF Processor", + "license": "Apache-2.0", + "repository": { + "url": "https://github.com/stacklok/plugins/pdf-processor", + "type": "git" + }, + "icons": [ + { + "src": "https://example.com/icon.png", + "size": "64x64", + "type": "image/png", + "label": "PDF icon" + } + ], + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/stacklok/plugins/pdf-processor:1.0.0", + "digest": "sha256:abc123", + "mediaType": "application/vnd.stacklok.plugin.v1" + }, + { + "registryType": "git", + "url": "https://github.com/stacklok/plugins/pdf-processor", + "ref": "main", + "commit": "abc123def456", + "subfolder": "plugins/pdf-processor" + } + ], + "metadata": { + "author": "Stacklok" + }, + "_meta": { + "io.github.stacklok": {} + } + }`, + wantErr: false, + }, + { + name: "valid git package", + data: `{ + "namespace": "io.github.user", + "name": "my-plugin", + "description": "A custom plugin from a git repository", + "version": "abc123def", + "packages": [ + { + "registryType": "git", + "url": "https://github.com/user/my-plugin", + "ref": "main", + "commit": "abc123def456", + "subfolder": "plugins/my-plugin" + } + ] + }`, + wantErr: false, + }, + { + name: "missing namespace", + data: `{ + "name": "pdf-processor", + "description": "Extract text and tables", + "version": "1.0.0" + }`, + wantErr: true, + errorContains: errKeyNamespace, + }, + { + name: "missing name", + data: `{ + "namespace": "io.github.stacklok", + "description": "Extract text and tables", + "version": "1.0.0" + }`, + wantErr: true, + errorContains: errKeyName, + }, + { + name: "missing description", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "version": "1.0.0" + }`, + wantErr: true, + errorContains: errKeyDescription, + }, + { + name: "missing version", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables" + }`, + wantErr: true, + errorContains: errKeyVersion, + }, + { + name: "invalid status", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables", + "version": "1.0.0", + "status": "invalid-status" + }`, + wantErr: true, + errorContains: errKeyStatus, + }, + { + name: "invalid registryType", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables", + "version": "1.0.0", + "packages": [ + { + "registryType": "invalid" + } + ] + }`, + wantErr: true, + errorContains: errKeyRegistryType, + }, + { + name: "invalid JSON", + data: `{invalid json`, + wantErr: true, + errorContains: "plugin schema validation failed", + }, + { + name: "invalid name pattern - uppercase", + data: `{ + "namespace": "io.github.stacklok", + "name": "MyPlugin", + "description": "Extract text and tables", + "version": "1.0.0" + }`, + wantErr: true, + errorContains: errKeyName, + }, + { + name: "package missing registryType", + data: `{ + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables", + "version": "1.0.0", + "packages": [ + { + "identifier": "ghcr.io/stacklok/plugins/pdf-processor:1.0.0" + } + ] + }`, + wantErr: true, + errorContains: errKeyRegistryType, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidatePluginBytes([]byte(tt.data)) + if tt.wantErr { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/registry/types/schema_validation.go b/registry/types/schema_validation.go index d55bbee..11b3541 100644 --- a/registry/types/schema_validation.go +++ b/registry/types/schema_validation.go @@ -14,7 +14,7 @@ import ( "github.com/xeipuuv/gojsonschema" ) -//go:embed data/upstream-registry.schema.json data/publisher-provided.schema.json data/skill.schema.json data/server.schema.json +//go:embed data/upstream-registry.schema.json data/publisher-provided.schema.json data/skill.schema.json data/server.schema.json data/plugin.schema.json var embeddedSchemaFS embed.FS // referencedSchemas lists embedded schema files that declare an $id matching @@ -23,6 +23,7 @@ var embeddedSchemaFS embed.FS var referencedSchemas = []string{ "data/server.schema.json", "data/skill.schema.json", + "data/plugin.schema.json", } // preloadedSchemaLoaders caches the raw bytes of each referenced schema, @@ -82,6 +83,15 @@ func (s *Skill) Validate() error { return validateAgainstSchema(data, "data/skill.schema.json", "skill schema validation failed") } +// Validate validates the Plugin against the plugin schema. +func (p *Plugin) Validate() error { + data, err := json.Marshal(p) + if err != nil { + return fmt.Errorf("failed to serialize plugin: %w", err) + } + return validateAgainstSchema(data, "data/plugin.schema.json", "plugin schema validation failed") +} + // ValidateUpstreamRegistryBytes validates raw upstream registry JSON bytes against the upstream registry schema. // It also validates any publisher-provided extensions found in server definitions. func ValidateUpstreamRegistryBytes(registryData []byte) error { @@ -103,6 +113,11 @@ func ValidateSkillBytes(skillData []byte) error { return validateAgainstSchema(skillData, "data/skill.schema.json", "skill schema validation failed") } +// ValidatePluginBytes validates raw plugin JSON bytes against the plugin schema. +func ValidatePluginBytes(pluginData []byte) error { + return validateAgainstSchema(pluginData, "data/plugin.schema.json", "plugin schema validation failed") +} + // ValidateServerJSON validates a single MCP server JSON object and optionally validates // any publisher-provided extensions found in its _meta field. func ValidateServerJSON(serverData []byte, validateExtensions bool) error { diff --git a/registry/types/schema_validation_test.go b/registry/types/schema_validation_test.go index 31d9eab..007aa1d 100644 --- a/registry/types/schema_validation_test.go +++ b/registry/types/schema_validation_test.go @@ -114,6 +114,60 @@ func TestValidateUpstreamRegistryBytes(t *testing.T) { wantErr: true, errorContains: "date-time", }, + { + name: "valid registry with plugins", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [], + "plugins": [ + { + "namespace": "io.github.stacklok", + "name": "pdf-processor", + "description": "Extract text and tables from PDF files", + "version": "1.0.0", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/stacklok/plugins/pdf-processor:1.0.0" + } + ] + } + ] + } + }`, + wantErr: false, + }, + { + name: "registry with invalid plugin - missing namespace", + data: `{ + "version": "1.0.0", + "meta": { + "last_updated": "2024-01-15T10:30:00Z" + }, + "data": { + "servers": [], + "plugins": [ + { + "name": "pdf-processor", + "description": "Extract text and tables from PDF files", + "version": "1.0.0", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/stacklok/plugins/pdf-processor:1.0.0" + } + ] + } + ] + } + }`, + wantErr: true, + errorContains: errKeyNamespace, + }, } for _, tt := range tests { @@ -948,7 +1002,7 @@ func TestValidateSkillBytes(t *testing.T) { "version": "1.0.0" }`, wantErr: true, - errorContains: "namespace", + errorContains: errKeyNamespace, }, { name: "missing required name", @@ -958,7 +1012,7 @@ func TestValidateSkillBytes(t *testing.T) { "version": "1.0.0" }`, wantErr: true, - errorContains: "name", + errorContains: errKeyName, }, { name: "missing required description", @@ -1006,7 +1060,7 @@ func TestValidateSkillBytes(t *testing.T) { ] }`, wantErr: true, - errorContains: "registryType", + errorContains: errKeyRegistryType, }, } diff --git a/registry/types/test_constants_test.go b/registry/types/test_constants_test.go index c43d2fa..67bc9b5 100644 --- a/registry/types/test_constants_test.go +++ b/registry/types/test_constants_test.go @@ -4,13 +4,23 @@ package registry const ( - testVersion = "1.0.0" - testServerName = "server-a" - testRemoteName = "remote-a" - testStatusActive = "active" - errKeyVersion = "version" - errKeyLastUpdated = "last_updated" - errKeyDescription = "description" - errKeyStatus = "status" - errKeyTier = "tier" + testVersion = "1.0.0" + testServerName = "server-a" + testRemoteName = "remote-a" + testStatusActive = "active" + testNamespace = "io.github.stacklok" + testPluginName = "pdf-processor" + testShortDesc = "Extract text and tables" + testLongDesc = "Extract text and tables from PDF files" + testRegistryType = "oci" + testRegistryTypeGit = "git" + testPluginIdentifier = "ghcr.io/stacklok/plugins/pdf-processor:1.0.0" + errKeyVersion = "version" + errKeyLastUpdated = "last_updated" + errKeyDescription = "description" + errKeyStatus = "status" + errKeyTier = "tier" + errKeyNamespace = "namespace" + errKeyName = "name" + errKeyRegistryType = "registryType" ) diff --git a/registry/types/upstream_registry.go b/registry/types/upstream_registry.go index 256a4f2..1e76a7e 100644 --- a/registry/types/upstream_registry.go +++ b/registry/types/upstream_registry.go @@ -33,11 +33,14 @@ type UpstreamMeta struct { LastUpdated string `json:"last_updated" yaml:"last_updated"` } -// UpstreamData contains the actual registry content (servers and skills) +// UpstreamData contains the actual registry content (servers, skills, and plugins) type UpstreamData struct { // Servers contains the server definitions in upstream MCP format Servers []upstreamv0.ServerJSON `json:"servers" yaml:"servers"` // Skills contains the skill definitions Skills []Skill `json:"skills,omitempty" yaml:"skills,omitempty"` + + // Plugins contains the plugin definitions + Plugins []Plugin `json:"plugins,omitempty" yaml:"plugins,omitempty"` } diff --git a/registry/types/upstream_registry_test.go b/registry/types/upstream_registry_test.go index 6aa0148..7f3771c 100644 --- a/registry/types/upstream_registry_test.go +++ b/registry/types/upstream_registry_test.go @@ -122,7 +122,7 @@ func TestRegistryMeta_TimeFormat(t *testing.T) { func TestRegistryData_EmptyOptionalFields(t *testing.T) { t.Parallel() - // Test that skills can be omitted (omitempty) + // Test that skills and plugins can be omitted (omitempty) data := UpstreamData{ Servers: []upstreamv0.ServerJSON{}, } @@ -132,14 +132,18 @@ func TestRegistryData_EmptyOptionalFields(t *testing.T) { // Skills should not appear in JSON when nil (omitempty behavior) assert.NotContains(t, string(jsonData), "skills") + // Plugins should not appear in JSON when nil (omitempty behavior) + assert.NotContains(t, string(jsonData), "plugins") // Test with empty slice - also omitted due to omitempty data.Skills = []Skill{} + data.Plugins = []Plugin{} jsonData, err = json.Marshal(data) require.NoError(t, err) // Empty arrays are also omitted with omitempty assert.NotContains(t, string(jsonData), "skills") + assert.NotContains(t, string(jsonData), "plugins") } func TestUpstreamRegistry_WithSkills(t *testing.T) { @@ -154,14 +158,14 @@ func TestUpstreamRegistry_WithSkills(t *testing.T) { Servers: []upstreamv0.ServerJSON{}, Skills: []Skill{ { - Namespace: "io.github.stacklok", - Name: "pdf-processor", - Description: "Extract text and tables from PDF files", + Namespace: testNamespace, + Name: testPluginName, + Description: testLongDesc, Version: testVersion, Status: testStatusActive, Packages: []SkillPackage{ { - RegistryType: "oci", + RegistryType: testRegistryType, Identifier: "ghcr.io/stacklok/skills/pdf-processor:1.0.0", }, }, @@ -177,9 +181,51 @@ func TestUpstreamRegistry_WithSkills(t *testing.T) { err = json.Unmarshal(jsonData, &decoded) require.NoError(t, err) require.Len(t, decoded.Data.Skills, 1) - assert.Equal(t, "io.github.stacklok", decoded.Data.Skills[0].Namespace) - assert.Equal(t, "pdf-processor", decoded.Data.Skills[0].Name) + assert.Equal(t, testNamespace, decoded.Data.Skills[0].Namespace) + assert.Equal(t, testPluginName, decoded.Data.Skills[0].Name) assert.Equal(t, testVersion, decoded.Data.Skills[0].Version) require.Len(t, decoded.Data.Skills[0].Packages, 1) - assert.Equal(t, "oci", decoded.Data.Skills[0].Packages[0].RegistryType) + assert.Equal(t, testRegistryType, decoded.Data.Skills[0].Packages[0].RegistryType) +} + +func TestUpstreamRegistry_WithPlugins(t *testing.T) { + t.Parallel() + reg := &UpstreamRegistry{ + Schema: UpstreamRegistrySchemaURL, + Version: testVersion, + Meta: UpstreamMeta{ + LastUpdated: time.Now().Format(time.RFC3339), + }, + Data: UpstreamData{ + Servers: []upstreamv0.ServerJSON{}, + Plugins: []Plugin{ + { + Namespace: testNamespace, + Name: testPluginName, + Description: testLongDesc, + Version: testVersion, + Status: testStatusActive, + Packages: []SkillPackage{ + { + RegistryType: testRegistryType, + Identifier: testPluginIdentifier, + }, + }, + }, + }, + }, + } + + jsonData, err := json.Marshal(reg) + require.NoError(t, err) + + var decoded UpstreamRegistry + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + require.Len(t, decoded.Data.Plugins, 1) + assert.Equal(t, testNamespace, decoded.Data.Plugins[0].Namespace) + assert.Equal(t, testPluginName, decoded.Data.Plugins[0].Name) + assert.Equal(t, testVersion, decoded.Data.Plugins[0].Version) + require.Len(t, decoded.Data.Plugins[0].Packages, 1) + assert.Equal(t, testRegistryType, decoded.Data.Plugins[0].Packages[0].RegistryType) }