Skip to main content

mz_aws_glue_schema_registry/
client.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! The Glue Schema Registry client.
11
12use aws_sdk_glue::error::{DisplayErrorContext, SdkError};
13use aws_sdk_glue::operation::create_schema::CreateSchemaError as SdkCreateSchemaError;
14use aws_sdk_glue::operation::get_registry::GetRegistryError as SdkGetRegistryError;
15use aws_sdk_glue::operation::get_schema::GetSchemaError as SdkGetSchemaError;
16use aws_sdk_glue::operation::get_schema_version::{
17    GetSchemaVersionError as SdkGetSchemaVersionError, GetSchemaVersionOutput,
18};
19// Module aliases: these two operation error types share names with this crate's
20// own error enums, so they must be qualified, and their fully-aliased `use`
21// lines overflow the format width. A short module alias sidesteps both.
22use aws_sdk_glue::operation::get_schema_by_definition as sdk_get_by_def;
23use aws_sdk_glue::operation::register_schema_version as sdk_register;
24use aws_sdk_glue::types::{
25    Compatibility as SdkCompatibility, DataFormat as SdkDataFormat, RegistryId,
26    RegistryStatus as SdkRegistryStatus, SchemaId, SchemaStatus as SdkSchemaStatus,
27    SchemaVersionNumber, SchemaVersionStatus as SdkSchemaVersionStatus,
28};
29use aws_types::SdkConfig;
30use thiserror::Error;
31use uuid::Uuid;
32
33/// An API client for the AWS Glue Schema Registry.
34///
35/// `Client` is cheap to clone — internally it wraps an [`aws_sdk_glue::Client`],
36/// which is itself a clone-friendly handle backed by a shared connection pool.
37#[derive(Clone, Debug)]
38pub struct Client {
39    inner: aws_sdk_glue::Client,
40}
41
42impl Client {
43    pub(crate) fn from_sdk_config(sdk_config: SdkConfig) -> Self {
44        Client {
45            inner: aws_sdk_glue::Client::new(&sdk_config),
46        }
47    }
48
49    /// Wraps an existing SDK client.
50    ///
51    /// Exists so tests can inject a mocked SDK client (e.g. via
52    /// `aws_smithy_mocks`). Production callers construct clients through
53    /// [`ClientConfig`](crate::ClientConfig) instead, which is why this is
54    /// gated behind the `test-util` feature.
55    #[cfg(feature = "test-util")]
56    pub fn from_sdk_client(inner: aws_sdk_glue::Client) -> Self {
57        Client { inner }
58    }
59
60    /// Look up a registry by name.
61    ///
62    /// Returns [`GetRegistryError::NotFound`] if the registry does not exist
63    /// in the configured account and region. Other errors (auth failures,
64    /// throttling, transport) surface as [`GetRegistryError::Other`].
65    pub async fn get_registry(&self, name: &str) -> Result<Registry, GetRegistryError> {
66        let id = RegistryId::builder().registry_name(name).build();
67        let output = self
68            .inner
69            .get_registry()
70            .registry_id(id)
71            .send()
72            .await
73            .map_err(classify_get_registry_error)?;
74        Ok(Registry {
75            name: output.registry_name,
76            arn: output.registry_arn,
77            description: output.description,
78            lifecycle_status: output.status.map(RegistryLifecycleStatus::from_sdk),
79        })
80    }
81
82    /// Fetch a schema version by its UUID.
83    ///
84    /// This is the source-decode path: the UUID is read from the Glue
85    /// wire-format header, and the returned `SchemaVersion::definition`
86    /// carries the writer schema (Avro JSON, for our usage).
87    ///
88    /// Glue schema-version UUIDs are globally unique within an AWS account
89    /// and this call does **not** scope to any registry — it returns the
90    /// matching version from anywhere the configured credentials can read.
91    /// Returns [`GetSchemaVersionError::NotFound`] only when no schema
92    /// version with this UUID exists in any visible registry. Callers that
93    /// need to enforce a specific registry must check the returned
94    /// `SchemaArn` themselves.
95    ///
96    /// The AWS `GetSchemaVersion` API accepts *either* a `SchemaVersionId`
97    /// (the UUID, registry-agnostic) *or* `SchemaId + SchemaVersionNumber`,
98    /// never both — which is why looking up by name is a separate method,
99    /// [`Client::get_schema_version_latest_by_name`].
100    pub async fn get_schema_version_by_id(
101        &self,
102        id: Uuid,
103    ) -> Result<SchemaVersion, GetSchemaVersionError> {
104        let output = self
105            .inner
106            .get_schema_version()
107            .schema_version_id(id.to_string())
108            .send()
109            .await
110            .map_err(classify_get_schema_version_error)?;
111        Ok(SchemaVersion::from_sdk(output))
112    }
113
114    /// Fetch the latest version of a schema by `(registry_name, schema_name)`.
115    ///
116    /// This is the DDL-planning path: at `CREATE SOURCE` time we don't yet
117    /// know any per-record UUIDs, so we pin the reader schema to whatever
118    /// the registry currently calls "latest". Runtime schema resolution
119    /// for individual records still goes through
120    /// [`Client::get_schema_version_by_id`] using the UUID in each Kafka
121    /// payload's Glue header.
122    pub async fn get_schema_version_latest_by_name(
123        &self,
124        registry_name: &str,
125        schema_name: &str,
126    ) -> Result<SchemaVersion, GetSchemaVersionError> {
127        let schema_id = SchemaId::builder()
128            .registry_name(registry_name)
129            .schema_name(schema_name)
130            .build();
131        let version_number = SchemaVersionNumber::builder().latest_version(true).build();
132        let output = self
133            .inner
134            .get_schema_version()
135            .schema_id(schema_id)
136            .schema_version_number(version_number)
137            .send()
138            .await
139            .map_err(classify_get_schema_version_error)?;
140        Ok(SchemaVersion::from_sdk(output))
141    }
142
143    /// Look up the schema version whose definition byte-for-byte matches
144    /// `definition`.
145    ///
146    /// This is the sink reuse path: before registering a new version, callers
147    /// check whether the exact definition is already registered so that a sink
148    /// restart does not create a duplicate version. Returns
149    /// [`GetSchemaByDefinitionError::NotFound`] if the schema does not exist or
150    /// has no version matching `definition`.
151    ///
152    /// The match is by definition only: Glue also matches versions whose
153    /// lifecycle status is `Failure` or `Deleting`, so callers must check the
154    /// returned status before reusing the version's id.
155    pub async fn get_schema_by_definition(
156        &self,
157        registry_name: &str,
158        schema_name: &str,
159        definition: &str,
160    ) -> Result<RegisteredSchemaVersion, GetSchemaByDefinitionError> {
161        let schema_id = SchemaId::builder()
162            .registry_name(registry_name)
163            .schema_name(schema_name)
164            .build();
165        let output = self
166            .inner
167            .get_schema_by_definition()
168            .schema_id(schema_id)
169            .schema_definition(definition)
170            .send()
171            .await
172            .map_err(classify_get_schema_by_definition_error)?;
173        let id = parse_schema_version_id(output.schema_version_id)
174            .map_err(GetSchemaByDefinitionError::Other)?;
175        Ok(RegisteredSchemaVersion {
176            id,
177            lifecycle_status: output.status.map(SchemaVersionLifecycleStatus::from_sdk),
178        })
179    }
180
181    /// Register `definition` as a new version of an existing schema.
182    ///
183    /// The schema `(registry_name, schema_name)` must already exist. Returns
184    /// [`RegisterSchemaVersionError::SchemaNotFound`] if it does not, in which
185    /// case the caller should create it with [`Client::create_schema`].
186    /// Registering a definition identical to an existing version is idempotent
187    /// on Glue's side and returns that version.
188    ///
189    /// Glue runs the compatibility check asynchronously: a newly registered
190    /// version comes back `Pending` and only later transitions to `Available`
191    /// or `Failure`. Callers must not use the version's id until they have
192    /// observed it `Available`, polling via
193    /// [`Client::get_schema_version_by_id`].
194    pub async fn register_schema_version(
195        &self,
196        registry_name: &str,
197        schema_name: &str,
198        definition: &str,
199    ) -> Result<RegisteredSchemaVersion, RegisterSchemaVersionError> {
200        let schema_id = SchemaId::builder()
201            .registry_name(registry_name)
202            .schema_name(schema_name)
203            .build();
204        let output = self
205            .inner
206            .register_schema_version()
207            .schema_id(schema_id)
208            .schema_definition(definition)
209            .send()
210            .await
211            .map_err(classify_register_schema_version_error)?;
212        let id = parse_schema_version_id(output.schema_version_id)
213            .map_err(RegisterSchemaVersionError::Other)?;
214        Ok(RegisteredSchemaVersion {
215            id,
216            lifecycle_status: output.status.map(SchemaVersionLifecycleStatus::from_sdk),
217        })
218    }
219
220    /// Create a schema in `registry_name` with `definition` as its first version
221    /// and `compatibility` as its evolution policy, returning that first
222    /// version.
223    ///
224    /// A first version has no prior version to be compatible with, so it is
225    /// usually `Available` immediately, but callers should still confirm the
226    /// returned status before using the version's id.
227    ///
228    /// Glue sets a schema's compatibility only at creation. This crate exposes
229    /// no way to change it afterward, matching the sink's set-if-unset policy:
230    /// an existing schema's compatibility is read (via [`Client::get_schema`])
231    /// and warned on, never overwritten.
232    ///
233    /// Returns [`CreateSchemaError::AlreadyExists`] if the schema already exists
234    /// and [`CreateSchemaError::RegistryNotFound`] if the registry does not.
235    pub async fn create_schema(
236        &self,
237        registry_name: &str,
238        schema_name: &str,
239        data_format: DataFormat,
240        compatibility: Compatibility,
241        definition: &str,
242    ) -> Result<RegisteredSchemaVersion, CreateSchemaError> {
243        let registry_id = RegistryId::builder().registry_name(registry_name).build();
244        let output = self
245            .inner
246            .create_schema()
247            .registry_id(registry_id)
248            .schema_name(schema_name)
249            .data_format(data_format.to_sdk())
250            .compatibility(compatibility.to_sdk())
251            .schema_definition(definition)
252            .send()
253            .await
254            .map_err(classify_create_schema_error)?;
255        let id =
256            parse_schema_version_id(output.schema_version_id).map_err(CreateSchemaError::Other)?;
257        Ok(RegisteredSchemaVersion {
258            id,
259            lifecycle_status: output
260                .schema_version_status
261                .map(SchemaVersionLifecycleStatus::from_sdk),
262        })
263    }
264
265    /// Fetch a schema's metadata by `(registry_name, schema_name)`.
266    ///
267    /// The sink uses this to read the current compatibility policy so it can
268    /// warn on a mismatch without overwriting it, and to detect whether the
269    /// schema already exists. Returns [`GetSchemaError::NotFound`] if the schema
270    /// does not exist.
271    pub async fn get_schema(
272        &self,
273        registry_name: &str,
274        schema_name: &str,
275    ) -> Result<Schema, GetSchemaError> {
276        let schema_id = SchemaId::builder()
277            .registry_name(registry_name)
278            .schema_name(schema_name)
279            .build();
280        let output = self
281            .inner
282            .get_schema()
283            .schema_id(schema_id)
284            .send()
285            .await
286            .map_err(classify_get_schema_error)?;
287        Ok(Schema {
288            compatibility: output.compatibility.map(Compatibility::from_sdk),
289            lifecycle_status: output.schema_status.map(SchemaLifecycleStatus::from_sdk),
290        })
291    }
292}
293
294/// Parse a schema-version UUID from a Glue response, mapping a missing or
295/// malformed id to a diagnostic string for the caller to wrap.
296fn parse_schema_version_id(id: Option<String>) -> Result<Uuid, String> {
297    let id = id.ok_or_else(|| "Glue response missing schema version id".to_string())?;
298    Uuid::parse_str(&id).map_err(|e| format!("invalid Glue schema version id {id:?}: {e}"))
299}
300
301/// A Glue Schema Registry, as returned by [`Client::get_registry`].
302///
303/// Only the fields Materialize currently cares about are surfaced; the full
304/// SDK type carries a few additional timestamps that we ignore.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct Registry {
307    pub name: Option<String>,
308    pub arn: Option<String>,
309    pub description: Option<String>,
310    pub lifecycle_status: Option<RegistryLifecycleStatus>,
311}
312
313/// Lifecycle status of a Glue registry.
314///
315/// Mirrors `aws_sdk_glue::types::RegistryStatus`, with `Unknown(String)` as
316/// the forward-compat escape hatch for variants AWS may add later. Keeping
317/// our own enum means callers get exhaustive matching without taking a
318/// direct dependency on the SDK type.
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub enum RegistryLifecycleStatus {
321    Available,
322    Deleting,
323    /// A value the SDK reported that this crate does not yet know about.
324    Unknown(String),
325}
326
327impl RegistryLifecycleStatus {
328    fn from_sdk(status: SdkRegistryStatus) -> Self {
329        match &status {
330            SdkRegistryStatus::Available => RegistryLifecycleStatus::Available,
331            SdkRegistryStatus::Deleting => RegistryLifecycleStatus::Deleting,
332            _ => RegistryLifecycleStatus::Unknown(status.as_str().to_string()),
333        }
334    }
335}
336
337/// Errors returned by [`Client::get_registry`].
338#[derive(Debug, Error)]
339pub enum GetRegistryError {
340    /// The named registry does not exist in the configured account/region.
341    /// Maps from Glue's `EntityNotFoundException`.
342    #[error("registry not found")]
343    NotFound,
344    /// Anything else: auth failure, throttling, transport error, etc.
345    /// The wrapped message preserves the upstream SDK's diagnostic.
346    #[error("AWS Glue error: {0}")]
347    Other(String),
348}
349
350fn classify_get_registry_error(err: SdkError<SdkGetRegistryError>) -> GetRegistryError {
351    if let SdkError::ServiceError(service_err) = &err
352        && matches!(
353            service_err.err(),
354            SdkGetRegistryError::EntityNotFoundException(_)
355        )
356    {
357        return GetRegistryError::NotFound;
358    }
359    GetRegistryError::Other(DisplayErrorContext(&err).to_string())
360}
361
362/// A Glue schema version, as returned by [`Client::get_schema_version_by_id`]
363/// and [`Client::get_schema_version_latest_by_name`].
364///
365/// `definition` is the format-specific schema text; for Avro it is a JSON
366/// document the Avro parser can ingest directly. The remaining fields are
367/// informational and exist for debug logging.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct SchemaVersion {
370    pub schema_version_id: Option<String>,
371    pub schema_arn: Option<String>,
372    /// The format-specific schema text (Avro JSON, JSON Schema, etc.).
373    pub definition: Option<String>,
374    /// Glue's data-format tag — `AVRO`, `JSON`, or `PROTOBUF`. Mirrors the
375    /// SDK enum so callers get exhaustive matching without depending on the
376    /// SDK type directly.
377    pub data_format: Option<DataFormat>,
378    pub version_number: Option<i64>,
379    pub lifecycle_status: Option<SchemaVersionLifecycleStatus>,
380}
381
382impl SchemaVersion {
383    fn from_sdk(output: GetSchemaVersionOutput) -> Self {
384        SchemaVersion {
385            schema_version_id: output.schema_version_id,
386            schema_arn: output.schema_arn,
387            definition: output.schema_definition,
388            data_format: output.data_format.map(DataFormat::from_sdk),
389            version_number: output.version_number,
390            lifecycle_status: output.status.map(SchemaVersionLifecycleStatus::from_sdk),
391        }
392    }
393}
394
395/// A schema version's identity and lifecycle status, as returned by the write
396/// path methods [`Client::get_schema_by_definition`],
397/// [`Client::register_schema_version`], and [`Client::create_schema`].
398///
399/// Glue validates new versions asynchronously, so `lifecycle_status` is often
400/// `Pending` here. A version is only usable for framing records once it is
401/// `Available`. Callers holding a non-`Available` status must poll
402/// [`Client::get_schema_version_by_id`] until it resolves.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct RegisteredSchemaVersion {
405    pub id: Uuid,
406    pub lifecycle_status: Option<SchemaVersionLifecycleStatus>,
407}
408
409/// Data format of a Glue schema.
410///
411/// Mirrors `aws_sdk_glue::types::DataFormat`, with `Unknown(String)` as the
412/// forward-compat escape hatch for variants AWS may add later. See
413/// [`RegistryLifecycleStatus`] for the rationale.
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub enum DataFormat {
416    Avro,
417    Json,
418    Protobuf,
419    /// A value the SDK reported that this crate does not yet know about.
420    Unknown(String),
421}
422
423impl DataFormat {
424    fn from_sdk(format: SdkDataFormat) -> Self {
425        match &format {
426            SdkDataFormat::Avro => DataFormat::Avro,
427            SdkDataFormat::Json => DataFormat::Json,
428            SdkDataFormat::Protobuf => DataFormat::Protobuf,
429            _ => DataFormat::Unknown(format.as_str().to_string()),
430        }
431    }
432
433    fn to_sdk(&self) -> SdkDataFormat {
434        match self {
435            DataFormat::Avro => SdkDataFormat::Avro,
436            DataFormat::Json => SdkDataFormat::Json,
437            DataFormat::Protobuf => SdkDataFormat::Protobuf,
438            DataFormat::Unknown(s) => SdkDataFormat::from(s.as_str()),
439        }
440    }
441
442    /// Returns the canonical Glue data-format string (`AVRO`, `JSON`,
443    /// `PROTOBUF`), or the raw SDK-reported value for `Unknown`.
444    pub fn as_str(&self) -> &str {
445        match self {
446            DataFormat::Avro => "AVRO",
447            DataFormat::Json => "JSON",
448            DataFormat::Protobuf => "PROTOBUF",
449            DataFormat::Unknown(s) => s,
450        }
451    }
452}
453
454/// A schema's evolution policy, as accepted by [`Client::create_schema`] and
455/// returned by [`Client::get_schema`].
456///
457/// Mirrors `aws_sdk_glue::types::Compatibility`, with `Unknown(String)` as the
458/// forward-compat escape hatch. The `*All` variants are Glue's transitive
459/// modes (a new schema must be compatible with every prior version, not just
460/// the latest). `Disabled` turns off enforcement entirely and has no Confluent
461/// analogue. See [`RegistryLifecycleStatus`] for the mirroring rationale.
462#[derive(Debug, Clone, PartialEq, Eq)]
463pub enum Compatibility {
464    None,
465    Disabled,
466    Backward,
467    BackwardAll,
468    Forward,
469    ForwardAll,
470    Full,
471    FullAll,
472    /// A value the SDK reported that this crate does not yet know about.
473    Unknown(String),
474}
475
476impl Compatibility {
477    fn from_sdk(compatibility: SdkCompatibility) -> Self {
478        match &compatibility {
479            SdkCompatibility::None => Compatibility::None,
480            SdkCompatibility::Disabled => Compatibility::Disabled,
481            SdkCompatibility::Backward => Compatibility::Backward,
482            SdkCompatibility::BackwardAll => Compatibility::BackwardAll,
483            SdkCompatibility::Forward => Compatibility::Forward,
484            SdkCompatibility::ForwardAll => Compatibility::ForwardAll,
485            SdkCompatibility::Full => Compatibility::Full,
486            SdkCompatibility::FullAll => Compatibility::FullAll,
487            _ => Compatibility::Unknown(compatibility.as_str().to_string()),
488        }
489    }
490
491    fn to_sdk(&self) -> SdkCompatibility {
492        match self {
493            Compatibility::None => SdkCompatibility::None,
494            Compatibility::Disabled => SdkCompatibility::Disabled,
495            Compatibility::Backward => SdkCompatibility::Backward,
496            Compatibility::BackwardAll => SdkCompatibility::BackwardAll,
497            Compatibility::Forward => SdkCompatibility::Forward,
498            Compatibility::ForwardAll => SdkCompatibility::ForwardAll,
499            Compatibility::Full => SdkCompatibility::Full,
500            Compatibility::FullAll => SdkCompatibility::FullAll,
501            Compatibility::Unknown(s) => SdkCompatibility::from(s.as_str()),
502        }
503    }
504}
505
506/// A Glue schema's metadata, as returned by [`Client::get_schema`].
507///
508/// Only the fields the sink needs are surfaced. `compatibility` drives the
509/// warn-on-mismatch check; `lifecycle_status` distinguishes a live schema from
510/// one mid-deletion.
511#[derive(Debug, Clone, PartialEq, Eq)]
512pub struct Schema {
513    pub compatibility: Option<Compatibility>,
514    pub lifecycle_status: Option<SchemaLifecycleStatus>,
515}
516
517/// Lifecycle status of a Glue schema.
518///
519/// Mirrors `aws_sdk_glue::types::SchemaStatus`. Distinct from
520/// [`SchemaVersionLifecycleStatus`], which tracks an individual version. See
521/// [`RegistryLifecycleStatus`] for the mirroring rationale.
522#[derive(Debug, Clone, PartialEq, Eq)]
523pub enum SchemaLifecycleStatus {
524    Available,
525    Pending,
526    Deleting,
527    /// A value the SDK reported that this crate does not yet know about.
528    Unknown(String),
529}
530
531impl SchemaLifecycleStatus {
532    fn from_sdk(status: SdkSchemaStatus) -> Self {
533        match &status {
534            SdkSchemaStatus::Available => SchemaLifecycleStatus::Available,
535            SdkSchemaStatus::Pending => SchemaLifecycleStatus::Pending,
536            SdkSchemaStatus::Deleting => SchemaLifecycleStatus::Deleting,
537            _ => SchemaLifecycleStatus::Unknown(status.as_str().to_string()),
538        }
539    }
540}
541
542/// Lifecycle status of a Glue schema version.
543///
544/// Mirrors `aws_sdk_glue::types::SchemaVersionStatus`. See
545/// [`RegistryLifecycleStatus`] for the rationale.
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub enum SchemaVersionLifecycleStatus {
548    Available,
549    Pending,
550    Failure,
551    Deleting,
552    /// A value the SDK reported that this crate does not yet know about.
553    Unknown(String),
554}
555
556impl SchemaVersionLifecycleStatus {
557    fn from_sdk(status: SdkSchemaVersionStatus) -> Self {
558        match &status {
559            SdkSchemaVersionStatus::Available => SchemaVersionLifecycleStatus::Available,
560            SdkSchemaVersionStatus::Pending => SchemaVersionLifecycleStatus::Pending,
561            SdkSchemaVersionStatus::Failure => SchemaVersionLifecycleStatus::Failure,
562            SdkSchemaVersionStatus::Deleting => SchemaVersionLifecycleStatus::Deleting,
563            _ => SchemaVersionLifecycleStatus::Unknown(status.as_str().to_string()),
564        }
565    }
566}
567
568/// Errors returned by [`Client::get_schema_version_by_id`] and
569/// [`Client::get_schema_version_latest_by_name`].
570#[derive(Debug, Error)]
571pub enum GetSchemaVersionError {
572    /// No matching schema version exists in the configured account/region.
573    /// Maps from Glue's `EntityNotFoundException`.
574    #[error("schema version not found")]
575    NotFound,
576    /// Anything else: auth failure, throttling, transport error, etc.
577    #[error("AWS Glue error: {0}")]
578    Other(String),
579}
580
581fn classify_get_schema_version_error(
582    err: SdkError<SdkGetSchemaVersionError>,
583) -> GetSchemaVersionError {
584    if let SdkError::ServiceError(service_err) = &err
585        && matches!(
586            service_err.err(),
587            SdkGetSchemaVersionError::EntityNotFoundException(_)
588        )
589    {
590        return GetSchemaVersionError::NotFound;
591    }
592    GetSchemaVersionError::Other(DisplayErrorContext(&err).to_string())
593}
594
595/// Errors returned by [`Client::get_schema_by_definition`].
596#[derive(Debug, Error)]
597pub enum GetSchemaByDefinitionError {
598    /// No schema version with the given definition exists (either the schema is
599    /// absent or none of its versions match). Maps from Glue's
600    /// `EntityNotFoundException`.
601    #[error("schema version for definition not found")]
602    NotFound,
603    /// Anything else: auth failure, throttling, transport error, or a malformed
604    /// version id in the response.
605    #[error("AWS Glue error: {0}")]
606    Other(String),
607}
608
609fn classify_get_schema_by_definition_error(
610    err: SdkError<sdk_get_by_def::GetSchemaByDefinitionError>,
611) -> GetSchemaByDefinitionError {
612    if let SdkError::ServiceError(service_err) = &err
613        && matches!(
614            service_err.err(),
615            sdk_get_by_def::GetSchemaByDefinitionError::EntityNotFoundException(_)
616        )
617    {
618        return GetSchemaByDefinitionError::NotFound;
619    }
620    GetSchemaByDefinitionError::Other(DisplayErrorContext(&err).to_string())
621}
622
623/// Errors returned by [`Client::register_schema_version`].
624#[derive(Debug, Error)]
625pub enum RegisterSchemaVersionError {
626    /// The target schema does not exist, so there is nothing to add a version
627    /// to. The caller should create it with [`Client::create_schema`]. Maps
628    /// from Glue's `EntityNotFoundException`.
629    #[error("schema not found")]
630    SchemaNotFound,
631    /// Anything else: auth failure, throttling, transport error, or a malformed
632    /// version id in the response.
633    #[error("AWS Glue error: {0}")]
634    Other(String),
635}
636
637fn classify_register_schema_version_error(
638    err: SdkError<sdk_register::RegisterSchemaVersionError>,
639) -> RegisterSchemaVersionError {
640    if let SdkError::ServiceError(service_err) = &err
641        && matches!(
642            service_err.err(),
643            sdk_register::RegisterSchemaVersionError::EntityNotFoundException(_)
644        )
645    {
646        return RegisterSchemaVersionError::SchemaNotFound;
647    }
648    RegisterSchemaVersionError::Other(DisplayErrorContext(&err).to_string())
649}
650
651/// Errors returned by [`Client::create_schema`].
652#[derive(Debug, Error)]
653pub enum CreateSchemaError {
654    /// A schema with this name already exists in the registry. The caller
655    /// should reuse it via [`Client::get_schema_by_definition`] and
656    /// [`Client::register_schema_version`]. Maps from Glue's
657    /// `AlreadyExistsException`.
658    #[error("schema already exists")]
659    AlreadyExists,
660    /// The target registry does not exist. Maps from Glue's
661    /// `EntityNotFoundException`.
662    #[error("registry not found")]
663    RegistryNotFound,
664    /// Anything else: auth failure, throttling, transport error, or a malformed
665    /// version id in the response.
666    #[error("AWS Glue error: {0}")]
667    Other(String),
668}
669
670fn classify_create_schema_error(err: SdkError<SdkCreateSchemaError>) -> CreateSchemaError {
671    if let SdkError::ServiceError(service_err) = &err {
672        match service_err.err() {
673            SdkCreateSchemaError::AlreadyExistsException(_) => {
674                return CreateSchemaError::AlreadyExists;
675            }
676            SdkCreateSchemaError::EntityNotFoundException(_) => {
677                return CreateSchemaError::RegistryNotFound;
678            }
679            _ => {}
680        }
681    }
682    CreateSchemaError::Other(DisplayErrorContext(&err).to_string())
683}
684
685/// Errors returned by [`Client::get_schema`].
686#[derive(Debug, Error)]
687pub enum GetSchemaError {
688    /// The schema does not exist. Maps from Glue's `EntityNotFoundException`.
689    #[error("schema not found")]
690    NotFound,
691    /// Anything else: auth failure, throttling, transport error, etc.
692    #[error("AWS Glue error: {0}")]
693    Other(String),
694}
695
696fn classify_get_schema_error(err: SdkError<SdkGetSchemaError>) -> GetSchemaError {
697    if let SdkError::ServiceError(service_err) = &err
698        && matches!(
699            service_err.err(),
700            SdkGetSchemaError::EntityNotFoundException(_)
701        )
702    {
703        return GetSchemaError::NotFound;
704    }
705    GetSchemaError::Other(DisplayErrorContext(&err).to_string())
706}
707
708#[cfg(test)]
709mod tests {
710    use aws_sdk_glue::operation::create_schema::CreateSchemaOutput;
711    use aws_sdk_glue::operation::get_schema_by_definition::GetSchemaByDefinitionOutput;
712    use aws_sdk_glue::operation::register_schema_version::RegisterSchemaVersionOutput;
713    use aws_smithy_mocks::{RuleMode, mock, mock_client};
714
715    use super::*;
716
717    const VERSION_ID: &str = "12345678-1234-5678-1234-567812345678";
718
719    /// The write methods must surface the lifecycle status from each response:
720    /// Glue validates versions asynchronously, so callers gate on it before
721    /// using a version's id.
722    #[mz_ore::test(tokio::test)]
723    async fn write_methods_surface_lifecycle_status() {
724        let register = mock!(aws_sdk_glue::Client::register_schema_version).then_output(|| {
725            RegisterSchemaVersionOutput::builder()
726                .schema_version_id(VERSION_ID)
727                .status(SdkSchemaVersionStatus::Pending)
728                .build()
729        });
730        let by_definition =
731            mock!(aws_sdk_glue::Client::get_schema_by_definition).then_output(|| {
732                GetSchemaByDefinitionOutput::builder()
733                    .schema_version_id(VERSION_ID)
734                    .status(SdkSchemaVersionStatus::Failure)
735                    .build()
736            });
737        let create = mock!(aws_sdk_glue::Client::create_schema).then_output(|| {
738            CreateSchemaOutput::builder()
739                .schema_version_id(VERSION_ID)
740                .schema_version_status(SdkSchemaVersionStatus::Available)
741                .build()
742        });
743        let client = Client {
744            inner: mock_client!(
745                aws_sdk_glue,
746                RuleMode::MatchAny,
747                &[&register, &by_definition, &create]
748            ),
749        };
750
751        let id = Uuid::parse_str(VERSION_ID).expect("valid uuid literal");
752        assert_eq!(
753            client
754                .register_schema_version("registry", "schema", "{}")
755                .await
756                .expect("mocked register succeeds"),
757            RegisteredSchemaVersion {
758                id,
759                lifecycle_status: Some(SchemaVersionLifecycleStatus::Pending),
760            }
761        );
762        assert_eq!(
763            client
764                .get_schema_by_definition("registry", "schema", "{}")
765                .await
766                .expect("mocked lookup succeeds"),
767            RegisteredSchemaVersion {
768                id,
769                lifecycle_status: Some(SchemaVersionLifecycleStatus::Failure),
770            }
771        );
772        assert_eq!(
773            client
774                .create_schema(
775                    "registry",
776                    "schema",
777                    DataFormat::Avro,
778                    Compatibility::Backward,
779                    "{}",
780                )
781                .await
782                .expect("mocked create succeeds"),
783            RegisteredSchemaVersion {
784                id,
785                lifecycle_status: Some(SchemaVersionLifecycleStatus::Available),
786            }
787        );
788    }
789
790    #[mz_ore::test]
791    fn parse_schema_version_id_valid() {
792        let uuid = Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
793        assert_eq!(parse_schema_version_id(Some(uuid.to_string())), Ok(uuid));
794    }
795
796    #[mz_ore::test]
797    fn parse_schema_version_id_missing() {
798        let err = parse_schema_version_id(None).unwrap_err();
799        assert!(err.contains("missing schema version id"), "{err}");
800    }
801
802    #[mz_ore::test]
803    fn parse_schema_version_id_malformed() {
804        let err = parse_schema_version_id(Some("not-a-uuid".to_string())).unwrap_err();
805        assert!(err.contains("invalid Glue schema version id"), "{err}");
806    }
807
808    #[mz_ore::test]
809    fn compatibility_sdk_round_trip() {
810        // Every known variant must survive to_sdk -> from_sdk unchanged, so the
811        // CSR-to-Glue mapping in the sink can rely on stable identities.
812        for c in [
813            Compatibility::None,
814            Compatibility::Disabled,
815            Compatibility::Backward,
816            Compatibility::BackwardAll,
817            Compatibility::Forward,
818            Compatibility::ForwardAll,
819            Compatibility::Full,
820            Compatibility::FullAll,
821        ] {
822            assert_eq!(Compatibility::from_sdk(c.to_sdk()), c);
823        }
824    }
825
826    #[mz_ore::test]
827    fn data_format_sdk_round_trip() {
828        for f in [DataFormat::Avro, DataFormat::Json, DataFormat::Protobuf] {
829            assert_eq!(DataFormat::from_sdk(f.to_sdk()), f);
830        }
831    }
832}