1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SchemaType {
78 Avro,
80 Protobuf,
82 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#[derive(Debug, Clone)]
123pub struct SchemaRegistryCacheConfig {
124 pub max_entries: usize,
126 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#[derive(Debug, Clone)]
141pub struct CachedSchema {
142 pub id: i32,
144 pub version: i32,
146 pub schema_type: SchemaType,
148 pub schema_str: String,
150 pub arrow_schema: SchemaRef,
152 inserted_at: Instant,
154}
155
156#[derive(Debug, Clone)]
158pub struct CompatibilityResult {
159 pub is_compatible: bool,
161 pub messages: Vec<String>,
163}
164
165pub struct SchemaRegistryClient {
170 client: Client,
171 base_url: String,
172 auth: Option<SrAuth>,
173 cache: Cache<i32, CachedSchema>,
175 subject_cache: Cache<String, CachedSchema>,
177 cache_config: SchemaRegistryCacheConfig,
179}
180
181#[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 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 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 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 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 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 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 #[must_use]
377 pub fn base_url(&self) -> &str {
378 &self.base_url
379 }
380
381 #[must_use]
383 pub fn has_auth(&self) -> bool {
384 self.auth.is_some()
385 }
386
387 #[must_use]
389 pub fn cache_config(&self) -> &SchemaRegistryCacheConfig {
390 &self.cache_config
391 }
392
393 fn cache_insert(&self, id: i32, mut schema: CachedSchema) {
397 schema.inserted_at = Instant::now();
398 self.cache.insert(id, schema);
399 }
400
401 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 Some(schema)
415 }
416
417 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, 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 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 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 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 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 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 pub async fn resolve_confluent_id(&self, id: i32) -> Result<CachedSchema, ConnectorError> {
623 self.get_schema_by_id(id).await
624 }
625
626 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 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 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 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 #[must_use]
732 pub fn is_cached(&self, id: i32) -> bool {
733 self.cache.contains_key(&id)
734 }
735
736 #[must_use]
738 pub fn cache_size(&self) -> usize {
739 self.cache.len()
740 }
741
742 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
832fn 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
847pub 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
872pub 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 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
912fn 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 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 ®istration,
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 assert_eq!(fields[0]["type"], "long");
1320 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 #[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 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 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 client.cache_insert(4, make_cached_schema(4));
1660 assert!(client.cache_size() <= 3);
1661 assert!(client.cache_get(4).is_some());
1663 }
1664
1665 #[test]
1666 fn test_cache_ttl_expiration() {
1667 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 std::thread::sleep(Duration::from_millis(1200));
1682 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 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 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 let client = SchemaRegistryClient::new("http://localhost:8081", None).unwrap();
1736 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 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}