Skip to main content

unitycatalog_delta_api/
models.rs

1//! Hand-written serde models for the UC Delta REST API (`/delta/v1/...`).
2//!
3//! These mirror the component schemas in `openapi/delta.yaml` (vendored from the
4//! Unity Catalog Java reference, `api/delta.yaml`). The wire format is kebab-case
5//! JSON, so every struct uses `#[serde(rename_all = "kebab-case")]`; individual
6//! fields whose JSON key is not a straight kebab-case of the Rust identifier
7//! carry an explicit `#[serde(rename = "...")]`.
8//!
9//! This is a standalone REST protocol (not resource-CRUD), so — unlike most of
10//! the workspace — it is not generated from proto. The pure wire DTOs live here
11//! so every server implementation of the Delta API and every client shares a
12//! single definition; the runtime error envelope (`DeltaApiError` +
13//! `IntoResponse`) lives in the sibling [`crate::error`] module.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19// ===================================================================
20// Enums
21// ===================================================================
22
23/// The type of a Delta table.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
26pub enum DeltaTableType {
27    Managed,
28    External,
29}
30
31/// The data source format of a table, as carried on `DeltaCreateTableRequest`.
32///
33/// Mirrors the canonical Unity Catalog `DataSourceFormat` set. Serializes to the
34/// uppercase UC format strings (e.g. `"DELTA"`), matching what the Java OSS
35/// server's createTable handler expects. The Delta REST API only operates on
36/// Delta tables, so in practice this is always [`Delta`](Self::Delta); the wider
37/// set is kept so the field round-trips any value the catalog might report.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
40pub enum DeltaDataSourceFormat {
41    Delta,
42    Iceberg,
43    Hudi,
44    Parquet,
45    Csv,
46    Json,
47    Orc,
48    Avro,
49    Text,
50    UnityCatalog,
51    Deltasharing,
52}
53
54/// The permission level for a storage credential.
55pub use olai_storage_credentials::wire::delta_api::Operation as DeltaCredentialOperation;
56
57// ===================================================================
58// Shared credential types
59// ===================================================================
60
61/// Temporary storage credential with prefix and config: the config keys are
62/// `s3.access-key-id`, `s3.secret-access-key`, `s3.session-token`,
63/// `azure.sas-token` and `gcs.oauth-token` (see
64/// [`olai_storage_credentials::wire::delta_api`]).
65pub use olai_storage_credentials::wire::delta_api::StorageCredential as DeltaStorageCredential;
66
67/// Response carrying temporary cloud storage credentials.
68pub use olai_storage_credentials::wire::delta_api::CredentialsResponse as DeltaCredentialsResponse;
69
70// ===================================================================
71// Configuration
72// ===================================================================
73
74/// Catalog configuration and supported endpoints.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "kebab-case")]
77pub struct DeltaCatalogConfig {
78    /// List of supported endpoints.
79    pub endpoints: Vec<String>,
80    /// The negotiated protocol version (e.g., "1.0").
81    pub protocol_version: String,
82}
83
84// ===================================================================
85// Delta data types (schema)
86// ===================================================================
87
88/// A Delta column data type.
89///
90/// On the wire, primitive types are bare JSON strings (e.g. `"long"`, `"string"`,
91/// `"decimal(10,2)"`) while complex types are JSON objects carrying a `type`
92/// discriminator (`"array"`, `"map"`, `"struct"`, `"decimal"`). serde's `untagged`
93/// representation drives the split: a JSON string matches [`DeltaDataType::Primitive`];
94/// a JSON object matches one of the object variants. Each object variant carries its
95/// own `type` constant so it both round-trips and self-identifies.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(untagged)]
98pub enum DeltaDataType {
99    /// Bare-string primitive type, e.g. `"long"`, `"string"`, `"decimal(10,2)"`.
100    Primitive(String),
101    Array(Box<DeltaArrayType>),
102    Map(Box<DeltaMapType>),
103    Struct(Box<DeltaStructType>),
104    /// Object form of decimal (`{"type":"decimal","precision":..,"scale":..}`).
105    /// The string form `"decimal(p,s)"` is carried by [`DeltaDataType::Primitive`].
106    Decimal(DeltaDecimalType),
107}
108
109/// The `type` discriminator constant for an array data type.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
111#[serde(rename_all = "lowercase")]
112pub enum ArrayTypeTag {
113    #[default]
114    Array,
115}
116
117/// The `type` discriminator constant for a map data type.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum MapTypeTag {
121    #[default]
122    Map,
123}
124
125/// The `type` discriminator constant for a decimal data type.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128pub enum DecimalTypeTag {
129    #[default]
130    Decimal,
131}
132
133/// Object form of an array type (`{"type":"array","element-type":..,"contains-null":..}`).
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135#[serde(rename_all = "kebab-case")]
136pub struct DeltaArrayType {
137    #[serde(rename = "type", default)]
138    pub type_tag: ArrayTypeTag,
139    pub element_type: DeltaDataType,
140    pub contains_null: bool,
141}
142
143/// Object form of a map type (`{"type":"map","key-type":..,"value-type":..}`).
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145#[serde(rename_all = "kebab-case")]
146pub struct DeltaMapType {
147    #[serde(rename = "type", default)]
148    pub type_tag: MapTypeTag,
149    pub key_type: DeltaDataType,
150    pub value_type: DeltaDataType,
151    pub value_contains_null: bool,
152}
153
154/// Object form of a decimal type (`{"type":"decimal","precision":..,"scale":..}`).
155///
156/// The compact string form `"decimal(p,s)"` is carried by
157/// [`DeltaDataType::Primitive`] instead.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct DeltaDecimalType {
160    #[serde(rename = "type", default)]
161    pub type_tag: DecimalTypeTag,
162    pub precision: i32,
163    pub scale: i32,
164}
165
166/// A field in a [`DeltaStructType`]: name, type, nullability, and metadata.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct DeltaStructField {
169    pub name: String,
170    #[serde(rename = "type")]
171    pub data_type: DeltaDataType,
172    pub nullable: bool,
173    /// Arbitrary column metadata (e.g. `comment`, `delta.columnMapping.id`).
174    /// Values can be strings, numbers, booleans, or nested objects.
175    pub metadata: BTreeMap<String, serde_json::Value>,
176}
177
178/// Struct type containing named fields. Carries the `type: "struct"`
179/// discriminator on the wire (it is the schema container for tables), so it is
180/// emitted/accepted explicitly even when used as a bare `columns` value.
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182pub struct DeltaStructType {
183    #[serde(rename = "type", default = "struct_type_tag")]
184    pub type_tag: StructTypeTag,
185    pub fields: Vec<DeltaStructField>,
186}
187
188fn struct_type_tag() -> StructTypeTag {
189    StructTypeTag::Struct
190}
191
192/// The constant `"struct"` discriminator for [`DeltaStructType`].
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
194#[serde(rename_all = "lowercase")]
195pub enum StructTypeTag {
196    #[default]
197    Struct,
198}
199
200// ===================================================================
201// Protocol
202// ===================================================================
203
204/// Delta table protocol specification.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "kebab-case")]
207pub struct DeltaProtocol {
208    pub min_reader_version: i32,
209    pub min_writer_version: i32,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub reader_features: Option<Vec<String>>,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub writer_features: Option<Vec<String>>,
214}
215
216/// Suggested protocol advertised in the staging response (no version fields,
217/// only feature hints).
218#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "kebab-case")]
220pub struct DeltaSuggestedProtocol {
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub reader_features: Option<Vec<String>>,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub writer_features: Option<Vec<String>>,
225}
226
227// ===================================================================
228// Domain metadata
229// ===================================================================
230
231/// Configuration for Clustered Tables.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct DeltaClusteringDomainMetadata {
234    /// Clustering column paths. Each inner array is a path from root schema to a
235    /// column.
236    #[serde(rename = "clusteringColumns")]
237    pub clustering_columns: Vec<Vec<String>>,
238}
239
240/// Configuration for Row Tracking.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct DeltaRowTrackingDomainMetadata {
243    /// The highest fresh Row ID assigned so far.
244    #[serde(rename = "rowIdHighWaterMark")]
245    pub row_id_high_water_mark: i64,
246}
247
248/// Domain metadata entries keyed by domain name.
249#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
250pub struct DeltaDomainMetadataUpdates {
251    #[serde(rename = "delta.clustering", skip_serializing_if = "Option::is_none")]
252    pub delta_clustering: Option<DeltaClusteringDomainMetadata>,
253    #[serde(rename = "delta.rowTracking", skip_serializing_if = "Option::is_none")]
254    pub delta_row_tracking: Option<DeltaRowTrackingDomainMetadata>,
255}
256
257// ===================================================================
258// Uniform / Iceberg
259// ===================================================================
260
261/// Iceberg-specific conversion metadata.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "kebab-case")]
264pub struct DeltaIcebergMetadata {
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub metadata_location: Option<String>,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub converted_delta_version: Option<i64>,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub converted_delta_timestamp: Option<i64>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub base_converted_delta_version: Option<i64>,
273}
274
275/// UniForm conversion metadata. Present only for Uniform (Delta + Iceberg) tables.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct DeltaUniformMetadata {
278    pub iceberg: DeltaIcebergMetadata,
279}
280
281// ===================================================================
282// Commit
283// ===================================================================
284
285/// Delta commit information for CCv2.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(rename_all = "kebab-case")]
288pub struct DeltaCommit {
289    pub version: i64,
290    /// In-commit timestamp, in epoch milliseconds.
291    pub timestamp: i64,
292    /// UUID-based commit file name.
293    pub file_name: String,
294    pub file_size: i64,
295    /// File modification timestamp, in epoch milliseconds.
296    pub file_modification_timestamp: i64,
297}
298
299// ===================================================================
300// Table lifecycle
301// ===================================================================
302
303/// Request to create a staging table.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305pub struct DeltaCreateStagingTableRequest {
306    pub name: String,
307}
308
309/// Response from creating a staging table. Advertises the UC catalog-managed
310/// contract (required / suggested protocol + properties) plus READ_WRITE
311/// credentials for writing the initial commit.
312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
313#[serde(rename_all = "kebab-case")]
314pub struct DeltaStagingTableResponse {
315    pub table_id: String,
316    pub table_type: DeltaTableType,
317    pub location: String,
318    pub storage_credentials: Vec<DeltaStorageCredential>,
319    pub required_protocol: DeltaProtocol,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub suggested_protocol: Option<DeltaSuggestedProtocol>,
322    /// Properties that must be set; null values mean any valid value is allowed.
323    pub required_properties: BTreeMap<String, Option<String>>,
324    /// Properties that should be set whenever possible; null values mean the
325    /// client generates the value.
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub suggested_properties: Option<BTreeMap<String, Option<String>>>,
328}
329
330/// Request to create a Delta table (managed or external).
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332#[serde(rename_all = "kebab-case")]
333pub struct DeltaCreateTableRequest {
334    pub name: String,
335    pub location: String,
336    pub table_type: DeltaTableType,
337    /// Data source format — `"DELTA"` for Delta tables. Required by the running
338    /// Java UC OSS server (`ghcr.io/roeap/unitycatalog:v0.0.0-dev-3`), whose
339    /// createTable handler reads it from the request body. (Newer server code
340    /// hardcodes `DELTA` in `DeltaCreateTableMapper` and ignores this field, and
341    /// the `delta.yaml` schema omits it — a spec/impl drift; sending it satisfies
342    /// both.)
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub data_source_format: Option<DeltaDataSourceFormat>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub comment: Option<String>,
347    pub columns: DeltaStructType,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub partition_columns: Option<Vec<String>>,
350    pub protocol: DeltaProtocol,
351    pub properties: BTreeMap<String, String>,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub domain_metadata: Option<DeltaDomainMetadataUpdates>,
354    /// Timestamp of version 0 (the commit the client wrote before calling this
355    /// endpoint), in epoch milliseconds.
356    pub last_commit_timestamp_ms: i64,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub uniform: Option<DeltaUniformMetadata>,
359}
360
361/// Complete table metadata.
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363#[serde(rename_all = "kebab-case")]
364pub struct DeltaTableMetadata {
365    /// Entity tag for optimistic concurrency control.
366    pub etag: String,
367    pub table_type: DeltaTableType,
368    pub table_uuid: String,
369    pub location: String,
370    pub created_time: i64,
371    pub updated_time: i64,
372    pub columns: DeltaStructType,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub partition_columns: Option<Vec<String>>,
375    pub properties: BTreeMap<String, String>,
376    /// The version of the last commit that changed table metadata
377    /// (`delta.lastUpdateVersion`).
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub last_commit_version: Option<i64>,
380    /// Timestamp of the last commit that changed table metadata, in epoch
381    /// milliseconds (`delta.lastCommitTimestamp`).
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub last_commit_timestamp_ms: Option<i64>,
384}
385
386/// Response from `loadTable` / `createTable` / `updateTable`.
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388#[serde(rename_all = "kebab-case")]
389pub struct DeltaLoadTableResponse {
390    pub metadata: DeltaTableMetadata,
391    /// All unbackfilled CCv2 commits.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub commits: Option<Vec<DeltaCommit>>,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub uniform: Option<DeltaUniformMetadata>,
396    /// The latest ratified table version tracked by the server, including
397    /// data-only commits.
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub latest_table_version: Option<i64>,
400}
401
402/// Request to rename a table within the same catalog and schema.
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404#[serde(rename_all = "kebab-case")]
405pub struct DeltaRenameTableRequest {
406    pub new_name: String,
407}
408
409// ===================================================================
410// Update table (requirements + actions)
411// ===================================================================
412
413/// A pre-condition that must hold for the update to apply.
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(tag = "type", rename_all = "kebab-case")]
416pub enum DeltaTableRequirement {
417    /// `assert-table-uuid`: the table UUID must match `uuid`.
418    AssertTableUuid { uuid: String },
419    /// `assert-etag`: the table etag must match `etag`.
420    AssertEtag { etag: String },
421}
422
423/// A single update action. Tagged by the `action` discriminator, matching the
424/// `propertyName: action` + `const` mapping in `delta.yaml`.
425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
426#[serde(tag = "action", rename_all = "kebab-case")]
427pub enum DeltaTableUpdate {
428    SetProperties {
429        updates: BTreeMap<String, String>,
430    },
431    RemoveProperties {
432        removals: Vec<String>,
433    },
434    SetColumns {
435        columns: DeltaStructType,
436    },
437    SetTableComment {
438        comment: String,
439    },
440    AddCommit {
441        commit: DeltaCommit,
442        #[serde(skip_serializing_if = "Option::is_none")]
443        uniform: Option<DeltaUniformMetadata>,
444    },
445    SetLatestBackfilledVersion {
446        #[serde(rename = "latest-published-version")]
447        latest_published_version: i64,
448    },
449    SetProtocol {
450        protocol: DeltaProtocol,
451    },
452    SetDomainMetadata {
453        updates: DeltaDomainMetadataUpdates,
454    },
455    RemoveDomainMetadata {
456        domains: Vec<String>,
457    },
458    SetPartitionColumns {
459        #[serde(rename = "partition-columns")]
460        partition_columns: Vec<String>,
461    },
462    UpdateMetadataSnapshotVersion {
463        #[serde(rename = "last-commit-version")]
464        last_commit_version: i64,
465        #[serde(rename = "last-commit-timestamp-ms")]
466        last_commit_timestamp_ms: i64,
467    },
468}
469
470/// Request to update a table with requirements and updates.
471#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
472pub struct DeltaUpdateTableRequest {
473    pub requirements: Vec<DeltaTableRequirement>,
474    pub updates: Vec<DeltaTableUpdate>,
475}
476
477// ===================================================================
478// Metrics
479// ===================================================================
480
481/// Histogram tracking file counts and total bytes across size ranges.
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(rename_all = "kebab-case")]
484pub struct DeltaFileSizeHistogram {
485    pub sorted_bin_boundaries: Vec<i64>,
486    pub file_counts: Vec<i64>,
487    pub total_bytes: Vec<i64>,
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub commit_version: Option<i64>,
490}
491
492/// Commit report metrics.
493#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "kebab-case")]
495pub struct DeltaCommitReport {
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub num_files_added: Option<i64>,
498    #[serde(skip_serializing_if = "Option::is_none")]
499    pub num_bytes_added: Option<i64>,
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub num_files_removed: Option<i64>,
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub num_bytes_removed: Option<i64>,
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub num_rows_inserted: Option<i64>,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub num_rows_removed: Option<i64>,
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub num_rows_updated: Option<i64>,
510    #[serde(skip_serializing_if = "Option::is_none")]
511    pub file_size_histogram: Option<DeltaFileSizeHistogram>,
512}
513
514/// The metrics being reported.
515#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "kebab-case")]
517pub struct DeltaReport {
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub commit_report: Option<DeltaCommitReport>,
520}
521
522/// Request to report commit metrics (telemetry) for a table.
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(rename_all = "kebab-case")]
525pub struct DeltaReportMetricsRequest {
526    /// Table UUID. Must match the table identified by the path.
527    pub table_id: String,
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub report: Option<DeltaReport>,
530}
531
532// ===================================================================
533// Errors (the /delta/v1 envelope)
534// ===================================================================
535
536/// Error type identifier returned in Delta API error responses. Mirrors the
537/// `DeltaErrorType` enum in `delta.yaml`.
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
539pub enum DeltaErrorType {
540    BadRequestException,
541    InvalidParameterValueException,
542    UnsupportedTableFormatException,
543    NotAuthorizedException,
544    PermissionDeniedException,
545    NotFoundException,
546    NoSuchCatalogException,
547    NoSuchSchemaException,
548    NoSuchTableException,
549    AlreadyExistsException,
550    CommitVersionConflictException,
551    UpdateRequirementConflictException,
552    ResourceExhaustedException,
553    TooManyRequestsException,
554    CommitStateUnknownException,
555    InternalServerErrorException,
556    NotImplementedException,
557}
558
559impl DeltaErrorType {
560    /// Whether this error type denotes a missing resource (404 family).
561    pub fn is_not_found(&self) -> bool {
562        matches!(
563            self,
564            DeltaErrorType::NotFoundException
565                | DeltaErrorType::NoSuchCatalogException
566                | DeltaErrorType::NoSuchSchemaException
567                | DeltaErrorType::NoSuchTableException
568        )
569    }
570
571    /// Whether this error type denotes a name collision (`AlreadyExistsException`).
572    pub fn is_already_exists(&self) -> bool {
573        matches!(self, DeltaErrorType::AlreadyExistsException)
574    }
575
576    /// Whether this error type denotes a commit-version conflict that the client
577    /// should resolve by rebuilding its snapshot and retrying.
578    pub fn is_commit_conflict(&self) -> bool {
579        matches!(self, DeltaErrorType::CommitVersionConflictException)
580    }
581
582    /// Whether this error type denotes an unmet `assert-etag`/`assert-table-uuid`
583    /// requirement; the client should reload the table and retry.
584    pub fn is_update_requirement_conflict(&self) -> bool {
585        matches!(self, DeltaErrorType::UpdateRequirementConflictException)
586    }
587
588    /// Whether the request was throttled or hit a resource limit (429 family).
589    /// The client should back off (and, for `ResourceExhaustedException`,
590    /// backfill pending commits) before retrying.
591    pub fn is_resource_exhausted(&self) -> bool {
592        matches!(
593            self,
594            DeltaErrorType::ResourceExhaustedException | DeltaErrorType::TooManyRequestsException
595        )
596    }
597
598    /// Whether the commit outcome is unknown; the client must check table state
599    /// before retrying to avoid duplicate commits.
600    pub fn is_commit_state_unknown(&self) -> bool {
601        matches!(self, DeltaErrorType::CommitStateUnknownException)
602    }
603
604    /// Whether the table is not Delta, or is a Delta table this `/delta/v1`
605    /// endpoint does not support (`UnsupportedTableFormatException`, a 400). Per
606    /// the spec the client should fall back to the legacy UC table API.
607    pub fn is_unsupported_table_format(&self) -> bool {
608        matches!(self, DeltaErrorType::UnsupportedTableFormatException)
609    }
610
611    /// Whether the requested `/delta/v1` functionality is not supported by the
612    /// server (`NotImplementedException`, a 501). The client should fall back to
613    /// the legacy UC table API.
614    pub fn is_not_implemented(&self) -> bool {
615        matches!(self, DeltaErrorType::NotImplementedException)
616    }
617}
618
619/// The JSON error payload (`DeltaErrorModel` in `delta.yaml`).
620#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct DeltaErrorModel {
622    pub message: String,
623    #[serde(rename = "type")]
624    pub error_type: DeltaErrorType,
625    /// HTTP response code.
626    pub code: u16,
627    /// Stack trace, only populated when the server runs in debug mode.
628    #[serde(skip_serializing_if = "Option::is_none")]
629    pub stack: Option<Vec<String>>,
630}
631
632/// The JSON wrapper for all Delta API error responses (`DeltaErrorResponse`).
633#[derive(Debug, Clone, Serialize, Deserialize)]
634pub struct DeltaErrorResponse {
635    pub error: DeltaErrorModel,
636}
637
638#[cfg(test)]
639mod tests {
640    //! Round-trip tests over the example payloads in `openapi/delta.yaml`.
641    //!
642    //! Each test deserializes a spec example into our hand-written model and
643    //! re-serializes it, asserting the JSON survives a round-trip with the
644    //! kebab-case keys, enum casing, and tagged-union discriminators intact.
645    //! This is the Phase-1 parity gate: it pins our models to the vendored spec.
646
647    use super::*;
648    use serde_json::{Value, json};
649
650    /// Deserialize `json` into `T`, re-serialize, and assert structural equality
651    /// with the original JSON value (key order independent).
652    fn round_trip<T>(value: Value)
653    where
654        T: Serialize + for<'de> Deserialize<'de>,
655    {
656        let parsed: T = serde_json::from_value(value.clone())
657            .unwrap_or_else(|e| panic!("deserialize failed: {e}\njson: {value:#}"));
658        let reserialized = serde_json::to_value(&parsed).expect("serialize");
659        assert_eq!(
660            reserialized, value,
661            "round-trip mismatch\n  expected: {value:#}\n  got:      {reserialized:#}"
662        );
663    }
664
665    #[test]
666    fn staging_table_response() {
667        round_trip::<DeltaStagingTableResponse>(json!({
668            "table-id": "123e4567-e89b-12d3-a456-426614174000",
669            "table-type": "MANAGED",
670            "location": "s3://bucket/warehouse/catalog/schema/table",
671            "storage-credentials": [{
672                "prefix": "s3://bucket/warehouse/catalog/schema/table/",
673                "operation": "READ_WRITE",
674                "config": {
675                    "s3.access-key-id": "AK...example",
676                    "s3.secret-access-key": "ExampleKey",
677                    "s3.session-token": "token"
678                },
679                "expiration-time-ms": 1234567890000_i64
680            }],
681            "required-protocol": {
682                "min-reader-version": 3,
683                "min-writer-version": 7,
684                "reader-features": ["deletionVectors", "vacuumProtocolCheck"],
685                "writer-features": ["catalogManaged", "deletionVectors"]
686            },
687            "suggested-protocol": {
688                "reader-features": ["typeWidening"],
689                "writer-features": ["domainMetadata", "rowTracking"]
690            },
691            "required-properties": { "delta.checkpointPolicy": "v2" },
692            "suggested-properties": {
693                "delta.rowTracking.materializedRowIdColumnName": null,
694                "delta.rowTracking.materializedRowCommitVersionColumnName": null
695            }
696        }));
697    }
698
699    #[test]
700    fn create_table_request_with_decimal_and_partition() {
701        round_trip::<DeltaCreateTableRequest>(json!({
702            "name": "sales",
703            "location": "s3://bucket/warehouse/catalog/schema/sales",
704            "table-type": "MANAGED",
705            "columns": {
706                "type": "struct",
707                "fields": [
708                    { "name": "id", "type": "long", "nullable": false, "metadata": {} },
709                    {
710                        "name": "amount",
711                        "type": { "type": "decimal", "precision": 10, "scale": 2 },
712                        "nullable": true,
713                        "metadata": {}
714                    }
715                ]
716            },
717            "partition-columns": ["id"],
718            "protocol": {
719                "min-reader-version": 3,
720                "min-writer-version": 7,
721                "reader-features": ["deletionVectors"],
722                "writer-features": ["deletionVectors", "invariants"]
723            },
724            "properties": { "delta.enableDeletionVectors": "true" },
725            "last-commit-timestamp-ms": 1704067400000_i64
726        }));
727    }
728
729    #[test]
730    fn struct_type_carries_type_tag() {
731        // A bare `columns` value round-trips with its `type: "struct"` tag.
732        round_trip::<DeltaStructType>(json!({
733            "type": "struct",
734            "fields": [
735                { "name": "id", "type": "long", "nullable": false, "metadata": {} },
736                { "name": "name", "type": "string", "nullable": true, "metadata": {} }
737            ]
738        }));
739    }
740
741    #[test]
742    fn nested_array_and_map_types() {
743        round_trip::<DeltaStructField>(json!({
744            "name": "tags",
745            "type": {
746                "type": "array",
747                "element-type": "string",
748                "contains-null": true
749            },
750            "nullable": true,
751            "metadata": {}
752        }));
753        round_trip::<DeltaStructField>(json!({
754            "name": "props",
755            "type": {
756                "type": "map",
757                "key-type": "string",
758                "value-type": "long",
759                "value-contains-null": false
760            },
761            "nullable": true,
762            "metadata": {}
763        }));
764    }
765
766    #[test]
767    fn load_table_response_with_commits() {
768        round_trip::<DeltaLoadTableResponse>(json!({
769            "metadata": {
770                "etag": "etag-1",
771                "table-type": "MANAGED",
772                "table-uuid": "123e4567-e89b-12d3-a456-426614174000",
773                "location": "s3://bucket/warehouse/catalog/schema/table",
774                "created-time": 1705600000000_i64,
775                "updated-time": 1705600000000_i64,
776                "columns": { "type": "struct", "fields": [] },
777                "properties": { "delta.checkpointPolicy": "v2" },
778                "last-commit-version": 0,
779                "last-commit-timestamp-ms": 1704067400000_i64
780            },
781            "commits": [{
782                "version": 1,
783                "timestamp": 1704067200000_i64,
784                "file-name": "00000000-0000-0000-0000-00000000002a.json",
785                "file-size": 2048,
786                "file-modification-timestamp": 1704067200000_i64
787            }],
788            "latest-table-version": 1
789        }));
790    }
791
792    #[test]
793    fn update_table_request_add_commit() {
794        round_trip::<DeltaUpdateTableRequest>(json!({
795            "requirements": [
796                { "type": "assert-table-uuid", "uuid": "123e4567-e89b-12d3-a456-426614174000" },
797                { "type": "assert-etag", "etag": "etag-1" }
798            ],
799            "updates": [
800                {
801                    "action": "add-commit",
802                    "commit": {
803                        "version": 1,
804                        "timestamp": 1704067200000_i64,
805                        "file-name": "v.uuid1.json",
806                        "file-size": 2048,
807                        "file-modification-timestamp": 1704067200000_i64
808                    }
809                },
810                { "action": "set-latest-backfilled-version", "latest-published-version": 0 }
811            ]
812        }));
813    }
814
815    #[test]
816    fn update_table_request_metadata_actions() {
817        round_trip::<DeltaUpdateTableRequest>(json!({
818            "requirements": [],
819            "updates": [
820                { "action": "set-properties", "updates": { "k": "v" } },
821                { "action": "remove-properties", "removals": ["old"] },
822                { "action": "set-table-comment", "comment": "hello" },
823                {
824                    "action": "set-protocol",
825                    "protocol": { "min-reader-version": 3, "min-writer-version": 7 }
826                },
827                { "action": "set-partition-columns", "partition-columns": ["id"] },
828                {
829                    "action": "update-metadata-snapshot-version",
830                    "last-commit-version": 5,
831                    "last-commit-timestamp-ms": 1704067400000_i64
832                }
833            ]
834        }));
835    }
836
837    #[test]
838    fn update_table_domain_metadata_actions() {
839        round_trip::<DeltaUpdateTableRequest>(json!({
840            "requirements": [],
841            "updates": [
842                {
843                    "action": "set-domain-metadata",
844                    "updates": {
845                        "delta.clustering": { "clusteringColumns": [["id"], ["address", "city"]] },
846                        "delta.rowTracking": { "rowIdHighWaterMark": 42 }
847                    }
848                },
849                { "action": "remove-domain-metadata", "domains": ["delta.clustering"] }
850            ]
851        }));
852    }
853
854    #[test]
855    fn credentials_response() {
856        round_trip::<DeltaCredentialsResponse>(json!({
857            "storage-credentials": [{
858                "prefix": "s3://bucket/path/",
859                "operation": "READ",
860                "config": { "s3.access-key-id": "AK", "s3.secret-access-key": "SK" },
861                "expiration-time-ms": 1234567890000_i64
862            }]
863        }));
864    }
865
866    #[test]
867    fn catalog_config() {
868        round_trip::<DeltaCatalogConfig>(json!({
869            "endpoints": ["GET /v1/temporary-path-credentials"],
870            "protocol-version": "1.0"
871        }));
872    }
873
874    #[test]
875    fn report_metrics_request() {
876        round_trip::<DeltaReportMetricsRequest>(json!({
877            "table-id": "123e4567-e89b-12d3-a456-426614174000",
878            "report": {
879                "commit-report": {
880                    "num-files-added": 10,
881                    "num-bytes-added": 104857600_i64,
882                    "file-size-histogram": {
883                        "sorted-bin-boundaries": [0, 1024, 2048],
884                        "file-counts": [100, 40, 5],
885                        "total-bytes": [104857600_i64, 167772160_i64, 83886080_i64],
886                        "commit-version": 6
887                    }
888                }
889            }
890        }));
891    }
892
893    #[test]
894    fn error_response_round_trips() {
895        round_trip::<DeltaErrorResponse>(json!({
896            "error": {
897                "message": "table not found",
898                "type": "NoSuchTableException",
899                "code": 404
900            }
901        }));
902    }
903
904    #[test]
905    fn error_response_with_stack_round_trips() {
906        round_trip::<DeltaErrorResponse>(json!({
907            "error": {
908                "message": "boom",
909                "type": "InternalServerErrorException",
910                "code": 500,
911                "stack": ["frame one", "frame two"]
912            }
913        }));
914    }
915
916    #[test]
917    fn uniform_metadata_round_trips() {
918        round_trip::<DeltaUniformMetadata>(json!({
919            "iceberg": {
920                "metadata-location": "s3://bucket/metadata/v1.json",
921                "converted-delta-version": 42,
922                "converted-delta-timestamp": 1704067200000_i64
923            }
924        }));
925    }
926}