You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							381 lines
						
					
					
						
							11 KiB
						
					
					
				
			
		
		
		
			
			
			
		
		
	
	
							381 lines
						
					
					
						
							11 KiB
						
					
					
				| package schema | |
| 
 | |
| import ( | |
| 	"bytes" | |
| 	"encoding/json" | |
| 	"fmt" | |
| 	"io" | |
| 	"net/http" | |
| 	"sync" | |
| 	"time" | |
| ) | |
| 
 | |
| // RegistryClient provides access to a Confluent Schema Registry | |
| type RegistryClient struct { | |
| 	baseURL    string | |
| 	httpClient *http.Client | |
| 
 | |
| 	// Caching | |
| 	schemaCache      map[uint32]*CachedSchema  // schema ID -> schema | |
| 	subjectCache     map[string]*CachedSubject // subject -> latest version info | |
| 	negativeCache    map[string]time.Time      // subject -> time when 404 was cached | |
| 	cacheMu          sync.RWMutex | |
| 	cacheTTL         time.Duration | |
| 	negativeCacheTTL time.Duration // TTL for negative (404) cache entries | |
| } | |
| 
 | |
| // CachedSchema represents a cached schema with metadata | |
| type CachedSchema struct { | |
| 	ID       uint32    `json:"id"` | |
| 	Schema   string    `json:"schema"` | |
| 	Subject  string    `json:"subject"` | |
| 	Version  int       `json:"version"` | |
| 	Format   Format    `json:"-"` // Derived from schema content | |
| 	CachedAt time.Time `json:"-"` | |
| } | |
| 
 | |
| // CachedSubject represents cached subject information | |
| type CachedSubject struct { | |
| 	Subject  string    `json:"subject"` | |
| 	LatestID uint32    `json:"id"` | |
| 	Version  int       `json:"version"` | |
| 	Schema   string    `json:"schema"` | |
| 	CachedAt time.Time `json:"-"` | |
| } | |
| 
 | |
| // RegistryConfig holds configuration for the Schema Registry client | |
| type RegistryConfig struct { | |
| 	URL        string | |
| 	Username   string // Optional basic auth | |
| 	Password   string // Optional basic auth | |
| 	Timeout    time.Duration | |
| 	CacheTTL   time.Duration | |
| 	MaxRetries int | |
| } | |
| 
 | |
| // NewRegistryClient creates a new Schema Registry client | |
| func NewRegistryClient(config RegistryConfig) *RegistryClient { | |
| 	if config.Timeout == 0 { | |
| 		config.Timeout = 30 * time.Second | |
| 	} | |
| 	if config.CacheTTL == 0 { | |
| 		config.CacheTTL = 5 * time.Minute | |
| 	} | |
| 
 | |
| 	httpClient := &http.Client{ | |
| 		Timeout: config.Timeout, | |
| 	} | |
| 
 | |
| 	return &RegistryClient{ | |
| 		baseURL:          config.URL, | |
| 		httpClient:       httpClient, | |
| 		schemaCache:      make(map[uint32]*CachedSchema), | |
| 		subjectCache:     make(map[string]*CachedSubject), | |
| 		negativeCache:    make(map[string]time.Time), | |
| 		cacheTTL:         config.CacheTTL, | |
| 		negativeCacheTTL: 2 * time.Minute, // Cache 404s for 2 minutes | |
| 	} | |
| } | |
| 
 | |
