Skip to main content

laminar_connectors/kafka/
schema_registry.rs

1//! Confluent Schema Registry client.
2//!
3//! [`SchemaRegistryClient`] provides a lightweight async REST client for
4//! the Confluent Schema Registry API, with in-memory caching, arrow
5//! schema conversion, and compatibility checking.
6
7use std::time::{Duration, Instant};
8
9use quick_cache::sync::Cache;
10
11use arrow_schema::{DataType, SchemaRef};
12use reqwest::Client;
13use serde::{Deserialize, Serialize};
14
15use crate::error::{ConnectorError, SerdeError};
16use crate::kafka::config::{CompatibilityLevel, SrAuth};
17
18const SCHEMA_REGISTRY_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
19const SCHEMA_REGISTRY_READ_TIMEOUT: Duration = Duration::from_secs(2);
20const SCHEMA_REGISTRY_REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
21
22#[derive(Clone, Copy)]
23struct SchemaRegistryHttpTimeouts {
24    connect: Duration,
25    read: Duration,
26    request: Duration,
27}
28
29const SCHEMA_REGISTRY_HTTP_TIMEOUTS: SchemaRegistryHttpTimeouts = SchemaRegistryHttpTimeouts {
30    connect: SCHEMA_REGISTRY_CONNECT_TIMEOUT,
31    read: SCHEMA_REGISTRY_READ_TIMEOUT,
32    request: SCHEMA_REGISTRY_REQUEST_TIMEOUT,
33};
34
35fn http_client_builder(timeouts: SchemaRegistryHttpTimeouts) -> reqwest::ClientBuilder {
36    Client::builder()
37        .connect_timeout(timeouts.connect)
38        .read_timeout(timeouts.read)
39        .timeout(timeouts.request)
40}
41
42fn build_http_client(timeouts: SchemaRegistryHttpTimeouts) -> Result<Client, ConnectorError> {
43    http_client_builder(timeouts).build().map_err(|e| {
44        ConnectorError::ConfigurationError(format!(
45            "failed to build Schema Registry HTTP client: {e}"
46        ))
47    })
48}
49
50fn schema_registry_http_error(
51    operation: &str,
52    status: reqwest::StatusCode,
53    detail: &str,
54) -> ConnectorError {
55    let message = format!("schema registry {operation} failed: {status} {detail}");
56    if status == reqwest::StatusCode::REQUEST_TIMEOUT
57        || status == reqwest::StatusCode::TOO_MANY_REQUESTS
58        || status.is_server_error()
59    {
60        ConnectorError::ConnectionFailed(message)
61    } else {
62        ConnectorError::ConfigurationError(message)
63    }
64}
65
66fn schema_registry_request_error(operation: &str, error: &reqwest::Error) -> ConnectorError {
67    let message = format!("schema registry {operation} failed: {error}");
68    if error.is_builder() {
69        ConnectorError::ConfigurationError(message)
70    } else {
71        ConnectorError::ConnectionFailed(message)
72    }
73}
74
75/// Schema type as reported by the Schema Registry.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SchemaType {
78    /// Apache Avro schema.
79    Avro,
80    /// Protocol Buffers schema.
81    Protobuf,
82    /// JSON Schema.
83    Json,
84}
85
86impl std::str::FromStr for SchemaType {
87    type Err = ConnectorError;
88
89    fn from_str(s: &str) -> Result<Self, Self::Err> {
90        match s.to_uppercase().as_str() {
91            "AVRO" => Ok(SchemaType::Avro),
92            "PROTOBUF" => Ok(SchemaType::Protobuf),
93            "JSON" => Ok(SchemaType::Json),
94            other => Err(ConnectorError::ConfigurationError(format!(
95                "unknown schema type: '{other}'"
96            ))),
97        }
98    }
99}
100
101impl std::fmt::Display for SchemaType {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            SchemaType::Avro => write!(f, "AVRO"),
105            SchemaType::Protobuf => write!(f, "PROTOBUF"),
106            SchemaType::Json => write!(f, "JSON"),
107        }
108    }
109}
110
111fn require_avro_mutation(schema_type: SchemaType) -> Result<(), ConnectorError> {
112    if schema_type == SchemaType::Avro {
113        Ok(())
114    } else {
115        Err(ConnectorError::ConfigurationError(format!(
116            "Kafka Schema Registry registration supports AVRO only, got {schema_type}"
117        )))
118    }
119}
120
121/// Configuration for the Schema Registry cache.
122#[derive(Debug, Clone)]
123pub struct SchemaRegistryCacheConfig {
124    /// Maximum number of cached schemas. Default: 1000.
125    pub max_entries: usize,
126    /// TTL for cache entries. `None` means no expiry. Default: 1 hour.
127    pub ttl: Option<Duration>,
128}
129
130impl Default for SchemaRegistryCacheConfig {
131    fn default() -> Self {
132        Self {
133            max_entries: 1000,
134            ttl: Some(Duration::from_secs(3600)),
135        }
136    }
137}
138
139/// A cached schema entry from the Schema Registry.
140#[derive(Debug, Clone)]
141pub struct CachedSchema {
142    /// Schema Registry schema ID.
143    pub id: i32,
144    /// Schema version within its subject.
145    pub version: i32,
146    /// The schema type.
147    pub schema_type: SchemaType,
148    /// Raw schema string (e.g., Avro JSON).
149    pub schema_str: String,
150    /// Derived Arrow schema for `RecordBatch` construction.
151    pub arrow_schema: SchemaRef,
152    /// When this entry was inserted or last accessed.
153    inserted_at: Instant,
154}
155
156/// Result of a compatibility check.
157#[derive(Debug, Clone)]
158pub struct CompatibilityResult {
159    /// Whether the schema is compatible.
160    pub is_compatible: bool,
161    /// Incompatibility reasons (if any).
162    pub messages: Vec<String>,
163}
164
165/// Async client for the Confluent Schema Registry REST API.
166///
167/// Provides schema lookup by ID and subject, caching with LRU eviction
168/// and TTL, compatibility checking, and Avro-to-Arrow schema conversion.
169pub struct SchemaRegistryClient {
170    client: Client,
171    base_url: String,
172    auth: Option<SrAuth>,
173    /// Cache by schema ID (`quick_cache`, S3-FIFO-style eviction).
174    cache: Cache<i32, CachedSchema>,
175    /// Cache by subject name (latest version).
176    subject_cache: Cache<String, CachedSchema>,
177    /// Cache configuration.
178    cache_config: SchemaRegistryCacheConfig,
179}
180
181// -- Schema Registry REST API response types --
182
183#[derive(Deserialize)]
184struct SchemaByIdResponse {
185    schema: String,
186    #[serde(default = "default_schema_type")]
187    #[serde(rename = "schemaType")]
188    schema_type: String,
189}
190
191#[derive(Deserialize)]
192struct SchemaVersionResponse {
193    id: i32,
194    version: i32,
195    schema: String,
196    #[serde(default = "default_schema_type")]
197    #[serde(rename = "schemaType")]
198    schema_type: String,
199}
200
201#[derive(Deserialize)]
202struct CompatibilityResponse {
203    is_compatible: bool,
204    #[serde(default)]
205    messages: Vec<String>,
206}
207
208#[derive(Deserialize)]
209struct ConfigResponse {
210    #[serde(rename = "compatibilityLevel")]
211    compatibility_level: String,
212}
213
214#[derive(Serialize)]
215struct CompatibilityRequest {
216    schema: String,
217    #[serde(rename = "schemaType")]
218    schema_type: String,
219}
220
221#[derive(Serialize)]
222struct ConfigUpdateRequest {
223    compatibility: String,
224}
225
226#[derive(Serialize)]
227struct RegisterSchemaRequest {
228    schema: String,
229    #[serde(rename = "schemaType")]
230    schema_type: String,
231}
232
233#[derive(Deserialize)]
234struct RegisterSchemaResponse {
235    id: i32,
236}
237
238fn default_schema_type() -> String {
239    "AVRO".to_string()
240}
241
242impl SchemaRegistryClient {
243    /// Creates a new Schema Registry client with default cache config.
244    ///
245    /// # Errors
246    ///
247    /// Returns `ConnectorError::ConfigurationError` if the HTTP client cannot be built.
248    pub fn new(base_url: impl Into<String>, auth: Option<SrAuth>) -> Result<Self, ConnectorError> {
249        Self::with_cache_config(base_url, auth, SchemaRegistryCacheConfig::default())
250    }
251
252    /// Creates a TLS client (CA cert only). Delegates to [`Self::with_tls_mtls`].
253    ///
254    /// # Errors
255    ///
256    /// Returns `ConnectorError::ConfigurationError` if the CA cert cannot be read.
257    pub fn with_tls(
258        base_url: impl Into<String>,
259        auth: Option<SrAuth>,
260        ca_cert_path: &str,
261    ) -> Result<Self, ConnectorError> {
262        Self::with_tls_mtls(base_url, auth, ca_cert_path, None, None)
263    }
264
265    /// Creates a client with full TLS/mTLS support.
266    ///
267    /// # Errors
268    ///
269    /// Returns `ConnectorError::ConfigurationError` if any cert/key file
270    /// cannot be read or parsed.
271    pub fn with_tls_mtls(
272        base_url: impl Into<String>,
273        auth: Option<SrAuth>,
274        ca_cert_path: &str,
275        client_cert_path: Option<&str>,
276        client_key_path: Option<&str>,
277    ) -> Result<Self, ConnectorError> {
278        let pem = std::fs::read(ca_cert_path).map_err(|e| {
279            ConnectorError::ConfigurationError(format!(
280                "failed to read SR CA cert at '{ca_cert_path}': {e}"
281            ))
282        })?;
283        let cert = reqwest::tls::Certificate::from_pem(&pem).map_err(|e| {
284            ConnectorError::ConfigurationError(format!(
285                "invalid PEM CA cert at '{ca_cert_path}': {e}"
286            ))
287        })?;
288
289        let mut builder =
290            http_client_builder(SCHEMA_REGISTRY_HTTP_TIMEOUTS).add_root_certificate(cert);
291
292        if client_cert_path.is_some() != client_key_path.is_some() {
293            return Err(ConnectorError::ConfigurationError(
294                "mTLS requires both client cert and key — only one was provided".into(),
295            ));
296        }
297        if let (Some(cert_path), Some(key_path)) = (client_cert_path, client_key_path) {
298            let mut identity_pem = std::fs::read(cert_path).map_err(|e| {
299                ConnectorError::ConfigurationError(format!(
300                    "failed to read SR client cert at '{cert_path}': {e}"
301                ))
302            })?;
303            let key_pem = std::fs::read(key_path).map_err(|e| {
304                ConnectorError::ConfigurationError(format!(
305                    "failed to read SR client key at '{key_path}': {e}"
306                ))
307            })?;
308            // reqwest Identity expects cert + key concatenated in PEM format.
309            identity_pem.extend_from_slice(&key_pem);
310            let identity = reqwest::tls::Identity::from_pem(&identity_pem).map_err(|e| {
311                ConnectorError::ConfigurationError(format!("invalid client cert/key PEM: {e}"))
312            })?;
313            builder = builder.identity(identity);
314        }
315
316        let client = builder.build().map_err(|e| {
317            ConnectorError::ConfigurationError(format!("failed to build TLS client: {e}"))
318        })?;
319
320        Ok(Self::from_http_client(
321            base_url,
322            auth,
323            SchemaRegistryCacheConfig::default(),
324            client,
325        ))
326    }
327
328    /// Creates a new Schema Registry client with custom cache config.
329    ///
330    /// # Errors
331    ///
332    /// Returns `ConnectorError::ConfigurationError` if the HTTP client cannot be built.
333    pub fn with_cache_config(
334        base_url: impl Into<String>,
335        auth: Option<SrAuth>,
336        cache_config: SchemaRegistryCacheConfig,
337    ) -> Result<Self, ConnectorError> {
338        Self::with_cache_config_and_timeouts(
339            base_url,
340            auth,
341            cache_config,
342            SCHEMA_REGISTRY_HTTP_TIMEOUTS,
343        )
344    }
345
346    fn with_cache_config_and_timeouts(
347        base_url: impl Into<String>,
348        auth: Option<SrAuth>,
349        cache_config: SchemaRegistryCacheConfig,
350        timeouts: SchemaRegistryHttpTimeouts,
351    ) -> Result<Self, ConnectorError> {
352        let client = build_http_client(timeouts)?;
353        Ok(Self::from_http_client(base_url, auth, cache_config, client))
354    }
355
356    fn from_http_client(
357        base_url: impl Into<String>,
358        auth: Option<SrAuth>,
359        cache_config: SchemaRegistryCacheConfig,
360        client: Client,
361    ) -> Self {
362        let cache = Cache::new(cache_config.max_entries);
363        // Subject cache is small — one entry per subject
364        let subject_cache = Cache::new(256);
365        Self {
366            client,
367            base_url: base_url.into().trim_end_matches('/').to_string(),
368            auth,
369            cache,
370            subject_cache,
371            cache_config,
372        }
373    }
374
375    /// Returns the base URL of the Schema Registry.
376    #[must_use]
377    pub fn base_url(&self) -> &str {
378        &self.base_url
379    }
380
381    /// Returns `true` if authentication is configured.
382    #[must_use]
383    pub fn has_auth(&self) -> bool {
384        self.auth.is_some()
385    }
386
387    /// Returns the cache configuration.
388    #[must_use]
389    pub fn cache_config(&self) -> &SchemaRegistryCacheConfig {
390        &self.cache_config
391    }
392
393    /// Inserts a schema into the cache.
394    ///
395    /// `quick_cache` handles eviction internally (S3-FIFO-style).
396    fn cache_insert(&self, id: i32, mut schema: CachedSchema) {
397        schema.inserted_at = Instant::now();
398        self.cache.insert(id, schema);
399    }
400
401    /// Gets from cache, returning `None` if expired.
402    ///
403    /// TTL is checked lazily on access — expired entries are removed
404    /// and treated as cache misses.
405    fn cache_get(&self, id: i32) -> Option<CachedSchema> {
406        let schema = self.cache.get(&id)?;
407        if let Some(ttl) = self.cache_config.ttl {
408            if schema.inserted_at.elapsed() > ttl {
409                self.cache.remove(&id);
410                return None;
411            }
412        }
413        // quick_cache's get() already promotes the entry in the eviction policy
414        Some(schema)
415    }
416
417    /// Fetches a schema by its global ID.
418    ///
419    /// Results are cached for subsequent lookups.
420    ///
421    /// # Errors
422    ///
423    /// Returns `ConnectorError` if the HTTP request fails or the schema
424    /// cannot be parsed.
425    pub async fn get_schema_by_id(&self, id: i32) -> Result<CachedSchema, ConnectorError> {
426        if let Some(cached) = self.cache_get(id) {
427            return Ok(cached);
428        }
429
430        let url = format!("{}/schemas/ids/{}", self.base_url, id);
431        let operation = format!("fetch schema ID {id}");
432        let resp: SchemaByIdResponse = self.get_json(&url, &operation).await?;
433
434        let schema_type: SchemaType = resp.schema_type.parse()?;
435        let arrow_schema = schema_to_arrow(schema_type, &resp.schema)?;
436
437        let cached = CachedSchema {
438            id,
439            version: 0, // not available from this endpoint
440            schema_type,
441            schema_str: resp.schema,
442            arrow_schema,
443            inserted_at: Instant::now(),
444        };
445        self.cache_insert(id, cached.clone());
446        Ok(cached)
447    }
448
449    /// Fetches the latest schema version for a subject.
450    ///
451    /// # Errors
452    ///
453    /// Returns `ConnectorError` if the HTTP request fails.
454    pub async fn get_latest_schema(&self, subject: &str) -> Result<CachedSchema, ConnectorError> {
455        let url = format!("{}/subjects/{}/versions/latest", self.base_url, subject);
456        let operation = format!("fetch latest schema for subject '{subject}'");
457        let resp: SchemaVersionResponse = self.get_json(&url, &operation).await?;
458
459        let schema_type: SchemaType = resp.schema_type.parse()?;
460        let arrow_schema = schema_to_arrow(schema_type, &resp.schema)?;
461
462        let cached = CachedSchema {
463            id: resp.id,
464            version: resp.version,
465            schema_type,
466            schema_str: resp.schema,
467            arrow_schema,
468            inserted_at: Instant::now(),
469        };
470
471        self.cache_insert(resp.id, cached.clone());
472        self.subject_cache
473            .insert(subject.to_string(), cached.clone());
474        Ok(cached)
475    }
476
477    /// Fetches a specific schema version for a subject.
478    ///
479    /// # Errors
480    ///
481    /// Returns `ConnectorError` if the HTTP request fails.
482    pub async fn get_schema_version(
483        &self,
484        subject: &str,
485        version: i32,
486    ) -> Result<CachedSchema, ConnectorError> {
487        let url = format!(
488            "{}/subjects/{}/versions/{}",
489            self.base_url, subject, version
490        );
491        let operation = format!("fetch version {version} for subject '{subject}'");
492        let resp: SchemaVersionResponse = self.get_json(&url, &operation).await?;
493
494        let schema_type: SchemaType = resp.schema_type.parse()?;
495        let arrow_schema = schema_to_arrow(schema_type, &resp.schema)?;
496
497        let cached = CachedSchema {
498            id: resp.id,
499            version: resp.version,
500            schema_type,
501            schema_str: resp.schema,
502            arrow_schema,
503            inserted_at: Instant::now(),
504        };
505        self.cache_insert(resp.id, cached.clone());
506        Ok(cached)
507    }
508
509    /// Checks compatibility of an Avro schema against the latest version.
510    ///
511    /// # Errors
512    ///
513    /// Returns `ConnectorError` if the HTTP request fails.
514    pub async fn check_compatibility(
515        &self,
516        subject: &str,
517        schema_str: &str,
518    ) -> Result<CompatibilityResult, ConnectorError> {
519        let url = format!(
520            "{}/compatibility/subjects/{}/versions/latest",
521            self.base_url, subject
522        );
523
524        let body = CompatibilityRequest {
525            schema: schema_str.to_string(),
526            schema_type: SchemaType::Avro.to_string(),
527        };
528
529        let mut req = self.client.post(&url).json(&body);
530        if let Some(ref auth) = self.auth {
531            req = req.basic_auth(&auth.username, Some(&auth.password));
532        }
533
534        let operation = format!("compatibility check for subject '{subject}'");
535        let resp = req
536            .send()
537            .await
538            .map_err(|error| schema_registry_request_error(&operation, &error))?;
539
540        if !resp.status().is_success() {
541            let status = resp.status();
542            let text = resp.text().await.unwrap_or_default();
543            if status == reqwest::StatusCode::NOT_FOUND {
544                return Ok(CompatibilityResult {
545                    is_compatible: true,
546                    messages: Vec::new(),
547                });
548            }
549            return Err(schema_registry_http_error(&operation, status, &text));
550        }
551
552        let result: CompatibilityResponse = resp.json().await.map_err(|e| {
553            ConnectorError::Internal(format!(
554                "schema registry {operation} returned an invalid response: {e}"
555            ))
556        })?;
557
558        Ok(CompatibilityResult {
559            is_compatible: result.is_compatible,
560            messages: result.messages,
561        })
562    }
563
564    /// Gets the compatibility level for a subject.
565    ///
566    /// # Errors
567    ///
568    /// Returns `ConnectorError` if the HTTP request fails.
569    pub async fn get_compatibility_level(
570        &self,
571        subject: &str,
572    ) -> Result<CompatibilityLevel, ConnectorError> {
573        let url = format!("{}/config/{}", self.base_url, subject);
574        let operation = format!("fetch compatibility config for subject '{subject}'");
575        let resp: ConfigResponse = self.get_json(&url, &operation).await?;
576        resp.compatibility_level.parse()
577    }
578
579    /// Sets the compatibility level for a subject.
580    ///
581    /// # Errors
582    ///
583    /// Returns `ConnectorError` if the HTTP request fails.
584    pub async fn set_compatibility_level(
585        &self,
586        subject: &str,
587        level: CompatibilityLevel,
588    ) -> Result<(), ConnectorError> {
589        let url = format!("{}/config/{}", self.base_url, subject);
590        let body = ConfigUpdateRequest {
591            compatibility: level.as_str().to_string(),
592        };
593
594        let mut req = self.client.put(&url).json(&body);
595        if let Some(ref auth) = self.auth {
596            req = req.basic_auth(&auth.username, Some(&auth.password));
597        }
598
599        let operation = format!("update compatibility config for subject '{subject}'");
600        let resp = req
601            .send()
602            .await
603            .map_err(|error| schema_registry_request_error(&operation, &error))?;
604
605        if !resp.status().is_success() {
606            let status = resp.status();
607            let text = resp.text().await.unwrap_or_default();
608            return Err(schema_registry_http_error(&operation, status, &text));
609        }
610
611        Ok(())
612    }
613
614    /// Resolves a Confluent schema ID, returning from cache if available.
615    ///
616    /// This is the hot-path method called during Avro deserialization to
617    /// look up schemas by the 4-byte ID in the Confluent wire format.
618    ///
619    /// # Errors
620    ///
621    /// Returns `ConnectorError` if the schema cannot be fetched.
622    pub async fn resolve_confluent_id(&self, id: i32) -> Result<CachedSchema, ConnectorError> {
623        self.get_schema_by_id(id).await
624    }
625
626    /// Registers an Avro schema with the Schema Registry under the given subject.
627    ///
628    /// Returns the schema ID assigned by the registry. Caches the result
629    /// so subsequent calls with the same subject return immediately.
630    ///
631    /// # Errors
632    ///
633    /// Returns `ConnectorError` if `schema_type` is not Avro, the HTTP request
634    /// fails, or the response is malformed.
635    pub async fn register_schema(
636        &self,
637        subject: &str,
638        schema_str: &str,
639        schema_type: SchemaType,
640    ) -> Result<i32, ConnectorError> {
641        require_avro_mutation(schema_type)?;
642
643        // Check subject cache — only return cached ID if schema hasn't changed.
644        if let Some(cached) = self.subject_cache.get(subject) {
645            if cached.schema_str == schema_str {
646                return Ok(cached.id);
647            }
648        }
649
650        let url = format!("{}/subjects/{}/versions", self.base_url, subject);
651        let body = RegisterSchemaRequest {
652            schema: schema_str.to_string(),
653            schema_type: schema_type.to_string(),
654        };
655
656        let mut req = self.client.post(&url).json(&body);
657        if let Some(ref auth) = self.auth {
658            req = req.basic_auth(&auth.username, Some(&auth.password));
659        }
660
661        let operation = format!("register schema for subject '{subject}'");
662        let resp = req
663            .send()
664            .await
665            .map_err(|error| schema_registry_request_error(&operation, &error))?;
666
667        if !resp.status().is_success() {
668            let status = resp.status();
669            let text = resp.text().await.unwrap_or_default();
670            return Err(schema_registry_http_error(&operation, status, &text));
671        }
672
673        let result: RegisterSchemaResponse = resp.json().await.map_err(|e| {
674            ConnectorError::Internal(format!(
675                "schema registry {operation} returned an invalid response: {e}"
676            ))
677        })?;
678
679        let arrow_schema = avro_to_arrow_schema(schema_str)?;
680        let cached = CachedSchema {
681            id: result.id,
682            version: 0,
683            schema_type,
684            schema_str: schema_str.to_string(),
685            arrow_schema,
686            inserted_at: Instant::now(),
687        };
688        self.cache_insert(result.id, cached.clone());
689        self.subject_cache.insert(subject.to_string(), cached);
690
691        Ok(result.id)
692    }
693
694    /// Validates compatibility and registers a schema in one step.
695    ///
696    /// If the subject already has schemas registered, checks compatibility
697    /// first. Returns `SerdeError::SchemaIncompatible` if the new schema
698    /// is not compatible with the existing versions.
699    ///
700    /// # Errors
701    ///
702    /// Returns `ConnectorError::Serde(SchemaIncompatible)` if incompatible,
703    /// or `ConnectorError` for HTTP/network errors.
704    pub async fn validate_and_register_schema(
705        &self,
706        subject: &str,
707        schema_str: &str,
708        schema_type: SchemaType,
709    ) -> Result<i32, ConnectorError> {
710        // Reject unsupported mutation types before the compatibility request.
711        // `register_schema` repeats this guard because it is also public.
712        require_avro_mutation(schema_type)?;
713
714        let result = self.check_compatibility(subject, schema_str).await?;
715        if !result.is_compatible {
716            let message = if result.messages.is_empty() {
717                "new schema is not compatible with existing version".to_string()
718            } else {
719                result.messages.join("; ")
720            };
721            return Err(ConnectorError::Serde(SerdeError::SchemaIncompatible {
722                subject: subject.to_string(),
723                message,
724            }));
725        }
726
727        self.register_schema(subject, schema_str, schema_type).await
728    }
729
730    /// Returns `true` if the schema ID is in the local cache.
731    #[must_use]
732    pub fn is_cached(&self, id: i32) -> bool {
733        self.cache.contains_key(&id)
734    }
735
736    /// Returns the number of cached schemas.
737    #[must_use]
738    pub fn cache_size(&self) -> usize {
739        self.cache.len()
740    }
741
742    /// Helper to perform a GET request and deserialize JSON.
743    ///
744    /// Retries transient failures (408, 429, 5xx, and transport errors) up to
745    /// 3 attempts with exponential backoff (100ms, 500ms).
746    async fn get_json<T: serde::de::DeserializeOwned>(
747        &self,
748        url: &str,
749        operation: &str,
750    ) -> Result<T, ConnectorError> {
751        let backoffs = [
752            std::time::Duration::from_millis(100),
753            std::time::Duration::from_millis(500),
754        ];
755        let mut last_err = None;
756
757        for (attempt, backoff) in std::iter::once(&std::time::Duration::ZERO)
758            .chain(backoffs.iter())
759            .enumerate()
760        {
761            if attempt > 0 {
762                tokio::time::sleep(*backoff).await;
763            }
764
765            let mut req = self.client.get(url);
766            if let Some(ref auth) = self.auth {
767                req = req.basic_auth(&auth.username, Some(&auth.password));
768            }
769
770            let resp = match req.send().await {
771                Ok(r) => r,
772                Err(e) => {
773                    let error = schema_registry_request_error(operation, &e);
774                    if !error.is_transient() {
775                        return Err(error);
776                    }
777                    tracing::warn!(
778                        attempt = attempt + 1,
779                        error = %error,
780                        "schema registry request failed, retrying"
781                    );
782                    last_err = Some(error);
783                    continue;
784                }
785            };
786
787            let status = resp.status();
788            if status.is_success() {
789                return resp.json::<T>().await.map_err(|e| {
790                    ConnectorError::Internal(format!(
791                        "schema registry {operation} returned an invalid response: {e}"
792                    ))
793                });
794            }
795
796            let transient = status == reqwest::StatusCode::REQUEST_TIMEOUT
797                || status == reqwest::StatusCode::TOO_MANY_REQUESTS
798                || status.is_server_error();
799            if !transient {
800                let text = resp.text().await.unwrap_or_default();
801                return Err(schema_registry_http_error(operation, status, &text));
802            }
803
804            let text = resp.text().await.unwrap_or_default();
805            tracing::warn!(
806                attempt = attempt + 1,
807                status = %status,
808                "schema registry server error, retrying"
809            );
810            last_err = Some(schema_registry_http_error(operation, status, &text));
811        }
812
813        Err(last_err.unwrap_or_else(|| {
814            ConnectorError::ConnectionFailed(format!(
815                "schema registry {operation} exhausted all retries"
816            ))
817        }))
818    }
819}
820
821impl std::fmt::Debug for SchemaRegistryClient {
822    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
823        f.debug_struct("SchemaRegistryClient")
824            .field("base_url", &self.base_url)
825            .field("has_auth", &self.auth.is_some())
826            .field("cached_schemas", &self.cache.len())
827            .field("cached_subjects", &self.subject_cache.len())
828            .finish_non_exhaustive()
829    }
830}
831
832/// Dispatch a Schema Registry payload to the right Arrow converter.
833/// Only Avro is wired today; JSON Schema and Protobuf return an
834/// actionable error until a maintained conversion library lands.
835fn schema_to_arrow(schema_type: SchemaType, schema_str: &str) -> Result<SchemaRef, ConnectorError> {
836    let name = match schema_type {
837        SchemaType::Avro => return avro_to_arrow_schema(schema_str),
838        SchemaType::Json => "JSON Schema Registry",
839        SchemaType::Protobuf => "Protobuf Schema Registry",
840    };
841    Err(ConnectorError::SchemaMismatch(format!(
842        "{name} subjects are not yet supported for auto-discovery \
843         — declare columns explicitly or use an Avro subject"
844    )))
845}
846
847/// Converts an Avro JSON schema string to an Arrow [`SchemaRef`] via `arrow-avro`'s Decoder.
848///
849/// # Errors
850///
851/// Returns `ConnectorError::SchemaMismatch` if the JSON is invalid or conversion fails.
852pub fn avro_to_arrow_schema(avro_schema_str: &str) -> Result<SchemaRef, ConnectorError> {
853    use arrow_avro::reader::ReaderBuilder;
854    use arrow_avro::schema::{AvroSchema, Fingerprint, FingerprintAlgorithm, SchemaStore};
855
856    let mut store = SchemaStore::new_with_type(FingerprintAlgorithm::Id);
857    let avro_schema = AvroSchema::new(avro_schema_str.to_string());
858    let fp = Fingerprint::Id(0);
859    store
860        .set(fp, avro_schema)
861        .map_err(|e| ConnectorError::SchemaMismatch(format!("invalid Avro schema: {e}")))?;
862
863    let decoder = ReaderBuilder::new()
864        .with_writer_schema_store(store)
865        .with_active_fingerprint(fp)
866        .build_decoder()
867        .map_err(|e| ConnectorError::SchemaMismatch(format!("Avro→Arrow conversion: {e}")))?;
868
869    Ok(decoder.schema())
870}
871
872/// Converts an Arrow [`SchemaRef`] to an Avro JSON schema string.
873///
874/// Generates a record schema named `"record"` with fields mapped from
875/// Arrow data types to Avro primitives.
876///
877/// # Errors
878///
879/// Returns `SerdeError` if an Arrow type has no Avro equivalent.
880pub fn arrow_to_avro_schema(schema: &SchemaRef, record_name: &str) -> Result<String, SerdeError> {
881    let mut fields = Vec::with_capacity(schema.fields().len());
882
883    for field in schema.fields() {
884        let avro_type = arrow_to_avro_type(field.data_type())?;
885
886        let field_type = if field.is_nullable() {
887            serde_json::json!(["null", avro_type])
888        } else {
889            avro_type
890        };
891
892        fields.push(serde_json::json!({
893            "name": field.name(),
894            "type": field_type,
895        }));
896    }
897
898    // Avro record names must match [A-Za-z_][A-Za-z0-9_]*; topic names
899    // commonly contain hyphens (e.g. "my-events") which are invalid.
900    let safe_name = record_name.replace('-', "_");
901
902    let schema = serde_json::json!({
903        "type": "record",
904        "name": safe_name,
905        "fields": fields,
906    });
907
908    serde_json::to_string(&schema)
909        .map_err(|e| SerdeError::MalformedInput(format!("failed to serialize Avro schema: {e}")))
910}
911
912/// Maps an Arrow `DataType` to an Avro type JSON value.
913fn arrow_to_avro_type(data_type: &DataType) -> Result<serde_json::Value, SerdeError> {
914    match data_type {
915        DataType::Null => Ok(serde_json::json!("null")),
916        DataType::Boolean => Ok(serde_json::json!("boolean")),
917        DataType::Int8
918        | DataType::Int16
919        | DataType::Int32
920        | DataType::UInt8
921        | DataType::UInt16
922        | DataType::UInt32 => Ok(serde_json::json!("int")),
923        DataType::Int64 | DataType::UInt64 => Ok(serde_json::json!("long")),
924        DataType::Float32 => Ok(serde_json::json!("float")),
925        DataType::Float64 => Ok(serde_json::json!("double")),
926        DataType::Utf8 | DataType::LargeUtf8 => Ok(serde_json::json!("string")),
927        DataType::Binary | DataType::LargeBinary => Ok(serde_json::json!("bytes")),
928        DataType::List(item_field) => {
929            let items = arrow_to_avro_type(item_field.data_type())?;
930            Ok(serde_json::json!({
931                "type": "array",
932                "items": items,
933            }))
934        }
935        DataType::Map(entries_field, _) => {
936            // Map entries field is a Struct with "key" and "value" children.
937            if let DataType::Struct(fields) = entries_field.data_type() {
938                let value_field = fields.iter().find(|f| f.name() == "value").ok_or_else(|| {
939                    SerdeError::UnsupportedFormat(
940                        "Arrow Map missing 'value' field in entries struct".into(),
941                    )
942                })?;
943                let values = arrow_to_avro_type(value_field.data_type())?;
944                Ok(serde_json::json!({
945                    "type": "map",
946                    "values": values,
947                }))
948            } else {
949                Err(SerdeError::UnsupportedFormat(
950                    "Arrow Map entries field is not a Struct".into(),
951                ))
952            }
953        }
954        DataType::Struct(fields) => {
955            let mut avro_fields = Vec::with_capacity(fields.len());
956            for field in fields {
957                let avro_type = arrow_to_avro_type(field.data_type())?;
958                let field_type = if field.is_nullable() {
959                    serde_json::json!(["null", avro_type])
960                } else {
961                    avro_type
962                };
963                avro_fields.push(serde_json::json!({
964                    "name": field.name(),
965                    "type": field_type,
966                }));
967            }
968            Ok(serde_json::json!({
969                "type": "record",
970                "name": "nested",
971                "fields": avro_fields,
972            }))
973        }
974        DataType::Dictionary(_, value_type) if value_type.as_ref() == &DataType::Utf8 => {
975            Ok(serde_json::json!({
976                "type": "enum",
977                "name": "enum_field",
978                "symbols": [],
979            }))
980        }
981        DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, _) => {
982            Ok(serde_json::json!({"type": "long", "logicalType": "timestamp-millis"}))
983        }
984        DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, _) => {
985            Ok(serde_json::json!({"type": "long", "logicalType": "timestamp-micros"}))
986        }
987        DataType::Date32 => Ok(serde_json::json!({"type": "int", "logicalType": "date"})),
988        DataType::Time32(arrow_schema::TimeUnit::Millisecond) => {
989            Ok(serde_json::json!({"type": "int", "logicalType": "time-millis"}))
990        }
991        DataType::Time64(arrow_schema::TimeUnit::Microsecond) => {
992            Ok(serde_json::json!({"type": "long", "logicalType": "time-micros"}))
993        }
994        DataType::FixedSizeBinary(size) => Ok(serde_json::json!({
995            "type": "fixed",
996            "name": "fixed_field",
997            "size": size,
998        })),
999        other => Err(SerdeError::UnsupportedFormat(format!(
1000            "no Avro equivalent for Arrow type: {other}"
1001        ))),
1002    }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use std::sync::Arc;
1008
1009    use super::*;
1010    use arrow_schema::{Field, Fields, Schema};
1011
1012    #[test]
1013    fn test_avro_to_arrow_simple_record() {
1014        let avro = r#"{
1015            "type": "record",
1016            "name": "test",
1017            "fields": [
1018                {"name": "id", "type": "long"},
1019                {"name": "name", "type": "string"},
1020                {"name": "active", "type": "boolean"}
1021            ]
1022        }"#;
1023
1024        let schema = avro_to_arrow_schema(avro).unwrap();
1025        assert_eq!(schema.fields().len(), 3);
1026        assert_eq!(schema.field(0).name(), "id");
1027        assert_eq!(schema.field(0).data_type(), &DataType::Int64);
1028        assert!(!schema.field(0).is_nullable());
1029        assert_eq!(schema.field(1).name(), "name");
1030        assert_eq!(schema.field(1).data_type(), &DataType::Utf8);
1031        assert_eq!(schema.field(2).name(), "active");
1032        assert_eq!(schema.field(2).data_type(), &DataType::Boolean);
1033    }
1034
1035    #[test]
1036    fn test_avro_to_arrow_nullable_union() {
1037        let avro = r#"{
1038            "type": "record",
1039            "name": "test",
1040            "fields": [
1041                {"name": "id", "type": "long"},
1042                {"name": "email", "type": ["null", "string"]}
1043            ]
1044        }"#;
1045
1046        let schema = avro_to_arrow_schema(avro).unwrap();
1047        assert_eq!(schema.fields().len(), 2);
1048        assert!(!schema.field(0).is_nullable());
1049        assert!(schema.field(1).is_nullable());
1050        assert_eq!(schema.field(1).data_type(), &DataType::Utf8);
1051    }
1052
1053    #[test]
1054    fn test_avro_to_arrow_all_primitives() {
1055        let avro = r#"{
1056            "type": "record",
1057            "name": "test",
1058            "fields": [
1059                {"name": "b", "type": "boolean"},
1060                {"name": "i", "type": "int"},
1061                {"name": "l", "type": "long"},
1062                {"name": "f", "type": "float"},
1063                {"name": "d", "type": "double"},
1064                {"name": "s", "type": "string"},
1065                {"name": "raw", "type": "bytes"}
1066            ]
1067        }"#;
1068
1069        let schema = avro_to_arrow_schema(avro).unwrap();
1070        assert_eq!(schema.field(0).data_type(), &DataType::Boolean);
1071        assert_eq!(schema.field(1).data_type(), &DataType::Int32);
1072        assert_eq!(schema.field(2).data_type(), &DataType::Int64);
1073        assert_eq!(schema.field(3).data_type(), &DataType::Float32);
1074        assert_eq!(schema.field(4).data_type(), &DataType::Float64);
1075        assert_eq!(schema.field(5).data_type(), &DataType::Utf8);
1076        assert_eq!(schema.field(6).data_type(), &DataType::Binary);
1077    }
1078
1079    #[test]
1080    fn test_avro_to_arrow_invalid_json() {
1081        assert!(avro_to_arrow_schema("not json").is_err());
1082    }
1083
1084    #[test]
1085    fn test_avro_to_arrow_missing_fields() {
1086        let avro = r#"{"type": "record", "name": "test"}"#;
1087        assert!(avro_to_arrow_schema(avro).is_err());
1088    }
1089
1090    #[test]
1091    fn schema_to_arrow_avro_works() {
1092        let avro = r#"{"type":"record","name":"t","fields":[{"name":"x","type":"long"}]}"#;
1093        let schema = schema_to_arrow(SchemaType::Avro, avro).unwrap();
1094        assert_eq!(schema.field(0).name(), "x");
1095    }
1096
1097    #[test]
1098    fn schema_to_arrow_json_returns_actionable_error() {
1099        let err = schema_to_arrow(SchemaType::Json, "{}").unwrap_err();
1100        assert!(
1101            err.to_string().contains("JSON Schema Registry"),
1102            "error should name the subject type, got: {err}"
1103        );
1104    }
1105
1106    #[test]
1107    fn schema_to_arrow_protobuf_returns_actionable_error() {
1108        let err = schema_to_arrow(SchemaType::Protobuf, "").unwrap_err();
1109        assert!(
1110            err.to_string().contains("Protobuf"),
1111            "error should name the subject type, got: {err}"
1112        );
1113    }
1114
1115    #[test]
1116    fn test_schema_type_parsing() {
1117        assert_eq!("AVRO".parse::<SchemaType>().unwrap(), SchemaType::Avro);
1118        assert_eq!(
1119            "PROTOBUF".parse::<SchemaType>().unwrap(),
1120            SchemaType::Protobuf
1121        );
1122        assert_eq!("JSON".parse::<SchemaType>().unwrap(), SchemaType::Json);
1123        assert!("UNKNOWN".parse::<SchemaType>().is_err());
1124    }
1125
1126    #[test]
1127    fn test_schema_type_display() {
1128        assert_eq!(SchemaType::Avro.to_string(), "AVRO");
1129        assert_eq!(SchemaType::Protobuf.to_string(), "PROTOBUF");
1130        assert_eq!(SchemaType::Json.to_string(), "JSON");
1131    }
1132
1133    #[tokio::test]
1134    async fn non_avro_mutations_are_rejected_without_network_access() {
1135        use wiremock::MockServer;
1136
1137        let server = MockServer::start().await;
1138        let client = SchemaRegistryClient::new(server.uri(), None).unwrap();
1139
1140        for schema_type in [SchemaType::Protobuf, SchemaType::Json] {
1141            let schema_name = schema_type.to_string();
1142            let registration = client
1143                .register_schema("orders-value", "unused", schema_type)
1144                .await
1145                .unwrap_err();
1146            assert!(matches!(
1147                &registration,
1148                ConnectorError::ConfigurationError(_)
1149            ));
1150            assert!(!registration.is_transient());
1151            assert!(registration.to_string().contains(schema_name.as_str()));
1152
1153            let validation = client
1154                .validate_and_register_schema("orders-value", "unused", schema_type)
1155                .await
1156                .unwrap_err();
1157            assert!(matches!(&validation, ConnectorError::ConfigurationError(_)));
1158            assert!(!validation.is_transient());
1159            assert!(validation.to_string().contains(schema_name.as_str()));
1160        }
1161
1162        let requests = server.received_requests().await.unwrap();
1163        assert!(
1164            requests.is_empty(),
1165            "unsupported schema mutations must fail before HTTP"
1166        );
1167    }
1168
1169    #[test]
1170    fn test_client_creation() {
1171        let client = SchemaRegistryClient::new("http://localhost:8081", None).unwrap();
1172        assert_eq!(client.base_url(), "http://localhost:8081");
1173        assert!(!client.has_auth());
1174        assert_eq!(client.cache_size(), 0);
1175    }
1176
1177    #[test]
1178    fn test_client_with_auth() {
1179        let auth = SrAuth {
1180            username: "user".into(),
1181            password: "pass".into(),
1182        };
1183        let client = SchemaRegistryClient::new("http://localhost:8081", Some(auth)).unwrap();
1184        assert!(client.has_auth());
1185    }
1186
1187    #[test]
1188    fn test_client_trailing_slash_stripped() {
1189        let client = SchemaRegistryClient::new("http://localhost:8081/", None).unwrap();
1190        assert_eq!(client.base_url(), "http://localhost:8081");
1191    }
1192
1193    #[test]
1194    fn schema_registry_http_budget_fits_default_discovery_deadline() {
1195        assert!(SCHEMA_REGISTRY_CONNECT_TIMEOUT <= SCHEMA_REGISTRY_REQUEST_TIMEOUT);
1196        assert!(SCHEMA_REGISTRY_READ_TIMEOUT <= SCHEMA_REGISTRY_REQUEST_TIMEOUT);
1197
1198        let three_attempt_get_budget =
1199            SCHEMA_REGISTRY_REQUEST_TIMEOUT.saturating_mul(3) + Duration::from_millis(600);
1200        assert!(three_attempt_get_budget <= Duration::from_secs(10));
1201    }
1202
1203    #[test]
1204    fn schema_registry_http_status_classification_is_fail_closed() {
1205        for status in [
1206            reqwest::StatusCode::REQUEST_TIMEOUT,
1207            reqwest::StatusCode::TOO_MANY_REQUESTS,
1208            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1209        ] {
1210            assert!(schema_registry_http_error("test", status, "failure").is_transient());
1211        }
1212        for status in [
1213            reqwest::StatusCode::BAD_REQUEST,
1214            reqwest::StatusCode::UNAUTHORIZED,
1215            reqwest::StatusCode::FORBIDDEN,
1216            reqwest::StatusCode::NOT_FOUND,
1217            reqwest::StatusCode::UNPROCESSABLE_ENTITY,
1218        ] {
1219            assert!(!schema_registry_http_error("test", status, "failure").is_transient());
1220        }
1221    }
1222
1223    #[tokio::test]
1224    async fn missing_subject_is_compatible_for_first_registration() {
1225        use wiremock::matchers::{method, path};
1226        use wiremock::{Mock, MockServer, ResponseTemplate};
1227
1228        let server = MockServer::start().await;
1229        Mock::given(method("POST"))
1230            .and(path("/compatibility/subjects/orders/versions/latest"))
1231            .respond_with(ResponseTemplate::new(404))
1232            .mount(&server)
1233            .await;
1234        let client = SchemaRegistryClient::new(server.uri(), None).unwrap();
1235
1236        let result = client.check_compatibility("orders", "{}").await.unwrap();
1237        assert!(result.is_compatible);
1238        assert!(result.messages.is_empty());
1239    }
1240
1241    #[tokio::test]
1242    async fn schema_registry_http_client_times_out_a_stalled_response() {
1243        use wiremock::matchers::{method, path};
1244        use wiremock::{Mock, MockServer, ResponseTemplate};
1245
1246        let server = MockServer::start().await;
1247        Mock::given(method("POST"))
1248            .and(path("/compatibility/subjects/orders/versions/latest"))
1249            .respond_with(
1250                ResponseTemplate::new(200)
1251                    .set_delay(Duration::from_millis(250))
1252                    .set_body_json(serde_json::json!({ "is_compatible": true })),
1253            )
1254            .mount(&server)
1255            .await;
1256
1257        let test_timeouts = SchemaRegistryHttpTimeouts {
1258            connect: Duration::from_millis(50),
1259            read: Duration::from_millis(50),
1260            request: Duration::from_millis(50),
1261        };
1262        let client = SchemaRegistryClient::with_cache_config_and_timeouts(
1263            server.uri(),
1264            None,
1265            SchemaRegistryCacheConfig::default(),
1266            test_timeouts,
1267        )
1268        .unwrap();
1269
1270        let err = client
1271            .check_compatibility("orders", "{}")
1272            .await
1273            .unwrap_err();
1274        assert!(matches!(err, ConnectorError::ConnectionFailed(_)));
1275    }
1276
1277    #[test]
1278    fn test_arrow_to_avro_schema_simple() {
1279        let schema = Arc::new(Schema::new(vec![
1280            Field::new("id", DataType::Int64, false),
1281            Field::new("name", DataType::Utf8, false),
1282        ]));
1283
1284        let avro_str = arrow_to_avro_schema(&schema, "test_record").unwrap();
1285        let avro: serde_json::Value = serde_json::from_str(&avro_str).unwrap();
1286
1287        assert_eq!(avro["type"], "record");
1288        assert_eq!(avro["name"], "test_record");
1289
1290        let fields = avro["fields"].as_array().unwrap();
1291        assert_eq!(fields.len(), 2);
1292        assert_eq!(fields[0]["name"], "id");
1293        assert_eq!(fields[0]["type"], "long");
1294        assert_eq!(fields[1]["name"], "name");
1295        assert_eq!(fields[1]["type"], "string");
1296    }
1297
1298    #[test]
1299    fn test_arrow_to_avro_schema_sanitizes_hyphens() {
1300        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1301
1302        let avro_str = arrow_to_avro_schema(&schema, "trades-avro-output").unwrap();
1303        let avro: serde_json::Value = serde_json::from_str(&avro_str).unwrap();
1304        assert_eq!(avro["name"], "trades_avro_output");
1305    }
1306
1307    #[test]
1308    fn test_arrow_to_avro_schema_nullable() {
1309        let schema = Arc::new(Schema::new(vec![
1310            Field::new("id", DataType::Int64, false),
1311            Field::new("email", DataType::Utf8, true),
1312        ]));
1313
1314        let avro_str = arrow_to_avro_schema(&schema, "record").unwrap();
1315        let avro: serde_json::Value = serde_json::from_str(&avro_str).unwrap();
1316
1317        let fields = avro["fields"].as_array().unwrap();
1318        // Non-nullable: plain type
1319        assert_eq!(fields[0]["type"], "long");
1320        // Nullable: union ["null", "string"]
1321        let union = fields[1]["type"].as_array().unwrap();
1322        assert_eq!(union.len(), 2);
1323        assert_eq!(union[0], "null");
1324        assert_eq!(union[1], "string");
1325    }
1326
1327    #[test]
1328    fn test_arrow_to_avro_all_primitives() {
1329        let schema = Arc::new(Schema::new(vec![
1330            Field::new("b", DataType::Boolean, false),
1331            Field::new("i32", DataType::Int32, false),
1332            Field::new("i64", DataType::Int64, false),
1333            Field::new("f32", DataType::Float32, false),
1334            Field::new("f64", DataType::Float64, false),
1335            Field::new("s", DataType::Utf8, false),
1336            Field::new("bin", DataType::Binary, false),
1337        ]));
1338
1339        let avro_str = arrow_to_avro_schema(&schema, "all_types").unwrap();
1340        let avro: serde_json::Value = serde_json::from_str(&avro_str).unwrap();
1341        let fields = avro["fields"].as_array().unwrap();
1342
1343        assert_eq!(fields[0]["type"], "boolean");
1344        assert_eq!(fields[1]["type"], "int");
1345        assert_eq!(fields[2]["type"], "long");
1346        assert_eq!(fields[3]["type"], "float");
1347        assert_eq!(fields[4]["type"], "double");
1348        assert_eq!(fields[5]["type"], "string");
1349        assert_eq!(fields[6]["type"], "bytes");
1350    }
1351
1352    #[test]
1353    fn test_arrow_to_avro_roundtrip() {
1354        let original = Arc::new(Schema::new(vec![
1355            Field::new("id", DataType::Int64, false),
1356            Field::new("name", DataType::Utf8, true),
1357            Field::new("active", DataType::Boolean, false),
1358        ]));
1359
1360        let avro_str = arrow_to_avro_schema(&original, "roundtrip").unwrap();
1361        let recovered = avro_to_arrow_schema(&avro_str).unwrap();
1362
1363        assert_eq!(recovered.fields().len(), 3);
1364        assert_eq!(recovered.field(0).data_type(), &DataType::Int64);
1365        assert!(!recovered.field(0).is_nullable());
1366        assert_eq!(recovered.field(1).data_type(), &DataType::Utf8);
1367        assert!(recovered.field(1).is_nullable());
1368        assert_eq!(recovered.field(2).data_type(), &DataType::Boolean);
1369    }
1370
1371    // ---- Complex type tests ----
1372
1373    #[test]
1374    fn test_avro_to_arrow_array_type() {
1375        let avro = r#"{
1376            "type": "record",
1377            "name": "test",
1378            "fields": [
1379                {"name": "tags", "type": {"type": "array", "items": "string"}}
1380            ]
1381        }"#;
1382
1383        let schema = avro_to_arrow_schema(avro).unwrap();
1384        assert_eq!(schema.fields().len(), 1);
1385        match schema.field(0).data_type() {
1386            DataType::List(item) => {
1387                assert_eq!(item.data_type(), &DataType::Utf8);
1388            }
1389            other => panic!("expected List, got {other:?}"),
1390        }
1391    }
1392
1393    #[test]
1394    fn test_avro_to_arrow_map_type() {
1395        let avro = r#"{
1396            "type": "record",
1397            "name": "test",
1398            "fields": [
1399                {"name": "metadata", "type": {"type": "map", "values": "long"}}
1400            ]
1401        }"#;
1402
1403        let schema = avro_to_arrow_schema(avro).unwrap();
1404        assert_eq!(schema.fields().len(), 1);
1405        match schema.field(0).data_type() {
1406            DataType::Map(entries, _) => {
1407                if let DataType::Struct(fields) = entries.data_type() {
1408                    assert_eq!(fields.len(), 2);
1409                    assert_eq!(fields[0].name(), "key");
1410                    assert_eq!(fields[0].data_type(), &DataType::Utf8);
1411                    assert_eq!(fields[1].name(), "value");
1412                    assert_eq!(fields[1].data_type(), &DataType::Int64);
1413                } else {
1414                    panic!("expected Struct entries");
1415                }
1416            }
1417            other => panic!("expected Map, got {other:?}"),
1418        }
1419    }
1420
1421    #[test]
1422    fn test_avro_to_arrow_nested_record() {
1423        let avro = r#"{
1424            "type": "record",
1425            "name": "test",
1426            "fields": [
1427                {
1428                    "name": "address",
1429                    "type": {
1430                        "type": "record",
1431                        "name": "Address",
1432                        "fields": [
1433                            {"name": "street", "type": "string"},
1434                            {"name": "zip", "type": "int"}
1435                        ]
1436                    }
1437                }
1438            ]
1439        }"#;
1440
1441        let schema = avro_to_arrow_schema(avro).unwrap();
1442        assert_eq!(schema.fields().len(), 1);
1443        match schema.field(0).data_type() {
1444            DataType::Struct(fields) => {
1445                assert_eq!(fields.len(), 2);
1446                assert_eq!(fields[0].name(), "street");
1447                assert_eq!(fields[0].data_type(), &DataType::Utf8);
1448                assert_eq!(fields[1].name(), "zip");
1449                assert_eq!(fields[1].data_type(), &DataType::Int32);
1450            }
1451            other => panic!("expected Struct, got {other:?}"),
1452        }
1453    }
1454
1455    #[test]
1456    fn test_avro_to_arrow_enum_type() {
1457        let avro = r#"{
1458            "type": "record",
1459            "name": "test",
1460            "fields": [
1461                {
1462                    "name": "status",
1463                    "type": {
1464                        "type": "enum",
1465                        "name": "Status",
1466                        "symbols": ["ACTIVE", "INACTIVE", "PENDING"]
1467                    }
1468                }
1469            ]
1470        }"#;
1471
1472        let schema = avro_to_arrow_schema(avro).unwrap();
1473        assert_eq!(schema.fields().len(), 1);
1474        match schema.field(0).data_type() {
1475            DataType::Dictionary(key, value) => {
1476                assert_eq!(key.as_ref(), &DataType::Int32);
1477                assert_eq!(value.as_ref(), &DataType::Utf8);
1478            }
1479            other => panic!("expected Dictionary, got {other:?}"),
1480        }
1481    }
1482
1483    #[test]
1484    fn test_avro_to_arrow_fixed_type() {
1485        let avro = r#"{
1486            "type": "record",
1487            "name": "test",
1488            "fields": [
1489                {
1490                    "name": "uuid",
1491                    "type": {"type": "fixed", "name": "uuid", "size": 16}
1492                }
1493            ]
1494        }"#;
1495
1496        let schema = avro_to_arrow_schema(avro).unwrap();
1497        assert_eq!(schema.fields().len(), 1);
1498        assert_eq!(schema.field(0).data_type(), &DataType::FixedSizeBinary(16));
1499    }
1500
1501    #[test]
1502    fn test_avro_to_arrow_nullable_complex_in_union() {
1503        let avro = r#"{
1504            "type": "record",
1505            "name": "test",
1506            "fields": [
1507                {
1508                    "name": "tags",
1509                    "type": ["null", {"type": "array", "items": "string"}]
1510                }
1511            ]
1512        }"#;
1513
1514        let schema = avro_to_arrow_schema(avro).unwrap();
1515        assert!(schema.field(0).is_nullable());
1516        assert!(matches!(schema.field(0).data_type(), DataType::List(_)));
1517    }
1518
1519    #[test]
1520    fn test_avro_array_missing_items() {
1521        let avro = r#"{
1522            "type": "record",
1523            "name": "test",
1524            "fields": [
1525                {"name": "bad", "type": {"type": "array"}}
1526            ]
1527        }"#;
1528        assert!(avro_to_arrow_schema(avro).is_err());
1529    }
1530
1531    #[test]
1532    fn test_avro_map_missing_values() {
1533        let avro = r#"{
1534            "type": "record",
1535            "name": "test",
1536            "fields": [
1537                {"name": "bad", "type": {"type": "map"}}
1538            ]
1539        }"#;
1540        assert!(avro_to_arrow_schema(avro).is_err());
1541    }
1542
1543    #[test]
1544    fn test_arrow_to_avro_array_type() {
1545        let schema = Arc::new(Schema::new(vec![Field::new(
1546            "tags",
1547            DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
1548            false,
1549        )]));
1550
1551        let avro_str = arrow_to_avro_schema(&schema, "test").unwrap();
1552        let avro: serde_json::Value = serde_json::from_str(&avro_str).unwrap();
1553        let field = &avro["fields"][0];
1554        assert_eq!(field["type"]["type"], "array");
1555        assert_eq!(field["type"]["items"], "string");
1556    }
1557
1558    #[test]
1559    fn test_arrow_to_avro_map_type() {
1560        let schema = Arc::new(Schema::new(vec![Field::new(
1561            "metadata",
1562            DataType::Map(
1563                Arc::new(Field::new(
1564                    "entries",
1565                    DataType::Struct(Fields::from(vec![
1566                        Field::new("key", DataType::Utf8, false),
1567                        Field::new("value", DataType::Int64, true),
1568                    ])),
1569                    false,
1570                )),
1571                false,
1572            ),
1573            false,
1574        )]));
1575
1576        let avro_str = arrow_to_avro_schema(&schema, "test").unwrap();
1577        let avro: serde_json::Value = serde_json::from_str(&avro_str).unwrap();
1578        let field = &avro["fields"][0];
1579        assert_eq!(field["type"]["type"], "map");
1580        assert_eq!(field["type"]["values"], "long");
1581    }
1582
1583    #[test]
1584    fn test_arrow_to_avro_struct_type() {
1585        let schema = Arc::new(Schema::new(vec![Field::new(
1586            "address",
1587            DataType::Struct(Fields::from(vec![
1588                Field::new("street", DataType::Utf8, false),
1589                Field::new("zip", DataType::Int32, false),
1590            ])),
1591            false,
1592        )]));
1593
1594        let avro_str = arrow_to_avro_schema(&schema, "test").unwrap();
1595        let avro: serde_json::Value = serde_json::from_str(&avro_str).unwrap();
1596        let field = &avro["fields"][0];
1597        assert_eq!(field["type"]["type"], "record");
1598        let nested = field["type"]["fields"].as_array().unwrap();
1599        assert_eq!(nested.len(), 2);
1600        assert_eq!(nested[0]["name"], "street");
1601        assert_eq!(nested[0]["type"], "string");
1602        assert_eq!(nested[1]["name"], "zip");
1603        assert_eq!(nested[1]["type"], "int");
1604    }
1605
1606    #[test]
1607    fn test_arrow_to_avro_fixed_type() {
1608        let schema = Arc::new(Schema::new(vec![Field::new(
1609            "uuid",
1610            DataType::FixedSizeBinary(16),
1611            false,
1612        )]));
1613
1614        let avro_str = arrow_to_avro_schema(&schema, "test").unwrap();
1615        let avro: serde_json::Value = serde_json::from_str(&avro_str).unwrap();
1616        let field = &avro["fields"][0];
1617        assert_eq!(field["type"]["type"], "fixed");
1618        assert_eq!(field["type"]["size"], 16);
1619    }
1620
1621    // ---- Cache eviction tests ----
1622
1623    fn make_cached_schema(id: i32) -> CachedSchema {
1624        CachedSchema {
1625            id,
1626            version: 1,
1627            schema_type: SchemaType::Avro,
1628            schema_str: format!(
1629                r#"{{"type":"record","name":"t{id}","fields":[{{"name":"x","type":"int"}}]}}"#
1630            ),
1631            arrow_schema: Arc::new(Schema::new(vec![Field::new("x", DataType::Int32, false)])),
1632            inserted_at: Instant::now(),
1633        }
1634    }
1635
1636    #[test]
1637    fn test_cache_config_defaults() {
1638        let config = SchemaRegistryCacheConfig::default();
1639        assert_eq!(config.max_entries, 1000);
1640        assert_eq!(config.ttl, Some(Duration::from_secs(3600)));
1641    }
1642
1643    #[test]
1644    fn test_cache_lru_eviction() {
1645        let config = SchemaRegistryCacheConfig {
1646            max_entries: 3,
1647            ttl: None,
1648        };
1649        let client =
1650            SchemaRegistryClient::with_cache_config("http://localhost:8081", None, config).unwrap();
1651
1652        // Insert 3 schemas.
1653        client.cache_insert(1, make_cached_schema(1));
1654        client.cache_insert(2, make_cached_schema(2));
1655        client.cache_insert(3, make_cached_schema(3));
1656        assert_eq!(client.cache_size(), 3);
1657
1658        // Insert a 4th — should evict one entry (S3-FIFO-style eviction).
1659        client.cache_insert(4, make_cached_schema(4));
1660        assert!(client.cache_size() <= 3);
1661        // The most recently inserted should always be present.
1662        assert!(client.cache_get(4).is_some());
1663    }
1664
1665    #[test]
1666    fn test_cache_ttl_expiration() {
1667        // TTL generous enough that scheduler jitter between insert and the
1668        // immediate `is_some()` check can't expire the entry early — a 50ms
1669        // window flaked under parallel test load on busy CI runners.
1670        let config = SchemaRegistryCacheConfig {
1671            max_entries: 100,
1672            ttl: Some(Duration::from_millis(1000)),
1673        };
1674        let client =
1675            SchemaRegistryClient::with_cache_config("http://localhost:8081", None, config).unwrap();
1676
1677        client.cache_insert(1, make_cached_schema(1));
1678        assert!(client.cache_get(1).is_some());
1679
1680        // Wait for TTL to expire.
1681        std::thread::sleep(Duration::from_millis(1200));
1682        // Lazy TTL: expired entry returns None on access.
1683        assert!(client.cache_get(1).is_none());
1684    }
1685
1686    #[test]
1687    fn test_cache_no_ttl() {
1688        let config = SchemaRegistryCacheConfig {
1689            max_entries: 100,
1690            ttl: None,
1691        };
1692        let client =
1693            SchemaRegistryClient::with_cache_config("http://localhost:8081", None, config).unwrap();
1694
1695        client.cache_insert(1, make_cached_schema(1));
1696        // No TTL — entry should stay.
1697        assert!(client.cache_get(1).is_some());
1698    }
1699
1700    #[test]
1701    fn test_cache_replace_existing_id() {
1702        let config = SchemaRegistryCacheConfig {
1703            max_entries: 10,
1704            ttl: None,
1705        };
1706        let client =
1707            SchemaRegistryClient::with_cache_config("http://localhost:8081", None, config).unwrap();
1708
1709        client.cache_insert(1, make_cached_schema(1));
1710        client.cache_insert(2, make_cached_schema(2));
1711        assert_eq!(client.cache_size(), 2);
1712
1713        // Re-insert 1 with updated schema — should not increase size.
1714        client.cache_insert(1, make_cached_schema(1));
1715        assert_eq!(client.cache_size(), 2);
1716    }
1717
1718    #[test]
1719    fn test_schema_incompatible_error_via_serde() {
1720        let err = SerdeError::SchemaIncompatible {
1721            subject: "orders-value".into(),
1722            message: "READER_FIELD_MISSING_DEFAULT_VALUE: field 'new_field'".into(),
1723        };
1724        let conn_err: ConnectorError = err.into();
1725        assert!(matches!(
1726            conn_err,
1727            ConnectorError::Serde(SerdeError::SchemaIncompatible { .. })
1728        ));
1729        assert!(conn_err.to_string().contains("orders-value"));
1730    }
1731
1732    #[test]
1733    fn test_validate_and_register_method_exists() {
1734        // Verify the method exists and has the correct signature by referencing it.
1735        let client = SchemaRegistryClient::new("http://localhost:8081", None).unwrap();
1736        // Just check the method is callable (we can't actually test without a registry).
1737        let _ = &client;
1738    }
1739
1740    #[test]
1741    fn test_complex_type_roundtrip() {
1742        let avro = r#"{
1743            "type": "record",
1744            "name": "test",
1745            "fields": [
1746                {"name": "tags", "type": {"type": "array", "items": "string"}},
1747                {"name": "metadata", "type": {"type": "map", "values": "long"}}
1748            ]
1749        }"#;
1750
1751        let arrow_schema = avro_to_arrow_schema(avro).unwrap();
1752        assert!(matches!(
1753            arrow_schema.field(0).data_type(),
1754            DataType::List(_)
1755        ));
1756        assert!(matches!(
1757            arrow_schema.field(1).data_type(),
1758            DataType::Map(_, _)
1759        ));
1760
1761        // Convert back to Avro
1762        let avro_str = arrow_to_avro_schema(&arrow_schema, "test").unwrap();
1763        let recovered = avro_to_arrow_schema(&avro_str).unwrap();
1764
1765        assert!(matches!(recovered.field(0).data_type(), DataType::List(_)));
1766        assert!(matches!(
1767            recovered.field(1).data_type(),
1768            DataType::Map(_, _)
1769        ));
1770    }
1771}