diff --git a/examples/config-api.yaml b/examples/config-api.yaml index 300ba2d..93f2fec 100644 --- a/examples/config-api.yaml +++ b/examples/config-api.yaml @@ -19,8 +19,7 @@ source: # Source type: git, or api type: api - # Data format: toolhive (native) or upstream (MCP registry format) - # Use 'upstream' for the official MCP registry format + # Data format: only 'upstream' is supported for API sources format: upstream # API endpoint configuration diff --git a/internal/config/config.go b/internal/config/config.go index 7641dda..de34ef2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -290,6 +290,25 @@ func (c *Config) validate() error { return fmt.Errorf("source.type is required") } + if err := c.validateSourceConfigByType(); err != nil { + return err + } + + // Validate sync policy + if c.SyncPolicy == nil || c.SyncPolicy.Interval == "" { + return fmt.Errorf("syncPolicy.interval is required") + } + + // Try to parse the interval to ensure it's valid + if _, err := time.ParseDuration(c.SyncPolicy.Interval); err != nil { + return fmt.Errorf("syncPolicy.interval must be a valid duration (e.g., '30m', '1h'): %w", err) + } + + return nil +} + +// validateSourceConfigByType validates the source configuration by the source type +func (c *Config) validateSourceConfigByType() error { // Validate source-specific settings switch c.Source.Type { case SourceTypeGit: @@ -307,6 +326,9 @@ func (c *Config) validate() error { if c.Source.API.Endpoint == "" { return fmt.Errorf("source.api.endpoint is required") } + if c.Source.Format != "" && c.Source.Format != SourceFormatUpstream { + return fmt.Errorf("source.format must be either empty or %s when type is api, got %s", SourceFormatUpstream, c.Source.Format) + } case SourceTypeFile: if c.Source.File == nil { @@ -320,15 +342,5 @@ func (c *Config) validate() error { return fmt.Errorf("unsupported source type: %s", c.Source.Type) } - // Validate sync policy - if c.SyncPolicy == nil || c.SyncPolicy.Interval == "" { - return fmt.Errorf("syncPolicy.interval is required") - } - - // Try to parse the interval to ensure it's valid - if _, err := time.ParseDuration(c.SyncPolicy.Interval); err != nil { - return fmt.Errorf("syncPolicy.interval must be a valid duration (e.g., '30m', '1h'): %w", err) - } - return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e84f8ce..3d72b1c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -377,6 +377,20 @@ func TestConfigValidate(t *testing.T) { wantErr: true, errMsg: "source.file.path is required", }, + { + name: "invalid_format_when_type_is_api", + config: &Config{ + Source: SourceConfig{ + Type: "api", + Format: "toolhive", + API: &APIConfig{ + Endpoint: "http://example.com", + }, + }, + }, + wantErr: true, + errMsg: "source.format must be either empty or upstream", + }, { name: "unsupported_source_type", config: &Config{ diff --git a/internal/sources/api.go b/internal/sources/api.go index 18f5a66..bfc1f7e 100644 --- a/internal/sources/api.go +++ b/internal/sources/api.go @@ -11,11 +11,10 @@ import ( ) // APISourceHandler handles registry data from API endpoints -// It detects the format (ToolHive vs Upstream) and delegates to the appropriate handler +// It validates the Upstream format and delegates to the appropriate handler type APISourceHandler struct { httpClient httpclient.Client validator SourceDataValidator - toolhiveHandler *ToolHiveAPIHandler upstreamHandler *UpstreamAPIHandler } @@ -26,7 +25,6 @@ func NewAPISourceHandler() *APISourceHandler { return &APISourceHandler{ httpClient: httpClient, validator: NewSourceDataValidator(), - toolhiveHandler: NewToolHiveAPIHandler(httpClient), upstreamHandler: NewUpstreamAPIHandler(httpClient), } } @@ -38,6 +36,11 @@ func (*APISourceHandler) Validate(source *config.SourceConfig) error { config.SourceTypeAPI, source.Type) } + if source.Format != "" && source.Format != config.SourceFormatUpstream { + return fmt.Errorf("unsupported format: expected %s or empty, got %s", + config.SourceFormatUpstream, source.Format) + } + if source.API == nil { return fmt.Errorf("api configuration is required for source type %s", config.SourceTypeAPI) @@ -51,7 +54,7 @@ func (*APISourceHandler) Validate(source *config.SourceConfig) error { } // FetchRegistry retrieves registry data from the API endpoint -// It auto-detects the format and delegates to the appropriate handler +// It validates the Upstream format and delegates to the appropriate handler func (h *APISourceHandler) FetchRegistry(ctx context.Context, cfg *config.Config) (*FetchResult, error) { logger := log.FromContext(ctx) @@ -60,14 +63,13 @@ func (h *APISourceHandler) FetchRegistry(ctx context.Context, cfg *config.Config return nil, fmt.Errorf("source validation failed: %w", err) } - // Detect format and get appropriate handler - handler, format, err := h.detectFormatAndGetHandler(ctx, cfg) + // Validate Upstream format and get appropriate handler + handler, err := h.validateUstreamFormat(ctx, cfg) if err != nil { - return nil, fmt.Errorf("format detection failed: %w", err) + return nil, fmt.Errorf("upstream format validation failed: %w", err) } - logger.Info("Detected API format, delegating to handler", - "format", format) + logger.Info("Validated Upstream format, delegating to handler") // Delegate to the appropriate handler return handler.FetchRegistry(ctx, cfg) @@ -80,48 +82,33 @@ func (h *APISourceHandler) CurrentHash(ctx context.Context, cfg *config.Config) return "", fmt.Errorf("source validation failed: %w", err) } - // Detect format and get appropriate handler - handler, _, err := h.detectFormatAndGetHandler(ctx, cfg) + // Validate Upstream format and get appropriate handler + handler, err := h.validateUstreamFormat(ctx, cfg) if err != nil { - return "", fmt.Errorf("format detection failed: %w", err) + return "", fmt.Errorf("upstream format validation failed: %w", err) } // Delegate to the appropriate handler return handler.CurrentHash(ctx, cfg) } -// apiFormatHandler is an internal interface for format-specific handlers -type apiFormatHandler interface { - Validate(ctx context.Context, endpoint string) error - FetchRegistry(ctx context.Context, cfg *config.Config) (*FetchResult, error) - CurrentHash(ctx context.Context, cfg *config.Config) (string, error) -} - -// detectFormatAndGetHandler detects the API format and returns the appropriate handler -func (h *APISourceHandler) detectFormatAndGetHandler( +// validateUstreamFormat validates the Upstream format and returns the appropriate handler +func (h *APISourceHandler) validateUstreamFormat( ctx context.Context, cfg *config.Config, -) (apiFormatHandler, string, error) { +) (*UpstreamAPIHandler, error) { logger := log.FromContext(ctx) endpoint := h.getBaseURL(cfg) - // Try ToolHive format first (/v0/info) - toolhiveErr := h.toolhiveHandler.Validate(ctx, endpoint) - if toolhiveErr == nil { - logger.Info("Validated as ToolHive format") - return h.toolhiveHandler, "toolhive", nil - } - logger.V(1).Info("ToolHive format validation failed", "error", toolhiveErr.Error()) - // Try upstream format (/openapi.yaml) upstreamErr := h.upstreamHandler.Validate(ctx, endpoint) if upstreamErr == nil { logger.Info("Validated as upstream MCP Registry format") - return h.upstreamHandler, "upstream", nil + return h.upstreamHandler, nil } logger.V(1).Info("Upstream format validation failed", "error", upstreamErr.Error()) - return nil, "", fmt.Errorf("unable to detect valid API format (tried toolhive and upstream)") + return nil, fmt.Errorf("unable to validate Upstream format") } // getBaseURL extracts and normalizes the base URL diff --git a/internal/sources/api_test.go b/internal/sources/api_test.go index e41ed9d..73e212c 100644 --- a/internal/sources/api_test.go +++ b/internal/sources/api_test.go @@ -14,9 +14,7 @@ import ( ) const ( - toolhiveInfoPath = "/v0/info" - toolhiveServersPath = "/v0/servers" - openapiPath = "/openapi.yaml" + openapiPath = "/openapi.yaml" ) func TestAPISources(t *testing.T) { @@ -54,6 +52,17 @@ var _ = Describe("APISourceHandler", func() { Expect(err.Error()).To(ContainSubstring("invalid source type")) }) + It("should reject non-Upstream format", func() { + source := &config.SourceConfig{ + Type: config.SourceTypeAPI, + Format: config.SourceFormatToolHive, + } + + err := handler.Validate(source) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unsupported format:")) + }) + It("should reject missing API configuration", func() { source := &config.SourceConfig{ Type: config.SourceTypeAPI, @@ -91,49 +100,11 @@ var _ = Describe("APISourceHandler", func() { }) }) - Describe("Format Detection", func() { - Context("ToolHive Format", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case toolhiveInfoPath: - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"version":"1.0.0","last_updated":"2025-01-14T00:00:00Z","source":"file:/data/registry.json","total_servers":5}`)) - case toolhiveServersPath: - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"servers":[],"total":0}`)) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - }) - - It("should detect and validate ToolHive format", func() { - registryConfig := &config.Config{ - Source: config.SourceConfig{ - Type: config.SourceTypeAPI, - API: &config.APIConfig{ - Endpoint: mockServer.URL, - }, - }, - } - result, err := handler.FetchRegistry(ctx, registryConfig) - Expect(err).NotTo(HaveOccurred()) - Expect(result).NotTo(BeNil()) - Expect(result.Format).To(Equal(config.SourceFormatToolHive)) - }) - }) - + Describe("Upstream Format Validation", func() { Context("Upstream Format", func() { BeforeEach(func() { mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case toolhiveInfoPath: - // Return 404 for /v0/info (upstream doesn't have this) - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"detail":"Endpoint not found"}`)) case openapiPath: w.Header().Set("Content-Type", "application/x-yaml") w.WriteHeader(http.StatusOK) @@ -189,7 +160,7 @@ openapi: 3.1.0 _, err := handler.FetchRegistry(ctx, registryConfig) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("format detection failed")) + Expect(err.Error()).To(ContainSubstring("upstream format validation failed")) }) }) }) diff --git a/internal/sources/api_toolhive.go b/internal/sources/api_toolhive.go deleted file mode 100644 index c7b1465..0000000 --- a/internal/sources/api_toolhive.go +++ /dev/null @@ -1,349 +0,0 @@ -package sources - -import ( - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "net/url" - "sync" - "time" - - "github.com/go-logr/logr" - "github.com/stacklok/toolhive/pkg/registry/converters" - toolhivetypes "github.com/stacklok/toolhive/pkg/registry/registry" - "sigs.k8s.io/controller-runtime/pkg/log" - - "github.com/stacklok/toolhive-registry-server/internal/config" - "github.com/stacklok/toolhive-registry-server/internal/httpclient" -) - -// ToolHiveAPIHandler handles registry data from ToolHive Registry API endpoints -// API Format: /v0/servers (list), /v0/servers/{name} (detail), /v0/info (metadata) -type ToolHiveAPIHandler struct { - httpClient httpclient.Client - validator SourceDataValidator -} - -// NewToolHiveAPIHandler creates a new ToolHive API handler -func NewToolHiveAPIHandler(httpClient httpclient.Client) *ToolHiveAPIHandler { - return &ToolHiveAPIHandler{ - httpClient: httpClient, - validator: NewSourceDataValidator(), - } -} - -// Validate validates that the endpoint is a ToolHive registry -func (h *ToolHiveAPIHandler) Validate(ctx context.Context, endpoint string) error { - infoURL := endpoint + "/v0/info" - - data, err := h.httpClient.Get(ctx, infoURL) - if err != nil { - return fmt.Errorf("failed to fetch /v0/info: %w", err) - } - - // Parse info response - var info RegistryInfoResponse - if err := json.Unmarshal(data, &info); err != nil { - return fmt.Errorf("failed to parse /v0/info response: %w", err) - } - - // Validate expected fields - if info.Version == "" { - return fmt.Errorf("/v0/info missing 'version' field") - } - - if info.TotalServers < 0 { - return fmt.Errorf("/v0/info has invalid 'total_servers' value: %d", info.TotalServers) - } - - return nil -} - -// FetchRegistry retrieves registry data from the ToolHive API endpoint -func (h *ToolHiveAPIHandler) FetchRegistry(ctx context.Context, registryConfig *config.Config) (*FetchResult, error) { - logger := log.FromContext(ctx) - baseURL := h.getBaseURL(registryConfig) - - // Build API URL: /v0/servers?format=toolhive - apiURL := h.buildServersURL(baseURL) - - // Fetch server list - startTime := time.Now() - logger.Info("Fetching from ToolHive API", "url", apiURL) - - data, err := h.httpClient.Get(ctx, apiURL) - if err != nil { - logger.Error(err, "API fetch failed", - "url", apiURL, - "duration", time.Since(startTime).String()) - return nil, fmt.Errorf("failed to fetch from API: %w", err) - } - - logger.Info("API fetch completed", - "url", apiURL, - "duration", time.Since(startTime).String(), - "response_size_bytes", len(data)) - - // Parse response - var listResponse ListServersResponse - if err := json.Unmarshal(data, &listResponse); err != nil { - return nil, fmt.Errorf("failed to parse API response: %w", err) - } - - logger.Info("Parsed API response", - "total_servers", listResponse.Total, - "servers_in_response", len(listResponse.Servers)) - - // Convert to ToolHive Registry format, fetching details for each server - toolhiveRegistry, err := h.convertToToolhiveRegistry(ctx, baseURL, &listResponse) - if err != nil { - return nil, fmt.Errorf("failed to convert to ToolHive format: %w", err) - } - - // Convert ToolHive Registry to UpstreamRegistry - UpstreamRegistry, err := converters.NewUpstreamRegistryFromToolhiveRegistry(toolhiveRegistry) - if err != nil { - return nil, fmt.Errorf("failed to convert to UpstreamRegistry: %w", err) - } - - // Calculate hash of the raw data for change detection - hash := fmt.Sprintf("%x", sha256.Sum256(data)) - - // Create and return fetch result - return NewFetchResult(UpstreamRegistry, hash, config.SourceFormatToolHive), nil -} - -// CurrentHash returns the current hash of the API response -func (h *ToolHiveAPIHandler) CurrentHash(ctx context.Context, cfg *config.Config) (string, error) { - baseURL := h.getBaseURL(cfg) - apiURL := h.buildServersURL(baseURL) - - // Fetch data from API - data, err := h.httpClient.Get(ctx, apiURL) - if err != nil { - return "", fmt.Errorf("failed to fetch from API: %w", err) - } - - // Compute and return hash - hash := fmt.Sprintf("%x", sha256.Sum256(data)) - return hash, nil -} - -// getBaseURL extracts and normalizes the base URL -func (*ToolHiveAPIHandler) getBaseURL(cfg *config.Config) string { - baseURL := cfg.Source.API.Endpoint - - // Remove trailing slash - if len(baseURL) > 0 && baseURL[len(baseURL)-1] == '/' { - baseURL = baseURL[:len(baseURL)-1] - } - - return baseURL -} - -// buildServersURL constructs the URL for listing servers -func (*ToolHiveAPIHandler) buildServersURL(baseURL string) string { - // ToolHive API path: /v0/servers - fullURL := baseURL + "/v0/servers" - - parsedURL, err := url.Parse(fullURL) - if err != nil { - return fullURL - } - - // Add format query parameter - q := parsedURL.Query() - q.Set("format", "toolhive") - parsedURL.RawQuery = q.Encode() - - return parsedURL.String() -} - -// buildServerDetailURL constructs the URL for fetching server details -func (*ToolHiveAPIHandler) buildServerDetailURL(baseURL, serverName string) string { - // Construct URL: /v0/servers/{name}?format=toolhive - fullURL := fmt.Sprintf("%s/v0/servers/%s", baseURL, url.PathEscape(serverName)) - parsedURL, err := url.Parse(fullURL) - if err != nil { - return fullURL - } - - // Add format query parameter - q := parsedURL.Query() - q.Set("format", "toolhive") - parsedURL.RawQuery = q.Encode() - - return parsedURL.String() -} - -// convertToToolhiveRegistry converts API response to ToolHive Registry format -// by fetching detailed information for each server -// -//nolint:unparam // error return kept for future extensibility and consistency with interface -func (h *ToolHiveAPIHandler) convertToToolhiveRegistry( - ctx context.Context, - baseURL string, - response *ListServersResponse, -) (*toolhivetypes.Registry, error) { - logger := log.FromContext(ctx) - - toolhiveRegistry := &toolhivetypes.Registry{ - Version: "1.0", - LastUpdated: time.Now().Format(time.RFC3339), - Servers: make(map[string]*toolhivetypes.ImageMetadata), - RemoteServers: make(map[string]*toolhivetypes.RemoteServerMetadata), - } - - // Fetch detailed information for each server in parallel - h.fetchServerDetailsParallel(ctx, baseURL, response.Servers, toolhiveRegistry, logger) - - return toolhiveRegistry, nil -} - -// fetchServerDetailsParallel fetches server details concurrently with controlled parallelism -func (h *ToolHiveAPIHandler) fetchServerDetailsParallel( - ctx context.Context, - baseURL string, - servers []ServerSummaryResponse, - toolhiveRegistry *toolhivetypes.Registry, - logger logr.Logger, -) { - // Limit concurrent requests to avoid overwhelming the API - const maxConcurrency = 10 - - // Create a semaphore to limit concurrency - semaphore := make(chan struct{}, maxConcurrency) - - // Use WaitGroup to wait for all goroutines to complete - var wg sync.WaitGroup - - // Mutex to protect concurrent writes to the registry - var mu sync.Mutex - - for _, serverSummary := range servers { - wg.Add(1) - - // Launch goroutine for each server - go func(summary ServerSummaryResponse) { - defer wg.Done() - - // Acquire semaphore slot - semaphore <- struct{}{} - defer func() { <-semaphore }() - - // Build URL for server details: /v0/servers/{name} - detailURL := h.buildServerDetailURL(baseURL, summary.Name) - - logger.V(1).Info("Fetching server details", - "server", summary.Name, - "url", detailURL) - - // Fetch server details - detailData, err := h.httpClient.Get(ctx, detailURL) - if err != nil { - logger.Error(err, "Failed to fetch server details, using summary only", - "server", summary.Name) - // Fall back to summary data - mu.Lock() - h.addServerFromSummary(toolhiveRegistry, &summary) - mu.Unlock() - return - } - - // Parse server detail response - var serverDetail ServerDetailResponse - if err := json.Unmarshal(detailData, &serverDetail); err != nil { - logger.Error(err, "Failed to parse server detail response, using summary only", - "server", summary.Name) - // Fall back to summary data - mu.Lock() - h.addServerFromSummary(toolhiveRegistry, &summary) - mu.Unlock() - return - } - - // Add server with full details (thread-safe) - mu.Lock() - h.addServerFromDetail(toolhiveRegistry, &serverDetail) - mu.Unlock() - }(serverSummary) - } - - // Wait for all goroutines to complete - wg.Wait() -} - -// addServerFromSummary adds a server using only summary data (fallback) -func (*ToolHiveAPIHandler) addServerFromSummary(reg *toolhivetypes.Registry, summary *ServerSummaryResponse) { - imageMetadata := &toolhivetypes.ImageMetadata{ - BaseServerMetadata: toolhivetypes.BaseServerMetadata{ - Name: summary.Name, - Description: summary.Description, - Tier: summary.Tier, - Status: summary.Status, - Transport: summary.Transport, - Tools: make([]string, 0), // Empty, not available in summary - }, - Image: "", // Not available in summary - } - reg.Servers[summary.Name] = imageMetadata -} - -// addServerFromDetail adds a server using full detail data -func (*ToolHiveAPIHandler) addServerFromDetail(reg *toolhivetypes.Registry, detail *ServerDetailResponse) { - imageMetadata := &toolhivetypes.ImageMetadata{ - BaseServerMetadata: toolhivetypes.BaseServerMetadata{ - Name: detail.Name, - Description: detail.Description, - Tier: detail.Tier, - Status: detail.Status, - Transport: detail.Transport, - Tools: detail.Tools, - RepositoryURL: detail.RepositoryURL, - Tags: detail.Tags, - }, - Image: detail.Image, - Args: detail.Args, - // Note: Permissions are stored in CustomMetadata below since API returns map[string]interface{} - // and ImageMetadata expects *permissions.Profile. Conversion would be needed for full support. - } - - // Add environment variables if present - if len(detail.EnvVars) > 0 { - imageMetadata.EnvVars = make([]*toolhivetypes.EnvVar, len(detail.EnvVars)) - for i, envVar := range detail.EnvVars { - imageMetadata.EnvVars[i] = &toolhivetypes.EnvVar{ - Name: envVar.Name, - Description: envVar.Description, - Required: envVar.Required, - Default: envVar.Default, - Secret: envVar.Secret, - } - } - } - - // Build custom metadata - customMetadata := make(map[string]interface{}) - - // Add all metadata from the detail response - for k, v := range detail.Metadata { - customMetadata[k] = v - } - - // Add permissions to custom metadata if present - if len(detail.Permissions) > 0 { - customMetadata["permissions"] = detail.Permissions - } - - // Add volumes to custom metadata if present - if len(detail.Volumes) > 0 { - customMetadata["volumes"] = detail.Volumes - } - - if len(customMetadata) > 0 { - imageMetadata.CustomMetadata = customMetadata - } - - reg.Servers[detail.Name] = imageMetadata -} diff --git a/internal/sources/api_toolhive_test.go b/internal/sources/api_toolhive_test.go deleted file mode 100644 index 9c1db29..0000000 --- a/internal/sources/api_toolhive_test.go +++ /dev/null @@ -1,363 +0,0 @@ -package sources_test - -import ( - "context" - "net/http" - "net/http/httptest" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/stacklok/toolhive-registry-server/internal/config" - "github.com/stacklok/toolhive-registry-server/internal/httpclient" - "github.com/stacklok/toolhive-registry-server/internal/sources" -) - -var _ = Describe("ToolHiveAPIHandler", func() { - var ( - handler *sources.ToolHiveAPIHandler - ctx context.Context - mockServer *httptest.Server - ) - - BeforeEach(func() { - httpClient := httpclient.NewDefaultClient(0) - handler = sources.NewToolHiveAPIHandler(httpClient) - ctx = context.Background() - }) - - AfterEach(func() { - if mockServer != nil { - mockServer.Close() - } - }) - - Describe("Validate", func() { - Context("Valid ToolHive API", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == toolhiveInfoPath { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"version":"1.0.0","last_updated":"2025-01-14T00:00:00Z","source":"file:/data/registry.json","total_servers":10}`)) - } else { - w.WriteHeader(http.StatusNotFound) - } - })) - }) - - It("should validate successfully", func() { - err := handler.Validate(ctx, mockServer.URL) - Expect(err).NotTo(HaveOccurred()) - }) - }) - - Context("Missing /v0/info endpoint", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - }) - - It("should fail validation", func() { - err := handler.Validate(ctx, mockServer.URL) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to fetch /v0/info")) - }) - }) - - Context("Invalid JSON response", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == toolhiveInfoPath { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{invalid json}`)) - } - })) - }) - - It("should fail validation", func() { - err := handler.Validate(ctx, mockServer.URL) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to parse")) - }) - }) - - Context("Missing version field", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == toolhiveInfoPath { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"last_updated":"2025-01-14T00:00:00Z","total_servers":10}`)) - } - })) - }) - - It("should fail validation", func() { - err := handler.Validate(ctx, mockServer.URL) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing 'version' field")) - }) - }) - - Context("Invalid total_servers value", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == toolhiveInfoPath { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"version":"1.0.0","total_servers":-5}`)) - } - })) - }) - - It("should fail validation", func() { - err := handler.Validate(ctx, mockServer.URL) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid 'total_servers' value")) - }) - }) - - Context("Zero servers is valid", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == toolhiveInfoPath { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"version":"1.0.0","total_servers":0}`)) - } - })) - }) - - It("should validate successfully", func() { - err := handler.Validate(ctx, mockServer.URL) - Expect(err).NotTo(HaveOccurred()) - }) - }) - }) - - Describe("FetchRegistry", func() { - var registryConfig *config.Config - - BeforeEach(func() { - registryConfig = &config.Config{ - Source: config.SourceConfig{ - Type: config.SourceTypeAPI, - API: &config.APIConfig{}, - }, - } - }) - - Context("Successful fetch with server details", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case toolhiveServersPath: - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{ - "servers": [ - {"name": "server1", "description": "Test Server 1", "tier": "official", "status": "active", "transport": "stdio"}, - {"name": "server2", "description": "Test Server 2", "tier": "community", "status": "active", "transport": "sse"} - ], - "total": 2 - }`)) - case "/v0/servers/server1": - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{ - "name": "server1", - "description": "Test Server 1", - "tier": "official", - "status": "active", - "transport": "stdio", - "image": "ghcr.io/test/server1:latest", - "env": {"KEY": "value"} - }`)) - case "/v0/servers/server2": - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{ - "name": "server2", - "description": "Test Server 2", - "tier": "community", - "status": "active", - "transport": "sse", - "image": "ghcr.io/test/server2:v1.0" - }`)) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - registryConfig.Source.API.Endpoint = mockServer.URL - }) - - It("should fetch and convert servers successfully", func() { - result, err := handler.FetchRegistry(ctx, registryConfig) - Expect(err).NotTo(HaveOccurred()) - Expect(result).NotTo(BeNil()) - Expect(result.Registry).NotTo(BeNil()) - Expect(result.Registry.Servers).To(HaveLen(2)) - - // Check server names in the slice - var serverNames []string - for _, server := range result.Registry.Servers { - serverNames = append(serverNames, server.Name) - } - Expect(serverNames).To(ContainElement(ContainSubstring("server1"))) - Expect(serverNames).To(ContainElement(ContainSubstring("server2"))) - - Expect(result.Hash).NotTo(BeEmpty()) - Expect(result.Format).To(Equal(config.SourceFormatToolHive)) - }) - - It("should fetch server details correctly", func() { - result, err := handler.FetchRegistry(ctx, registryConfig) - Expect(err).NotTo(HaveOccurred()) - - // Just verify we have 2 servers and they have packages - Expect(result.Registry.Servers).To(HaveLen(2)) - for _, server := range result.Registry.Servers { - Expect(server.Packages).NotTo(BeEmpty(), "Server %s should have packages", server.Name) - } - }) - }) - - Context("Fallback to summary when detail fetch fails", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == toolhiveServersPath { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{ - "servers": [ - {"name": "server1", "description": "Test Server", "tier": "official", "status": "active", "transport": "stdio"} - ], - "total": 1 - }`)) - } else { - // Server detail endpoint fails - w.WriteHeader(http.StatusNotFound) - } - })) - registryConfig.Source.API.Endpoint = mockServer.URL - }) - - It("should use summary data when detail fetch fails", func() { - result, err := handler.FetchRegistry(ctx, registryConfig) - Expect(err).NotTo(HaveOccurred()) - Expect(result.Registry.Servers).To(HaveLen(1)) - - // Check server name exists in slice - var serverNames []string - for _, server := range result.Registry.Servers { - serverNames = append(serverNames, server.Name) - } - Expect(serverNames).To(ContainElement(ContainSubstring("server1"))) - }) - }) - - Context("Failed to fetch server list", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - registryConfig.Source.API.Endpoint = mockServer.URL - }) - - It("should return error", func() { - _, err := handler.FetchRegistry(ctx, registryConfig) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to fetch from API")) - }) - }) - - Context("Invalid JSON in server list response", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == toolhiveServersPath { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{invalid json}`)) - } - })) - registryConfig.Source.API.Endpoint = mockServer.URL - }) - - It("should return parse error", func() { - _, err := handler.FetchRegistry(ctx, registryConfig) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to parse API response")) - }) - }) - - Context("Empty server list", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == toolhiveServersPath { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"servers": [], "total": 0}`)) - } - })) - registryConfig.Source.API.Endpoint = mockServer.URL - }) - - It("should return empty registry", func() { - result, err := handler.FetchRegistry(ctx, registryConfig) - Expect(err).NotTo(HaveOccurred()) - Expect(result.Registry.Servers).To(BeEmpty()) - }) - }) - }) - - Describe("CurrentHash", func() { - var registryConfig *config.Config - - BeforeEach(func() { - registryConfig = &config.Config{ - Source: config.SourceConfig{ - Type: config.SourceTypeAPI, - API: &config.APIConfig{}, - }, - } - }) - - Context("Successful hash calculation", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == toolhiveServersPath { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"servers": [], "total": 0}`)) - } - })) - registryConfig.Source.API.Endpoint = mockServer.URL - }) - - It("should return hash of response", func() { - hash, err := handler.CurrentHash(ctx, registryConfig) - Expect(err).NotTo(HaveOccurred()) - Expect(hash).NotTo(BeEmpty()) - Expect(hash).To(HaveLen(64)) // SHA256 hex string length - }) - }) - - Context("Failed to fetch", func() { - BeforeEach(func() { - mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - registryConfig.Source.API.Endpoint = mockServer.URL - }) - - It("should return error", func() { - _, err := handler.CurrentHash(ctx, registryConfig) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to fetch from API")) - }) - }) - }) -}) diff --git a/internal/sources/api_types.go b/internal/sources/api_types.go deleted file mode 100644 index 63fde22..0000000 --- a/internal/sources/api_types.go +++ /dev/null @@ -1,61 +0,0 @@ -package sources - -// ToolHive Registry API response types -// -// NOTE: These types are duplicated from github.com/stacklok/toolhive-registry-server/internal/api/v1 -// to avoid circular dependency issues. Once thv-operator is moved to a separate repository, -// we can import these types directly from toolhive-registry-server. -// -// TODO: When thv-operator is extracted to its own repo, remove these duplicates and import from: -// github.com/stacklok/toolhive-registry-server/internal/api/v1 - -// RegistryInfoResponse represents the registry information response from /v0/info -type RegistryInfoResponse struct { - Version string `json:"version"` - LastUpdated string `json:"last_updated"` - Source string `json:"source"` - TotalServers int `json:"total_servers"` -} - -// ServerSummaryResponse represents a server in list API responses (summary view) -type ServerSummaryResponse struct { - Name string `json:"name"` - Description string `json:"description"` - Tier string `json:"tier"` - Status string `json:"status"` - Transport string `json:"transport"` - ToolsCount int `json:"tools_count"` -} - -// EnvVarDetail represents detailed environment variable information -type EnvVarDetail struct { - Name string `json:"name"` - Description string `json:"description"` - Required bool `json:"required"` - Default string `json:"default,omitempty"` - Secret bool `json:"secret,omitempty"` -} - -// ServerDetailResponse represents a server in detail API responses (full view) -type ServerDetailResponse struct { - Name string `json:"name"` - Description string `json:"description"` - Tier string `json:"tier"` - Status string `json:"status"` - Transport string `json:"transport"` - Tools []string `json:"tools"` - EnvVars []EnvVarDetail `json:"env_vars,omitempty"` - Permissions map[string]interface{} `json:"permissions,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - RepositoryURL string `json:"repository_url,omitempty"` - Tags []string `json:"tags,omitempty"` - Args []string `json:"args,omitempty"` - Volumes map[string]interface{} `json:"volumes,omitempty"` - Image string `json:"image,omitempty"` -} - -// ListServersResponse represents the servers list response from /v0/servers -type ListServersResponse struct { - Servers []ServerSummaryResponse `json:"servers"` - Total int `json:"total"` -}