use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get};
use axum::{Json, Router};
use iceberg::{Catalog, NamespaceIdent, TableIdent};
use serde::Serialize;
use tracing::{debug, info};
#[derive(Clone)]
pub struct CatalogFacade {
catalog: Arc<dyn Catalog>,
}
impl std::fmt::Debug for CatalogFacade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CatalogFacade").finish_non_exhaustive()
}
}
impl CatalogFacade {
pub fn new(catalog: Arc<dyn Catalog>) -> Self {
Self { catalog }
}
pub fn router(self) -> Router {
Router::new()
.route("/v1/config", get(config).fallback(read_only))
.route("/v1/namespaces", get(list_namespaces).fallback(read_only))
.route(
"/v1/namespaces/{namespace}",
get(load_namespace).fallback(read_only),
)
.route(
"/v1/namespaces/{namespace}/tables",
get(list_tables).fallback(read_only),
)
.route(
"/v1/namespaces/{namespace}/tables/{table}",
get(load_table).fallback(read_only),
)
.fallback(any(not_found))
.with_state(self)
}
}
const NAMESPACE_SEPARATOR: char = '\u{1F}';
fn namespace_of(raw: &str) -> NamespaceIdent {
let parts: Vec<String> = raw
.split(NAMESPACE_SEPARATOR)
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect();
NamespaceIdent::from_vec(parts.clone()).unwrap_or_else(|_| NamespaceIdent::new(raw.to_string()))
}
async fn config() -> Json<ConfigResponse> {
Json(ConfigResponse {
defaults: HashMap::new(),
overrides: HashMap::new(),
})
}
#[derive(Serialize)]
struct ConfigResponse {
defaults: HashMap<String, String>,
overrides: HashMap<String, String>,
}
async fn list_namespaces(State(facade): State<CatalogFacade>) -> Result<Response, ApiError> {
let namespaces = facade
.catalog
.list_namespaces(None)
.await
.map_err(ApiError::from)?;
Ok(Json(NamespacesResponse {
namespaces: namespaces.iter().map(|n| n.as_ref().to_vec()).collect(),
})
.into_response())
}
#[derive(Serialize)]
struct NamespacesResponse {
namespaces: Vec<Vec<String>>,
}
async fn load_namespace(
State(facade): State<CatalogFacade>,
Path(namespace): Path<String>,
) -> Result<Response, ApiError> {
let ident = namespace_of(&namespace);
let found = facade
.catalog
.get_namespace(&ident)
.await
.map_err(ApiError::from)?;
Ok(Json(NamespaceResponse {
namespace: ident.as_ref().to_vec(),
properties: found.properties().clone(),
})
.into_response())
}
#[derive(Serialize)]
struct NamespaceResponse {
namespace: Vec<String>,
properties: HashMap<String, String>,
}
async fn list_tables(
State(facade): State<CatalogFacade>,
Path(namespace): Path<String>,
) -> Result<Response, ApiError> {
let ident = namespace_of(&namespace);
let tables = facade
.catalog
.list_tables(&ident)
.await
.map_err(ApiError::from)?;
Ok(Json(TablesResponse {
identifiers: tables
.into_iter()
.map(|t| TableIdentifier {
namespace: t.namespace().as_ref().to_vec(),
name: t.name().to_string(),
})
.collect(),
})
.into_response())
}
#[derive(Serialize)]
struct TablesResponse {
identifiers: Vec<TableIdentifier>,
}
#[derive(Serialize)]
struct TableIdentifier {
namespace: Vec<String>,
name: String,
}
async fn load_table(
State(facade): State<CatalogFacade>,
Path((namespace, table)): Path<(String, String)>,
) -> Result<Response, ApiError> {
let ident = TableIdent::new(namespace_of(&namespace), table.clone());
let loaded = facade
.catalog
.load_table(&ident)
.await
.map_err(ApiError::from)?;
debug!(%table, "served table metadata");
Ok(Json(LoadTableResponse {
metadata_location: loaded.metadata_location().map(str::to_string),
metadata: loaded.metadata().clone(),
config: HashMap::new(),
})
.into_response())
}
#[derive(Serialize)]
struct LoadTableResponse {
#[serde(rename = "metadata-location", skip_serializing_if = "Option::is_none")]
metadata_location: Option<String>,
metadata: iceberg::spec::TableMetadata,
config: HashMap<String, String>,
}
async fn read_only() -> ApiError {
ApiError {
status: StatusCode::METHOD_NOT_ALLOWED,
kind: "MethodNotAllowedException",
message: "this catalog is read-only: an external writer would place rows in the cold \
tier without MeterStore knowing, breaking the invariant that PostgreSQL holds \
exactly the rows at or above the tiering watermark. Write through MeterStore."
.to_string(),
}
}
async fn not_found() -> ApiError {
ApiError {
status: StatusCode::NOT_FOUND,
kind: "NotFoundException",
message: "this endpoint serves the read-only subset of the Iceberg REST catalog spec: \
config, namespaces, and table metadata"
.to_string(),
}
}
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
kind: &'static str,
message: String,
}
impl From<iceberg::Error> for ApiError {
fn from(error: iceberg::Error) -> Self {
let status = match error.kind() {
iceberg::ErrorKind::TableNotFound | iceberg::ErrorKind::NamespaceNotFound => {
StatusCode::NOT_FOUND
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let kind = if status == StatusCode::NOT_FOUND {
"NoSuchTableException"
} else {
"InternalServerError"
};
Self {
status,
kind,
message: error.to_string(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
#[derive(Serialize)]
struct Body {
error: Inner,
}
#[derive(Serialize)]
struct Inner {
message: String,
r#type: String,
code: u16,
}
info!(status = %self.status, kind = self.kind, "catalog facade refused a request");
(
self.status,
Json(Body {
error: Inner {
message: self.message,
r#type: self.kind.to_string(),
code: self.status.as_u16(),
},
}),
)
.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_single_level_namespace_parses() {
assert_eq!(namespace_of("metering").as_ref(), &["metering".to_string()]);
}
#[test]
fn a_multi_level_namespace_splits_on_the_unit_separator() {
let ident = namespace_of("a\u{1F}b\u{1F}c");
assert_eq!(
ident.as_ref(),
&["a".to_string(), "b".to_string(), "c".to_string()]
);
}
#[test]
fn a_namespace_containing_a_dot_stays_one_level() {
assert_eq!(
namespace_of("edm.metering").as_ref(),
&["edm.metering".to_string()]
);
}
#[tokio::test]
async fn a_mutating_request_is_refused_with_the_reason() {
let error = read_only().await;
assert_eq!(error.status, StatusCode::METHOD_NOT_ALLOWED);
assert!(error.message.contains("watermark"), "{}", error.message);
}
#[tokio::test]
async fn the_config_handshake_is_empty_rather_than_absent() {
let Json(config) = config().await;
assert!(config.defaults.is_empty());
assert!(config.overrides.is_empty());
}
}