| // GetSchemaByID retrieves a schema by its ID | |
| func (rc *RegistryClient) GetSchemaByID(schemaID uint32) (*CachedSchema, error) { | |
| 	// Check cache first | |
| 	rc.cacheMu.RLock() | |
| 	if cached, exists := rc.schemaCache[schemaID]; exists { | |
| 		if time.Since(cached.CachedAt) < rc.cacheTTL { | |
| 			rc.cacheMu.RUnlock() | |
| 			return cached, nil | |
| 		} | |
| 	} | |
| 	rc.cacheMu.RUnlock() | |
| 
 | |
| 	// Fetch from registry | |
| 	url := fmt.Sprintf("%s/schemas/ids/%d", rc.baseURL, schemaID) | |
| 	resp, err := rc.httpClient.Get(url) | |
| 	if err != nil { | |
| 		return nil, fmt.Errorf("failed to fetch schema %d: %w", schemaID, err) | |
| 	} | |
| 	defer resp.Body.Close() | |
| 
 | |
| 	if resp.StatusCode != http.StatusOK { | |
| 		body, _ := io.ReadAll(resp.Body) | |
| 		return nil, fmt.Errorf("schema registry error %d: %s", resp.StatusCode, string(body)) | |
| 	} | |
| 
 | |
| 	var schemaResp struct { | |
| 		Schema  string `json:"schema"` | |
| 		Subject string `json:"subject"` | |
| 		Version int    `json:"version"` | |
| 	} | |
| 
 | |
| 	if err := json.NewDecoder(resp.Body).Decode(&schemaResp); err != nil { | |
| 		return nil, fmt.Errorf("failed to decode schema response: %w", err) | |
| 	} | |
| 
 | |
| 	// Determine format from schema content | |
| 	format := rc.detectSchemaFormat(schemaResp.Schema) | |
| 
 | |
| 	cached := &CachedSchema{ | |
| 		ID:       schemaID, | |
| 		Schema:   schemaResp.Schema, | |
| 		Subject:  schemaResp.Subject, | |
| 		Version:  schemaResp.Version, | |
| 		Format:   format, | |
| 		CachedAt: time.Now(), | |
| 	} | |
| 
 | |
| 	// Update cache | |
| 	rc.cacheMu.Lock() | |
| 	rc.schemaCache[schemaID] = cached | |
| 	rc.cacheMu.Unlock() | |
| 
 | |
| 	return cached, nil | |
| } | |
| 
 | |
| // GetLatestSchema retrieves the latest schema for a subject | |
| func (rc *RegistryClient) GetLatestSchema(subject string) (*CachedSubject, error) { | |
| 	// Check positive cache first | |
| 	rc.cacheMu.RLock() | |
| 	if cached, exists := rc.subjectCache[subject]; exists { | |
| 		if time.Since(cached.CachedAt) < rc.cacheTTL { | |
| 			rc.cacheMu.RUnlock() | |
| 			return cached, nil | |
| 		} | |
| 	} | |
| 
 | |
| 	// Check negative cache (404 cache) | |
| 	if cachedAt, exists := rc.negativeCache[subject]; exists { | |
| 		if time.Since(cachedAt) < rc.negativeCacheTTL { | |
| 			rc.cacheMu.RUnlock() | |
| 			return nil, fmt.Errorf("schema registry error 404: subject not found (cached)") | |
| 		} | |
| 	} | |
| 	rc.cacheMu.RUnlock() | |
| 
 | |
| 	// Fetch from registry | |
| 	url := fmt.Sprintf("%s/subjects/%s/versions/latest", rc.baseURL, subject) | |
| 	resp, err := rc.httpClient.Get(url) | |
| 	if err != nil { | |
| 		return nil, fmt.Errorf("failed to fetch latest schema for %s: %w", subject, err) | |
| 	} | |
| 	defer resp.Body.Close() | |
| 
 | |
| 	if resp.StatusCode != http.StatusOK { | |
| 		body, _ := io.ReadAll(resp.Body) | |
| 
 | |
| 		// Cache 404 responses to avoid repeated lookups | |
| 		if resp.StatusCode == http.StatusNotFound { | |
| 			rc.cacheMu.Lock() | |
| 			rc.negativeCache[subject] = time.Now() | |
| 			rc.cacheMu.Unlock() | |
| 		} | |
| 
 | |
| 		return nil, fmt.Errorf("schema registry error %d: %s", resp.StatusCode, string(body)) | |
| 	} | |
| 
 | |
| 	var schemaResp struct { | |
| 		ID      uint32 `json:"id"` | |
| 		Schema  string `json:"schema"` | |
| 		Subject string `json:"subject"` | |
| 		Version int    `json:"version"` | |
| 	} | |
| 
 | |
| 	if err := json.NewDecoder(resp.Body).Decode(&schemaResp); err != nil { | |
| 		return nil, fmt.Errorf("failed to decode schema response: %w", err) | |
| 	} | |
| 
 | |
| 	cached := &CachedSubject{ | |
| 		Subject:  subject, | |
| 		LatestID: schemaResp.ID, | |
| 		Version:  schemaResp.Version, | |
| 		Schema:   schemaResp.Schema, | |
| 		CachedAt: time.Now(), | |
| 	} | |
| 
 | |
| 	// Update cache and clear negative cache entry | |
| 	rc.cacheMu.Lock() | |
| 	rc.subjectCache[subject] = cached | |
| 	delete(rc.negativeCache, subject) // Clear any cached 404 | |
| 	rc.cacheMu.Unlock() | |
| 
 | |
| 	return cached, nil | |
| } | |
| 
 | |
