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;