Skip to main content

laminar_connectors/kafka/schema_registry/
mod.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;