| // RegisterSchema registers a new schema for a subject | |
| func (rc *RegistryClient) RegisterSchema(subject, schema string) (uint32, error) { | |
| 	url := fmt.Sprintf("%s/subjects/%s/versions", rc.baseURL, subject) | |
| 
 | |
| 	reqBody := map[string]string{ | |
| 		"schema": schema, | |
| 	} | |
| 
 | |
| 	jsonData, err := json.Marshal(reqBody) | |
| 	if err != nil { | |
| 		return 0, fmt.Errorf("failed to marshal schema request: %w", err) | |
| 	} | |
| 
 | |
| 	resp, err := rc.httpClient.Post(url, "application/json", bytes.NewBuffer(jsonData)) | |
| 	if err != nil { | |
| 		return 0, fmt.Errorf("failed to register schema: %w", err) | |
| 	} | |
| 	defer resp.Body.Close() | |
| 
 | |
| 	if resp.StatusCode != http.StatusOK { | |
| 		body, _ := io.ReadAll(resp.Body) | |
| 		return 0, fmt.Errorf("schema registry error %d: %s", resp.StatusCode, string(body)) | |
| 	} | |
| 
 | |
| 	var regResp struct { | |
| 		ID uint32 `json:"id"` | |
| 	} | |
| 
 | |
| 	if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil { | |
| 		return 0, fmt.Errorf("failed to decode registration response: %w", err) | |
| 	} | |
| 
 | |
| 	// Invalidate caches for this subject | |
| 	rc.cacheMu.Lock() | |
| 	delete(rc.subjectCache, subject) | |
| 	delete(rc.negativeCache, subject) // Clear any cached 404 | |
| 	// Note: we don't cache the new schema here since we don't have full metadata | |
| 	rc.cacheMu.Unlock() | |
| 
 | |
| 	return regResp.ID, nil | |
| } | |
| 
 | |
| // CheckCompatibility checks if a schema is compatible with the subject | |
| func (rc *RegistryClient) CheckCompatibility(subject, schema string) (bool, error) { | |
| 	url := fmt.Sprintf("%s/compatibility/subjects/%s/versions/latest", rc.baseURL, subject) | |
| 
 | |
| 	reqBody := map[string]string{ | |
| 		"schema": schema, | |
| 	} | |
| 
 | |
| 	jsonData, err := json.Marshal(reqBody) | |
| 	if err != nil { | |
| 		return false, fmt.Errorf("failed to marshal compatibility request: %w", err) | |
| 	} | |
| 
 | |
| 	resp, err := rc.httpClient.Post(url, "application/json", bytes.NewBuffer(jsonData)) | |
| 	if err != nil { | |
| 		return false, fmt.Errorf("failed to check compatibility: %w", err) | |
| 	} | |
| 	defer resp.Body.Close() | |
| 
 | |
| 	if resp.StatusCode != http.StatusOK { | |
| 		body, _ := io.ReadAll(resp.Body) | |
| 		return false, fmt.Errorf("schema registry error %d: %s", resp.StatusCode, string(body)) | |
| 	} | |
| 
 | |
| 	var compatResp struct { | |
| 		IsCompatible bool `json:"is_compatible"` | |
| 	} | |
| 
 | |
| 	if err := json.NewDecoder(resp.Body).Decode(&compatResp); err != nil { | |
| 		return false, fmt.Errorf("failed to decode compatibility response: %w", err) | |
| 	} | |
| 
 | |
| 	return compatResp.IsCompatible, nil | |
| } | |
| 
 | |
