This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new ac28d65  feat(rest-server): add FileSystemCatalog-backed REST catalog 
server (#371)
ac28d65 is described below

commit ac28d65898f7a04fe97d2c1161d1acf350685973
Author: chaoyang <[email protected]>
AuthorDate: Tue Jun 23 15:32:14 2026 +0800

    feat(rest-server): add FileSystemCatalog-backed REST catalog server (#371)
---
 .github/workflows/ci.yml               |  10 +
 Cargo.toml                             |   2 +-
 crates/paimon-rest-server/Cargo.toml   |  50 +++
 crates/paimon-rest-server/README.md    |  81 +++++
 crates/paimon-rest-server/src/lib.rs   | 566 +++++++++++++++++++++++++++++++++
 crates/paimon-rest-server/src/main.rs  |  63 ++++
 crates/paimon-rest-server/tests/e2e.rs | 431 +++++++++++++++++++++++++
 7 files changed, 1202 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 46c0a90..cffd549 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -90,6 +90,16 @@ jobs:
           RUST_LOG: DEBUG
           RUST_BACKTRACE: full
 
+      # The e2e tests round-trip through a real FileSystemCatalog, which has
+      # known Windows path-compatibility issues (the `paimon` filesystem
+      # catalog tests are likewise gated with `#[cfg(not(windows))]`).
+      - name: Test paimon-rest-server
+        if: runner.os != 'Windows'
+        run: cargo test -p paimon-rest-server --all-targets
+        env:
+          RUST_LOG: DEBUG
+          RUST_BACKTRACE: full
+
   integration:
     runs-on: ubuntu-latest
     steps:
diff --git a/Cargo.toml b/Cargo.toml
index a669a2a..c72bf55 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,7 +17,7 @@
 
 [workspace]
 resolver = "2"
-members = ["crates/paimon", "crates/integration_tests", "bindings/c", 
"bindings/python", "crates/integrations/datafusion"]
+members = ["crates/paimon", "crates/paimon-rest-server", 
"crates/integration_tests", "bindings/c", "bindings/python", 
"crates/integrations/datafusion"]
 
 [workspace.package]
 version = "0.3.0"
diff --git a/crates/paimon-rest-server/Cargo.toml 
b/crates/paimon-rest-server/Cargo.toml
new file mode 100644
index 0000000..238eacc
--- /dev/null
+++ b/crates/paimon-rest-server/Cargo.toml
@@ -0,0 +1,50 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[package]
+name = "paimon-rest-server"
+version.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+rust-version.workspace = true
+description = "A FileSystemCatalog-backed Paimon REST catalog server for local 
end-to-end testing."
+# Testing/dev tool: not published to crates.io.
+publish = false
+
+[lib]
+name = "paimon_rest_server"
+path = "src/lib.rs"
+
+[[bin]]
+name = "paimon-rest-server"
+path = "src/main.rs"
+
+[dependencies]
+paimon = { workspace = true }
+axum = { version = "0.7", features = ["macros", "tokio", "http1", "http2"] }
+tokio = { version = "1.39.2", features = ["rt-multi-thread", "macros", "net", 
"signal", "time"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1.0.120"
+async-trait = "0.1.81"
+
+[dev-dependencies]
+tempfile = "3"
+arrow-array = { workspace = true }
+arrow-schema = { workspace = true }
+futures = "0.3"
diff --git a/crates/paimon-rest-server/README.md 
b/crates/paimon-rest-server/README.md
new file mode 100644
index 0000000..2b8a1ea
--- /dev/null
+++ b/crates/paimon-rest-server/README.md
@@ -0,0 +1,81 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# paimon-rest-server
+
+A Paimon REST catalog server backed by a real [`FileSystemCatalog`], for
+**local end-to-end testing** of the REST catalog client. It is a dev/testing
+tool and is **not published** to crates.io.
+
+Unlike the in-memory mock used in `paimon`'s own unit tests, this server maps
+the Paimon REST protocol onto a real `FileSystemCatalog`, so the client-side
+`RESTCatalog` can be exercised against actual on-disk metadata:
+
+- config + database/table metadata CRUD;
+- append write + commit (the commit endpoint persists the posted snapshot via
+  `SnapshotManager`) + read back;
+- column-level `alter table`.
+
+Because both the server and the client point at the **same** local warehouse,
+the client writes data files directly while the server persists the snapshot
+metadata it receives on the commit endpoint. The wire format mirrors Java
+Paimon, so the same warehouse can be round-tripped with a Java reader/writer.
+
+## Run the standalone server
+
+```bash
+REST_WAREHOUSE=/tmp/paimon-warehouse REST_HOST=127.0.0.1 REST_PORT=8080 \
+  cargo run -p paimon-rest-server
+```
+
+Environment variables: `REST_WAREHOUSE` (default `/tmp/paimon-warehouse`),
+`REST_HOST` (default `127.0.0.1`), `REST_PORT` (default `8080`),
+`REST_PREFIX` (default empty).
+
+## Use as a test fixture
+
+```rust,no_run
+# async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+let server = paimon_rest_server::FsRestCatalogServer::start("/tmp/paimon-wh", 
"").await?;
+// Point a RESTCatalog client at `server.url()`.
+# Ok(()) }
+```
+
+See `tests/e2e.rs` for the full metadata / write-commit-read / alter-table
+round trips.
+
+## Endpoints
+
+Served under the configured prefix (`/v1/...` by default):
+
+| Method | Path | Operation |
+| --- | --- | --- |
+| GET | `/v1/config` | server config |
+| GET / POST | `/databases` | list / create database |
+| GET / POST / DELETE | `/databases/{db}` | get / alter (no-op) / drop 
database |
+| GET / POST | `/databases/{db}/tables` | list / create table |
+| GET / POST / DELETE | `/databases/{db}/tables/{table}` | get / alter / drop 
table |
+| POST | `/tables/rename` | rename table |
+| POST | `/databases/{db}/tables/{table}/commit` | commit a snapshot |
+| GET | `/databases/{db}/tables/{table}/partitions` | list partitions |
+
+The data-token endpoint returns `501`; it is never called when
+`data-token.enabled=false` (the default).
+
+[`FileSystemCatalog`]: https://docs.rs/paimon
diff --git a/crates/paimon-rest-server/src/lib.rs 
b/crates/paimon-rest-server/src/lib.rs
new file mode 100644
index 0000000..434f38d
--- /dev/null
+++ b/crates/paimon-rest-server/src/lib.rs
@@ -0,0 +1,566 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! A real Paimon REST catalog server backed by [`FileSystemCatalog`].
+//!
+//! Unlike the in-memory mock used by `paimon`'s own tests, this server maps 
the
+//! Paimon REST protocol onto a real [`FileSystemCatalog`], so the client-side
+//! [`RESTCatalog`](paimon::catalog::RESTCatalog) can be exercised end to end:
+//! metadata CRUD, plus append write + commit + read back (the commit endpoint
+//! persists the posted snapshot via [`SnapshotManager`]).
+//!
+//! Because both the server and the client point at the *same* local warehouse,
+//! the client writes data files directly while the server only persists the
+//! snapshot metadata it receives on the commit endpoint.
+//!
+//! # Example
+//! ```no_run
+//! # async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+//! let server = 
paimon_rest_server::FsRestCatalogServer::start("/tmp/paimon-wh", "").await?;
+//! println!("listening on {}", server.url());
+//! # Ok(()) }
+//! ```
+
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use std::sync::Arc;
+
+use axum::{
+    extract::{Extension, FromRequestParts, Json, MatchedPath, Query},
+    http::request::Parts,
+    http::StatusCode,
+    response::{IntoResponse, Response},
+    routing::{get, post},
+    serve, Router,
+};
+use serde::Deserialize;
+use serde_json::json;
+
+use paimon::api::{
+    AlterDatabaseRequest, AlterTableRequest, AuditRESTResponse, 
ConfigResponse, CreateTableRequest,
+    ErrorResponse, GetDatabaseResponse, GetTableResponse, 
ListDatabasesResponse,
+    ListPartitionsResponse, ListTablesResponse, RESTUtil, RenameTableRequest, 
ResourcePaths,
+};
+use paimon::catalog::{list_partitions_from_file_system, Catalog, Identifier};
+use paimon::common::{CatalogOptions, Options};
+use paimon::spec::{Schema, Snapshot};
+use paimon::table::SnapshotManager;
+use paimon::{Error, FileSystemCatalog};
+
+/// Convenience boxed error type for server construction (covers both
+/// [`paimon::Error`] from catalog setup and [`std::io::Error`] from binding).
+pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
+
+/// Shared server state handed to every request handler.
+struct AppState {
+    catalog: FileSystemCatalog,
+    config: ConfigResponse,
+}
+
+/// A running FileSystemCatalog-backed REST catalog server.
+///
+/// The server runs on a background Tokio task and is aborted on drop, so a
+/// test can simply let it go out of scope when finished.
+pub struct FsRestCatalogServer {
+    addr: SocketAddr,
+    handle: tokio::task::JoinHandle<()>,
+}
+
+impl FsRestCatalogServer {
+    /// Start a server on an OS-assigned port (`127.0.0.1:0`).
+    ///
+    /// `warehouse` is the local warehouse root; `prefix` is the REST resource
+    /// prefix (pass `""` for none, mirroring `/v1/...`).
+    pub async fn start(warehouse: impl Into<String>, prefix: &str) -> 
Result<Self, BoxError> {
+        Self::start_on(warehouse, prefix, "127.0.0.1", 0).await
+    }
+
+    /// Start a server bound to an explicit host and port.
+    pub async fn start_on(
+        warehouse: impl Into<String>,
+        prefix: &str,
+        host: &str,
+        port: u16,
+    ) -> Result<Self, BoxError> {
+        let warehouse = warehouse.into();
+
+        let mut options = Options::new();
+        options.set(CatalogOptions::WAREHOUSE, warehouse.clone());
+        let catalog = FileSystemCatalog::new(options)?;
+
+        // Advertise the prefix (and warehouse) to clients via GET /v1/config.
+        let mut defaults = HashMap::new();
+        if !prefix.is_empty() {
+            defaults.insert(CatalogOptions::PREFIX.to_string(), 
prefix.to_string());
+        }
+        defaults.insert(CatalogOptions::WAREHOUSE.to_string(), warehouse);
+        let config = ConfigResponse::new(defaults);
+
+        let state = Arc::new(AppState { catalog, config });
+        let app = build_router(prefix, state);
+
+        let listener = tokio::net::TcpListener::bind((host, port)).await?;
+        let addr = listener.local_addr()?;
+        let handle = tokio::spawn(async move {
+            if let Err(e) = serve(listener, app.into_make_service()).await {
+                eprintln!("paimon-rest-server error: {e}");
+            }
+        });
+
+        Ok(Self { addr, handle })
+    }
+
+    /// The bound socket address.
+    pub fn addr(&self) -> SocketAddr {
+        self.addr
+    }
+
+    /// The base URL clients should connect to (e.g. `http://127.0.0.1:54321`).
+    pub fn url(&self) -> String {
+        format!("http://{}";, self.addr)
+    }
+}
+
+impl Drop for FsRestCatalogServer {
+    fn drop(&mut self) {
+        self.handle.abort();
+    }
+}
+
+/// Build the axum router, wiring every endpoint under the given prefix.
+fn build_router(prefix: &str, state: Arc<AppState>) -> Router {
+    let paths = ResourcePaths::new(prefix);
+    let base = paths.base_path();
+
+    Router::new()
+        // Config endpoint is always at the fixed /v1/config path.
+        .route("/v1/config", get(get_config))
+        .route(
+            &format!("{base}/databases"),
+            get(list_databases).post(create_database),
+        )
+        .route(
+            &format!("{base}/databases/:db"),
+            get(get_database).post(alter_database).delete(drop_database),
+        )
+        .route(
+            &format!("{base}/databases/:db/tables"),
+            get(list_tables).post(create_table),
+        )
+        .route(
+            &format!("{base}/databases/:db/tables/:table"),
+            get(get_table).post(alter_table).delete(drop_table),
+        )
+        .route(&format!("{base}/tables/rename"), post(rename_table))
+        .route(
+            &format!("{base}/databases/:db/tables/:table/commit"),
+            post(commit),
+        )
+        .route(
+            &format!("{base}/databases/:db/tables/:table/partitions"),
+            get(list_partitions),
+        )
+        // Token endpoint is never hit when `data-token.enabled=false` 
(default),
+        // but we serve a stub so a misconfigured client gets a clear 501.
+        .route(
+            &format!("{base}/databases/:db/tables/:table/token"),
+            get(table_token_stub),
+        )
+        .layer(Extension(state))
+}
+
+// ============================================================================
+// Error mapping: paimon::Error -> (HTTP status, ErrorResponse)
+//
+// The client reconstructs the original error solely from the HTTP status code
+// (see `crates/paimon/src/api/rest_error.rs`), so the code below MUST line the
+// status codes up with `RestError::from_error_response`.
+// ============================================================================
+
+fn error_response(e: Error) -> Response {
+    let (status, resource_type, resource_name) = match &e {
+        Error::DatabaseNotExist { database } => (
+            StatusCode::NOT_FOUND,
+            Some("database".to_string()),
+            Some(database.clone()),
+        ),
+        Error::TableNotExist { full_name } => (
+            StatusCode::NOT_FOUND,
+            Some("table".to_string()),
+            Some(full_name.clone()),
+        ),
+        Error::DatabaseAlreadyExist { database } => (
+            StatusCode::CONFLICT,
+            Some("database".to_string()),
+            Some(database.clone()),
+        ),
+        Error::TableAlreadyExist { full_name } => (
+            StatusCode::CONFLICT,
+            Some("table".to_string()),
+            Some(full_name.clone()),
+        ),
+        Error::DatabaseNotEmpty { database } => (
+            StatusCode::CONFLICT,
+            Some("database".to_string()),
+            Some(database.clone()),
+        ),
+        Error::ColumnNotExist { full_name, column } => (
+            StatusCode::BAD_REQUEST,
+            Some(format!("column:{full_name}")),
+            Some(column.clone()),
+        ),
+        Error::ColumnAlreadyExist { full_name, column } => (
+            StatusCode::BAD_REQUEST,
+            Some(format!("column:{full_name}")),
+            Some(column.clone()),
+        ),
+        Error::IdentifierInvalid { .. } | Error::ConfigInvalid { .. } => {
+            (StatusCode::BAD_REQUEST, None, None)
+        }
+        Error::Unsupported { .. } => (StatusCode::NOT_IMPLEMENTED, None, None),
+        _ => (StatusCode::INTERNAL_SERVER_ERROR, None, None),
+    };
+
+    let body = ErrorResponse::new(
+        resource_type,
+        resource_name,
+        Some(e.to_string()),
+        Some(status.as_u16() as i32),
+    );
+    (status, Json(body)).into_response()
+}
+
+/// A 2xx response with an empty JSON body, for operations whose REST contract
+/// returns nothing meaningful (the client deserializes these as `Value`).
+fn ok_empty() -> Response {
+    (StatusCode::OK, Json(json!({}))).into_response()
+}
+
+// ============================================================================
+// Path parameter decoding
+//
+// The client (`ResourcePaths`) builds path segments with 
`RESTUtil::encode_string`
+// (`application/x-www-form-urlencoded`), so e.g. a space becomes `+`. Axum's 
own
+// `Path`/`RawPathParams` extractors percent-decode `%xx` but leave `+` 
untouched,
+// which makes catalog names containing spaces unaddressable through 
`RESTCatalog`.
+//
+// We therefore decode the *raw* (still percent-encoded) URI segments with the
+// same `RESTUtil` codec, mirroring Java's `RESTCatalogServer`, which calls
+// `RESTUtil.decodeString` on the raw segments. Decoding the raw segment 
(rather
+// than post-processing Axum's already percent-decoded value) is the only way 
to
+// recover names correctly for all inputs — a literal `+` (encoded as `%2B`) 
and
+// a real space (encoded as `+`) are indistinguishable once `%xx` is decoded.
+// ============================================================================
+
+/// Path parameters captured from the matched route, decoded with the REST 
codec.
+struct RestPath(HashMap<String, String>);
+
+impl RestPath {
+    /// The decoded value of a captured parameter (empty string if absent).
+    fn get(&self, key: &str) -> String {
+        self.0.get(key).cloned().unwrap_or_default()
+    }
+}
+
+#[async_trait::async_trait]
+impl<S: Send + Sync> FromRequestParts<S> for RestPath {
+    type Rejection = std::convert::Infallible;
+
+    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, 
Self::Rejection> {
+        // The route pattern (e.g. `/v1/databases/:db/tables/:table`) is 
recorded
+        // by Axum in the request extensions once a route matches.
+        let pattern = parts
+            .extensions
+            .get::<MatchedPath>()
+            .map(|m| m.as_str().to_string());
+        // `parts.uri.path()` is the original, still-percent-encoded request 
path.
+        let raw_path = parts.uri.path().to_string();
+
+        let mut params = HashMap::new();
+        if let Some(pattern) = pattern {
+            for (pat_seg, raw_seg) in 
pattern.split('/').zip(raw_path.split('/')) {
+                if let Some(name) = pat_seg.strip_prefix(':') {
+                    params.insert(name.to_string(), 
RESTUtil::decode_string(raw_seg));
+                }
+            }
+        }
+        Ok(RestPath(params))
+    }
+}
+
+// ============================================================================
+// Handlers
+// ============================================================================
+
+async fn get_config(
+    Query(_params): Query<HashMap<String, String>>,
+    Extension(state): Extension<Arc<AppState>>,
+) -> Response {
+    (StatusCode::OK, Json(state.config.clone())).into_response()
+}
+
+async fn list_databases(Extension(state): Extension<Arc<AppState>>) -> 
Response {
+    match state.catalog.list_databases().await {
+        Ok(mut dbs) => {
+            dbs.sort();
+            (StatusCode::OK, Json(ListDatabasesResponse::new(dbs, 
None))).into_response()
+        }
+        Err(e) => error_response(e),
+    }
+}
+
+async fn create_database(
+    Extension(state): Extension<Arc<AppState>>,
+    Json(payload): Json<paimon::api::CreateDatabaseRequest>,
+) -> Response {
+    match state
+        .catalog
+        .create_database(&payload.name, false, payload.options)
+        .await
+    {
+        Ok(()) => ok_empty(),
+        Err(e) => error_response(e),
+    }
+}
+
+async fn get_database(path: RestPath, Extension(state): 
Extension<Arc<AppState>>) -> Response {
+    let db = path.get("db");
+    match state.catalog.get_database(&db).await {
+        Ok(database) => {
+            let response = GetDatabaseResponse::new(
+                Some(database.name.clone()),
+                Some(database.name),
+                None,
+                database.options,
+                empty_audit(),
+            );
+            (StatusCode::OK, Json(response)).into_response()
+        }
+        Err(e) => error_response(e),
+    }
+}
+
+/// Alter database: `FileSystemCatalog` does not persist database properties,
+/// and the `Catalog` trait has no `alter_database`, so no client path reaches
+/// this. We only validate that the database exists and return OK; the request
+/// is intentionally a no-op.
+async fn alter_database(
+    path: RestPath,
+    Extension(state): Extension<Arc<AppState>>,
+    Json(_request): Json<AlterDatabaseRequest>,
+) -> Response {
+    let db = path.get("db");
+    match state.catalog.get_database(&db).await {
+        Ok(_) => ok_empty(),
+        Err(e) => error_response(e),
+    }
+}
+
+async fn drop_database(path: RestPath, Extension(state): 
Extension<Arc<AppState>>) -> Response {
+    let db = path.get("db");
+    // The client (`RESTCatalog::drop_database`) already enforces the 
non-cascade
+    // "database must be empty" check before issuing the DELETE, so the server
+    // force-drops with cascade=true.
+    match state.catalog.drop_database(&db, false, true).await {
+        Ok(()) => ok_empty(),
+        Err(e) => error_response(e),
+    }
+}
+
+async fn list_tables(path: RestPath, Extension(state): 
Extension<Arc<AppState>>) -> Response {
+    let db = path.get("db");
+    match state.catalog.list_tables(&db).await {
+        Ok(mut tables) => {
+            tables.sort();
+            (
+                StatusCode::OK,
+                Json(ListTablesResponse::new(Some(tables), None)),
+            )
+                .into_response()
+        }
+        Err(e) => error_response(e),
+    }
+}
+
+async fn create_table(
+    path: RestPath,
+    Extension(state): Extension<Arc<AppState>>,
+    Json(request): Json<CreateTableRequest>,
+) -> Response {
+    // Trust the path's database; take the table name from the request body.
+    let identifier = Identifier::new(path.get("db"), 
request.identifier.object().to_string());
+    match state
+        .catalog
+        .create_table(&identifier, request.schema, false)
+        .await
+    {
+        Ok(()) => ok_empty(),
+        Err(e) => error_response(e),
+    }
+}
+
+async fn get_table(path: RestPath, Extension(state): Extension<Arc<AppState>>) 
-> Response {
+    let table = path.get("table");
+    let identifier = Identifier::new(path.get("db"), table.clone());
+    let resolved = match state.catalog.get_table(&identifier).await {
+        Ok(t) => t,
+        Err(e) => return error_response(e),
+    };
+
+    let table_schema = resolved.schema();
+    // Convert the stored `TableSchema` into the DDL `Schema` the response
+    // carries. `Schema` is a field subset of `TableSchema` (both camelCase),
+    // and serde ignores the extra keys, preserving field ids exactly.
+    let schema: Schema =
+        match 
serde_json::to_value(table_schema).and_then(serde_json::from_value::<Schema>) {
+            Ok(s) => s,
+            Err(e) => {
+                return error_response(Error::DataInvalid {
+                    message: format!("Failed to convert table schema: {e}"),
+                    source: Some(Box::new(e)),
+                })
+            }
+        };
+
+    let response = GetTableResponse::new(
+        // FileSystemCatalog has no UUID concept; the full name is a stable id
+        // that satisfies the client's RESTEnv requirement.
+        Some(identifier.full_name()),
+        Some(table),
+        Some(resolved.location().to_string()),
+        Some(false),
+        Some(table_schema.id()),
+        Some(schema),
+        empty_audit(),
+    );
+    (StatusCode::OK, Json(response)).into_response()
+}
+
+async fn drop_table(path: RestPath, Extension(state): 
Extension<Arc<AppState>>) -> Response {
+    let identifier = Identifier::new(path.get("db"), path.get("table"));
+    match state.catalog.drop_table(&identifier, false).await {
+        Ok(()) => ok_empty(),
+        Err(e) => error_response(e),
+    }
+}
+
+async fn alter_table(
+    path: RestPath,
+    Extension(state): Extension<Arc<AppState>>,
+    Json(request): Json<AlterTableRequest>,
+) -> Response {
+    let identifier = Identifier::new(path.get("db"), path.get("table"));
+    match state
+        .catalog
+        .alter_table(&identifier, request.changes, false)
+        .await
+    {
+        Ok(()) => ok_empty(),
+        Err(e) => error_response(e),
+    }
+}
+
+async fn rename_table(
+    Extension(state): Extension<Arc<AppState>>,
+    Json(request): Json<RenameTableRequest>,
+) -> Response {
+    match state
+        .catalog
+        .rename_table(&request.source, &request.destination, false)
+        .await
+    {
+        Ok(()) => ok_empty(),
+        Err(e) => error_response(e),
+    }
+}
+
+/// Request body posted by the client's `RESTSnapshotCommit` (see
+/// `crates/paimon/src/api/rest_api.rs::commit_snapshot`).
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct CommitRequest {
+    #[serde(default)]
+    table_uuid: Option<String>,
+    snapshot: Snapshot,
+    #[serde(default)]
+    statistics: serde_json::Value,
+}
+
+async fn commit(
+    path: RestPath,
+    Extension(state): Extension<Arc<AppState>>,
+    Json(request): Json<CommitRequest>,
+) -> Response {
+    let _ = (request.table_uuid, request.statistics);
+    let identifier = Identifier::new(path.get("db"), path.get("table"));
+
+    // Resolve the table's FileIO and on-disk location, then persist the posted
+    // snapshot exactly like the filesystem catalog's own commit path does.
+    let resolved = match state.catalog.get_table(&identifier).await {
+        Ok(t) => t,
+        Err(e) => return error_response(e),
+    };
+    let manager = SnapshotManager::new(resolved.file_io().clone(), 
resolved.location().to_string());
+    match manager.commit_snapshot(&request.snapshot).await {
+        Ok(success) => (StatusCode::OK, Json(json!({ "success": success 
}))).into_response(),
+        Err(e) => error_response(e),
+    }
+}
+
+/// List a table's partitions, computed from the latest snapshot on disk.
+///
+/// Mirrors `Catalog::list_partitions`: resolve the table, then derive 
partition
+/// aggregates from the filesystem via [`list_partitions_from_file_system`]. 
The
+/// pagination params (`maxResults`/`pageToken`) are accepted but ignored — the
+/// whole set is returned in one page (`nextPageToken = null`).
+async fn list_partitions(
+    path: RestPath,
+    Query(_params): Query<HashMap<String, String>>,
+    Extension(state): Extension<Arc<AppState>>,
+) -> Response {
+    let identifier = Identifier::new(path.get("db"), path.get("table"));
+    let resolved = match state.catalog.get_table(&identifier).await {
+        Ok(t) => t,
+        Err(e) => return error_response(e),
+    };
+    match list_partitions_from_file_system(&resolved).await {
+        Ok(partitions) => (
+            StatusCode::OK,
+            Json(ListPartitionsResponse::new(Some(partitions), None)),
+        )
+            .into_response(),
+        Err(e) => error_response(e),
+    }
+}
+
+async fn table_token_stub() -> Response {
+    let body = ErrorResponse::new(
+        None,
+        None,
+        Some(
+            "Data token is not supported by paimon-rest-server; \
+             set data-token.enabled=false (the default)."
+                .to_string(),
+        ),
+        Some(StatusCode::NOT_IMPLEMENTED.as_u16() as i32),
+    );
+    (StatusCode::NOT_IMPLEMENTED, Json(body)).into_response()
+}
+
+fn empty_audit() -> AuditRESTResponse {
+    AuditRESTResponse::new(None, None, None, None, None)
+}
diff --git a/crates/paimon-rest-server/src/main.rs 
b/crates/paimon-rest-server/src/main.rs
new file mode 100644
index 0000000..8a4cbfe
--- /dev/null
+++ b/crates/paimon-rest-server/src/main.rs
@@ -0,0 +1,63 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Standalone Paimon REST catalog server backed by a local filesystem 
warehouse.
+//!
+//! # Usage
+//! ```bash
+//! REST_WAREHOUSE=/tmp/paimon-warehouse REST_HOST=127.0.0.1 REST_PORT=8080 \
+//!   cargo run -p paimon-rest-server
+//! ```
+//!
+//! Then point a client (e.g. the `rest_local_smoke` example, or Java) at it:
+//! ```bash
+//! REST_URI=http://localhost:8080 REST_WAREHOUSE=/tmp/paimon-warehouse \
+//!   cargo run -p paimon --example rest_local_smoke
+//! ```
+
+use paimon_rest_server::{BoxError, FsRestCatalogServer};
+
+fn env_or(key: &str, default: &str) -> String {
+    std::env::var(key).unwrap_or_else(|_| default.to_string())
+}
+
+#[tokio::main]
+async fn main() -> Result<(), BoxError> {
+    let warehouse = env_or("REST_WAREHOUSE", "/tmp/paimon-warehouse");
+    let host = env_or("REST_HOST", "127.0.0.1");
+    let port: u16 = env_or("REST_PORT", "8080")
+        .parse()
+        .map_err(|e| format!("invalid REST_PORT: {e}"))?;
+    let prefix = env_or("REST_PREFIX", "");
+
+    // Ensure the warehouse directory exists so FileIO can list it.
+    std::fs::create_dir_all(&warehouse)?;
+
+    let server = FsRestCatalogServer::start_on(warehouse.clone(), &prefix, 
&host, port).await?;
+
+    println!("Paimon REST catalog server (filesystem-backed)");
+    println!("  warehouse : {warehouse}");
+    println!("  listening : {}", server.url());
+    if !prefix.is_empty() {
+        println!("  prefix    : {prefix}");
+    }
+    println!("Press Ctrl-C to stop.");
+
+    tokio::signal::ctrl_c().await?;
+    println!("\nShutting down.");
+    Ok(())
+}
diff --git a/crates/paimon-rest-server/tests/e2e.rs 
b/crates/paimon-rest-server/tests/e2e.rs
new file mode 100644
index 0000000..6edf274
--- /dev/null
+++ b/crates/paimon-rest-server/tests/e2e.rs
@@ -0,0 +1,431 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! End-to-end tests for the FileSystemCatalog-backed REST catalog server.
+//!
+//! Each test spins up a real [`FsRestCatalogServer`] over a temporary 
warehouse
+//! and drives it through the client-side [`RESTCatalog`], covering metadata 
CRUD
+//! and a full append write + commit + read-back round trip.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use arrow_array::{Int32Array, RecordBatch, StringArray};
+use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as 
ArrowSchema};
+use futures::TryStreamExt;
+use tempfile::TempDir;
+
+use paimon::catalog::{Catalog, Identifier, RESTCatalog};
+use paimon::common::{CatalogOptions, Options};
+use paimon::spec::{BigIntType, DataType, IntType, Schema, SchemaChange, 
VarCharType};
+
+use paimon_rest_server::FsRestCatalogServer;
+
+/// Holds the temp warehouse and the running server so they outlive the 
catalog.
+struct TestContext {
+    _warehouse: TempDir,
+    _server: FsRestCatalogServer,
+    catalog: RESTCatalog,
+}
+
+async fn setup() -> TestContext {
+    let warehouse = TempDir::new().expect("create temp warehouse");
+    let warehouse_path = warehouse.path().to_str().unwrap().to_string();
+
+    let server = FsRestCatalogServer::start(warehouse_path.clone(), "")
+        .await
+        .expect("start server");
+
+    let mut options = Options::new();
+    options.set(CatalogOptions::METASTORE, "rest");
+    options.set(CatalogOptions::URI, server.url());
+    options.set(CatalogOptions::WAREHOUSE, &warehouse_path);
+    options.set(CatalogOptions::TOKEN_PROVIDER, "bear");
+    options.set(CatalogOptions::TOKEN, "dummy-token");
+
+    let catalog = RESTCatalog::new(options, true)
+        .await
+        .expect("create RESTCatalog");
+
+    TestContext {
+        _warehouse: warehouse,
+        _server: server,
+        catalog,
+    }
+}
+
+fn append_only_schema() -> Schema {
+    Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("name", DataType::VarChar(VarCharType::new(255).unwrap()))
+        .option("bucket", "1")
+        .option("bucket-key", "id")
+        .build()
+        .expect("build schema")
+}
+
+fn sample_batch() -> RecordBatch {
+    let arrow_schema = Arc::new(ArrowSchema::new(vec![
+        ArrowField::new("id", ArrowDataType::Int32, true),
+        ArrowField::new("name", ArrowDataType::Utf8, true),
+    ]));
+    RecordBatch::try_new(
+        arrow_schema,
+        vec![
+            Arc::new(Int32Array::from(vec![1, 2, 3])),
+            Arc::new(StringArray::from(vec!["alice", "bob", "carol"])),
+        ],
+    )
+    .expect("build batch")
+}
+
+fn partitioned_schema() -> Schema {
+    Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("region", DataType::VarChar(VarCharType::new(255).unwrap()))
+        .partition_keys(["region"])
+        .option("bucket", "1")
+        .option("bucket-key", "id")
+        .build()
+        .expect("build schema")
+}
+
+fn partitioned_batch() -> RecordBatch {
+    let arrow_schema = Arc::new(ArrowSchema::new(vec![
+        ArrowField::new("id", ArrowDataType::Int32, true),
+        ArrowField::new("region", ArrowDataType::Utf8, true),
+    ]));
+    RecordBatch::try_new(
+        arrow_schema,
+        vec![
+            Arc::new(Int32Array::from(vec![1, 2, 3])),
+            Arc::new(StringArray::from(vec!["us", "eu", "us"])),
+        ],
+    )
+    .expect("build batch")
+}
+
+// ==================== Database metadata ====================
+
+#[tokio::test]
+async fn test_database_crud() {
+    let ctx = setup().await;
+    let cat = &ctx.catalog;
+
+    // Initially empty.
+    assert!(cat.list_databases().await.unwrap().is_empty());
+
+    // Create + list + get.
+    cat.create_database("db1", false, HashMap::new())
+        .await
+        .unwrap();
+    let dbs = cat.list_databases().await.unwrap();
+    assert_eq!(dbs, vec!["db1".to_string()]);
+    let db = cat.get_database("db1").await.unwrap();
+    assert_eq!(db.name, "db1");
+
+    // Duplicate without ignore_if_exists -> error; with ignore -> ok.
+    assert!(cat
+        .create_database("db1", false, HashMap::new())
+        .await
+        .is_err());
+    cat.create_database("db1", true, HashMap::new())
+        .await
+        .unwrap();
+
+    // Get missing -> error.
+    assert!(cat.get_database("nope").await.is_err());
+
+    // Drop + verify; ignore_if_not_exists semantics.
+    cat.drop_database("db1", false, false).await.unwrap();
+    assert!(cat.list_databases().await.unwrap().is_empty());
+    assert!(cat.drop_database("db1", false, false).await.is_err());
+    cat.drop_database("db1", true, false).await.unwrap();
+}
+
+// ==================== Table metadata ====================
+
+#[tokio::test]
+async fn test_table_crud_and_rename() {
+    let ctx = setup().await;
+    let cat = &ctx.catalog;
+    cat.create_database("db", false, HashMap::new())
+        .await
+        .unwrap();
+
+    let users = Identifier::new("db", "users");
+    cat.create_table(&users, append_only_schema(), false)
+        .await
+        .unwrap();
+    assert_eq!(
+        cat.list_tables("db").await.unwrap(),
+        vec!["users".to_string()]
+    );
+
+    // Duplicate create errors unless ignored.
+    assert!(cat
+        .create_table(&users, append_only_schema(), false)
+        .await
+        .is_err());
+    cat.create_table(&users, append_only_schema(), true)
+        .await
+        .unwrap();
+
+    // get_table returns a usable table with the right schema/location.
+    let table = cat.get_table(&users).await.unwrap();
+    assert_eq!(table.schema().fields().len(), 2);
+    assert!(table.location().ends_with("db.db/users"));
+
+    // Rename round trip.
+    let renamed = Identifier::new("db", "users_renamed");
+    cat.rename_table(&users, &renamed, false).await.unwrap();
+    assert_eq!(
+        cat.list_tables("db").await.unwrap(),
+        vec!["users_renamed".to_string()]
+    );
+    assert!(cat.get_table(&users).await.is_err());
+    cat.rename_table(&renamed, &users, false).await.unwrap();
+
+    // Drop + missing semantics.
+    cat.drop_table(&users, false).await.unwrap();
+    assert!(cat.list_tables("db").await.unwrap().is_empty());
+    assert!(cat.drop_table(&users, false).await.is_err());
+    cat.drop_table(&users, true).await.unwrap();
+}
+
+#[tokio::test]
+async fn test_get_table_missing() {
+    let ctx = setup().await;
+    let cat = &ctx.catalog;
+    cat.create_database("db", false, HashMap::new())
+        .await
+        .unwrap();
+    assert!(cat
+        .get_table(&Identifier::new("db", "ghost"))
+        .await
+        .is_err());
+}
+
+// ==================== Full write + commit + read ====================
+
+#[tokio::test]
+async fn test_write_commit_read_roundtrip() {
+    let ctx = setup().await;
+    let cat = &ctx.catalog;
+
+    cat.create_database("smoke_db", false, HashMap::new())
+        .await
+        .unwrap();
+    let ident = Identifier::new("smoke_db", "users");
+    cat.create_table(&ident, append_only_schema(), false)
+        .await
+        .unwrap();
+
+    // Append write + commit through RESTSnapshotCommit (-> server commit 
endpoint).
+    let table = cat.get_table(&ident).await.unwrap();
+    let write_builder = table.new_write_builder();
+    let mut writer = write_builder.new_write().unwrap();
+    writer.write_arrow_batch(&sample_batch()).await.unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    assert!(!messages.is_empty(), "expected at least one commit message");
+    write_builder.new_commit().commit(messages).await.unwrap();
+
+    // Read back.
+    let table = cat.get_table(&ident).await.unwrap();
+    let read_builder = table.new_read_builder();
+    let plan = read_builder.new_scan().plan().await.unwrap();
+    let read = read_builder.new_read().unwrap();
+    let mut stream = read.to_arrow(plan.splits()).unwrap();
+
+    let mut total = 0usize;
+    while let Some(batch) = stream.try_next().await.unwrap() {
+        total += batch.num_rows();
+    }
+    assert_eq!(total, 3, "expected 3 rows read back, got {total}");
+}
+
+// ==================== alter table over REST ====================
+
+#[tokio::test]
+async fn test_alter_table_columns() {
+    let ctx = setup().await;
+    let cat = &ctx.catalog;
+
+    cat.create_database("db", false, HashMap::new())
+        .await
+        .unwrap();
+    let ident = Identifier::new("db", "events");
+    cat.create_table(&ident, append_only_schema(), false)
+        .await
+        .unwrap();
+
+    // Apply a batch of column changes through the REST alter_table path.
+    cat.alter_table(
+        &ident,
+        vec![
+            SchemaChange::add_column("age".to_string(), 
DataType::Int(IntType::new())),
+            SchemaChange::rename_column("name".to_string(), 
"full_name".to_string()),
+            SchemaChange::update_column_comment("id".to_string(), "the 
id".to_string()),
+            SchemaChange::update_column_type(
+                "age".to_string(),
+                DataType::BigInt(BigIntType::new()),
+            ),
+        ],
+        false,
+    )
+    .await
+    .unwrap();
+
+    // The server persisted a new schema version; get_table reflects it.
+    let table = cat.get_table(&ident).await.unwrap();
+    let schema = table.schema();
+    let names: Vec<&str> = schema.fields().iter().map(|f| f.name()).collect();
+    assert_eq!(names, vec!["id", "full_name", "age"]);
+
+    let id_field = schema.fields().iter().find(|f| f.name() == "id").unwrap();
+    assert_eq!(id_field.description(), Some("the id"));
+    let age_field = schema.fields().iter().find(|f| f.name() == 
"age").unwrap();
+    assert!(matches!(age_field.data_type(), DataType::BigInt(_)));
+
+    // alter on a missing table: ignored vs. error.
+    let missing = Identifier::new("db", "nope");
+    cat.alter_table(
+        &missing,
+        vec![SchemaChange::update_column_comment(
+            "id".to_string(),
+            "x".to_string(),
+        )],
+        true,
+    )
+    .await
+    .unwrap();
+    assert!(cat
+        .alter_table(
+            &missing,
+            vec![SchemaChange::update_column_comment(
+                "id".to_string(),
+                "x".to_string(),
+            )],
+            false,
+        )
+        .await
+        .is_err());
+}
+
+// ==================== list partitions over REST ====================
+
+#[tokio::test]
+async fn test_list_partitions() {
+    let ctx = setup().await;
+    let cat = &ctx.catalog;
+
+    cat.create_database("db", false, HashMap::new())
+        .await
+        .unwrap();
+    let ident = Identifier::new("db", "events");
+    cat.create_table(&ident, partitioned_schema(), false)
+        .await
+        .unwrap();
+
+    // Write rows spanning two partitions (region=us has 2 rows, region=eu 1),
+    // then commit through the server's commit endpoint.
+    let table = cat.get_table(&ident).await.unwrap();
+    let write_builder = table.new_write_builder();
+    let mut writer = write_builder.new_write().unwrap();
+    writer
+        .write_arrow_batch(&partitioned_batch())
+        .await
+        .unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    assert!(!messages.is_empty(), "expected at least one commit message");
+    write_builder.new_commit().commit(messages).await.unwrap();
+
+    // The partitions endpoint must serve the two partitions (no 404 fallback);
+    // the client receives them directly from the server.
+    let partitions = cat.list_partitions(&ident).await.unwrap();
+    assert_eq!(partitions.len(), 2, "expected two partitions");
+
+    let mut regions: Vec<String> = partitions
+        .iter()
+        .map(|p| p.spec.get("region").cloned().unwrap_or_default())
+        .collect();
+    regions.sort();
+    assert_eq!(regions, vec!["eu".to_string(), "us".to_string()]);
+
+    let total: i64 = partitions.iter().map(|p| p.record_count).sum();
+    assert_eq!(total, 3, "partition record counts should sum to all rows");
+
+    let us = partitions
+        .iter()
+        .find(|p| p.spec.get("region").map(String::as_str) == Some("us"))
+        .expect("us partition present");
+    assert_eq!(us.record_count, 2, "region=us holds two rows");
+}
+
+// ==================== Path codec round trip ====================
+
+/// Database/table names with characters the REST path codec encodes specially
+/// must survive the client encode -> server decode round trip.
+///
+/// The client builds path segments with `RESTUtil::encode_string`
+/// (`application/x-www-form-urlencoded`), so a space becomes `+` and a literal
+/// `+` becomes `%2B`. The server must decode them back to the exact original
+/// names; otherwise these databases/tables are unaddressable through
+/// `RESTCatalog`. A literal `+` additionally proves the server decodes the raw
+/// segment (not Axum's already percent-decoded value), since `+` and a space
+/// would otherwise be indistinguishable.
+#[tokio::test]
+async fn test_special_char_names() {
+    let ctx = setup().await;
+    let cat = &ctx.catalog;
+
+    // Two databases: one with a space, one with a literal `+`.
+    let space_db = "sales db";
+    let plus_db = "a+b";
+    cat.create_database(space_db, false, HashMap::new())
+        .await
+        .unwrap();
+    cat.create_database(plus_db, false, HashMap::new())
+        .await
+        .unwrap();
+
+    let mut dbs = cat.list_databases().await.unwrap();
+    dbs.sort();
+    assert_eq!(dbs, vec![plus_db.to_string(), space_db.to_string()]);
+
+    // get_database must address each one by its exact name.
+    assert_eq!(cat.get_database(space_db).await.unwrap().name, space_db);
+    assert_eq!(cat.get_database(plus_db).await.unwrap().name, plus_db);
+
+    // A table whose name also contains a space, under the space database.
+    let ident = Identifier::new(space_db, "my table");
+    cat.create_table(&ident, append_only_schema(), false)
+        .await
+        .unwrap();
+    assert_eq!(
+        cat.list_tables(space_db).await.unwrap(),
+        vec!["my table".to_string()]
+    );
+    let table = cat.get_table(&ident).await.unwrap();
+    assert_eq!(table.schema().fields().len(), 2);
+
+    // Drop the table and both databases by their exact names.
+    cat.drop_table(&ident, false).await.unwrap();
+    cat.drop_database(space_db, false, false).await.unwrap();
+    cat.drop_database(plus_db, false, false).await.unwrap();
+    assert!(cat.list_databases().await.unwrap().is_empty());
+}


Reply via email to