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