| // ListSubjects returns all subjects in the registry | |
| func (rc *RegistryClient) ListSubjects() ([]string, error) { | |
| 	url := fmt.Sprintf("%s/subjects", rc.baseURL) | |
| 	resp, err := rc.httpClient.Get(url) | |
| 	if err != nil { | |
| 		return nil, fmt.Errorf("failed to list subjects: %w", err) | |
| 	} | |
| 	defer resp.Body.Close() | |
| 
 | |
| 	if resp.StatusCode != http.StatusOK { | |
| 		body, _ := io.ReadAll(resp.Body) | |
| 		return nil, fmt.Errorf("schema registry error %d: %s", resp.StatusCode, string(body)) | |
| 	} | |
| 
 | |
| 	var subjects []string | |
| 	if err := json.NewDecoder(resp.Body).Decode(&subjects); err != nil { | |
| 		return nil, fmt.Errorf("failed to decode subjects response: %w", err) | |
| 	} | |
| 
 | |
| 	return subjects, nil | |
| } | |
| 
 | |
| // ClearCache clears all cached schemas and subjects | |
| func (rc *RegistryClient) ClearCache() { | |
| 	rc.cacheMu.Lock() | |
| 	defer rc.cacheMu.Unlock() | |
| 
 | |
| 	rc.schemaCache = make(map[uint32]*CachedSchema) | |
| 	rc.subjectCache = make(map[string]*CachedSubject) | |
| 	rc.negativeCache = make(map[string]time.Time) | |
| } | |
| 
 | |
| // GetCacheStats returns cache statistics | |
| func (rc *RegistryClient) GetCacheStats() (schemaCount, subjectCount, negativeCacheCount int) { | |
| 	rc.cacheMu.RLock() | |
| 	defer rc.cacheMu.RUnlock() | |
| 
 | |
| 	return len(rc.schemaCache), len(rc.subjectCache), len(rc.negativeCache) | |
| } | |
| 
 | |
| // detectSchemaFormat attempts to determine the schema format from content | |
| func (rc *RegistryClient) detectSchemaFormat(schema string) Format { | |
| 	// Try to parse as JSON first (Avro schemas are JSON) | |
| 	var jsonObj interface{} | |
| 	if err := json.Unmarshal([]byte(schema), &jsonObj); err == nil { | |
| 		// Check for Avro-specific fields | |
| 		if schemaMap, ok := jsonObj.(map[string]interface{}); ok { | |
| 			if schemaType, exists := schemaMap["type"]; exists { | |
| 				if typeStr, ok := schemaType.(string); ok { | |
| 					// Common Avro types | |
| 					avroTypes := []string{"record", "enum", "array", "map", "union", "fixed"} | |
| 					for _, avroType := range avroTypes { | |
| 						if typeStr == avroType { | |
| 							return FormatAvro | |
| 						} | |
| 					} | |
| 					// Common JSON Schema types (that are not Avro types) | |
| 					// Note: "string" is ambiguous - it could be Avro primitive or JSON Schema | |
| 					// We need to check other indicators first | |
| 					jsonSchemaTypes := []string{"object", "number", "integer", "boolean", "null"} | |
| 					for _, jsonSchemaType := range jsonSchemaTypes { | |
| 						if typeStr == jsonSchemaType { | |
| 							return FormatJSONSchema | |
| 						} | |
| 					} | |
| 				} | |
| 			} | |
| 			// Check for JSON Schema indicators | |
| 			if _, exists := schemaMap["$schema"]; exists { | |
| 				return FormatJSONSchema | |
| 			} | |
| 			// Check for JSON Schema properties field | |
| 			if _, exists := schemaMap["properties"]; exists { | |
| 				return FormatJSONSchema | |
| 			} | |
| 		} | |
| 		// Default JSON-based schema to Avro only if it doesn't look like JSON Schema | |
| 		return FormatAvro | |
| 	} | |
| 
 | |
| 	// Check for Protobuf (typically not JSON) | |
| 	// Protobuf schemas in Schema Registry are usually stored as descriptors | |
| 	// For now, assume non-JSON schemas are Protobuf | |
| 	return FormatProtobuf | |
| } | |
| 
 | |
| // HealthCheck verifies the registry is accessible | |
| func (rc *RegistryClient) HealthCheck() error { | |
| 	url := fmt.Sprintf("%s/subjects", rc.baseURL) | |
| 	resp, err := rc.httpClient.Get(url) | |
| 	if err != nil { | |
| 		return fmt.Errorf("schema registry health check failed: %w", err) | |
| 	} | |
| 	defer resp.Body.Close() | |
| 
 | |
| 	if resp.StatusCode != http.StatusOK { | |
| 		return fmt.Errorf("schema registry health check failed with status %d", resp.StatusCode) | |
| 	} | |
| 
 | |
| 	return nil | |
| }
 |