Embedding in Rust
Embedding in Rust
Section titled “Embedding in Rust”pgvis is not just a standalone server — it’s a library you can embed in any Rust application. The pgvis-lib crate provides a Builder that assembles the full stack in one call.
Add the Dependency
Section titled “Add the Dependency”[dependencies]pgvis-lib = "0.1"tokio = { version = "1", features = ["full"] }The pgvis-lib crate includes all backends and MCP by default. Feature flags:
| Feature | Default | Description |
|---|---|---|
postgres | ✓ | PostgreSQL backend |
sqlite | ✓ | SQLite backend |
mcp | ✓ | MCP server support |
Quick Start: REST Router
Section titled “Quick Start: REST Router”use pgvis_lib::Builder;
#[tokio::main]async fn main() -> anyhow::Result<()> { let router = Builder::new("postgres://localhost/mydb") .schemas(vec!["public"]) .build() .await?;
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; axum::serve(listener, router).await?; Ok(())}REST + MCP (Streamable HTTP)
Section titled “REST + MCP (Streamable HTTP)”use pgvis_lib::Builder;
#[tokio::main]async fn main() -> anyhow::Result<()> { let router = Builder::new("postgres://localhost/mydb") .schemas(vec!["public", "analytics"]) .with_mcp_http() .build() .await?;
// Serves REST at /api/{schema}/{table} AND MCP at /mcp let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; axum::serve(listener, router).await?; Ok(())}MCP-only Server (stdio)
Section titled “MCP-only Server (stdio)”For Claude Desktop or other agent integrations:
use pgvis_lib::Builder;
#[tokio::main]async fn main() -> anyhow::Result<()> { let mcp = Builder::new("postgres://localhost/mydb") .schemas(vec!["public"]) .build_mcp_server() .await?;
pgvis_lib::pgvis_mcp::serve_stdio(mcp).await?; Ok(())}SQLite Backend
Section titled “SQLite Backend”The backend is auto-detected from the DSN:
use pgvis_lib::Builder;
// File-based SQLitelet router = Builder::new("sqlite:./mydata.db") .build() .await?;
// In-memory SQLite (useful for testing)let router = Builder::new("sqlite::memory:") .build() .await?;SQLite uses the main schema by default. The .schemas() call is ignored for SQLite.
Custom Configuration
Section titled “Custom Configuration”use pgvis_lib::Builder;use pgvis_core::config::{Config, RoutingConfig, JwtAlgorithm};
let config = Config { schemas: vec!["public".into(), "api".into()], max_rows: Some(1000), statement_timeout_ms: Some(10_000), jwt_secret: Some("my-secret-key".into()), jwt_algo: JwtAlgorithm::HS256, anon_role: Some("web_anon".into()), aggregates_enabled: true, read_only: false, routing: RoutingConfig { prefix: "v1".into(), schema_in_path: true, ..Default::default() }, ..Default::default()};
let router = Builder::new("postgres://localhost/mydb") .config(config) .build() .await?;Accessing Internal Components
Section titled “Accessing Internal Components”Use build_components() for access to the schema cache, backend, and other internals:
use pgvis_lib::Builder;
let components = Builder::new("postgres://localhost/mydb") .schemas(vec!["public"]) .build_components() .await?;
// Access the schema cachelet cache = components.cache.load();println!("Tables: {}", cache.tables.len());for table in &cache.tables { println!(" {}.{} ({} columns)", table.id.schema, table.id.name, table.columns.len());}
// Generate OpenAPI speclet spec = pgvis_lib::pgvis_router::openapi::generate_spec(&cache, &components.config);let json = serde_json::to_string_pretty(&spec)?;println!("{json}");
// Serve the routerlet listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;axum::serve(listener, components.router).await?;Components struct
Section titled “Components struct”| Field | Type | Description |
|---|---|---|
backend | Arc<dyn Backend> | Database backend (execute queries) |
cache | Arc<ArcSwap<SchemaCache>> | Hot-swappable schema cache |
config | Arc<Config> | Shared configuration |
dialect | Arc<Dialect> | SQL dialect (Postgres/SQLite) |
router | axum::Router | The fully-wired REST + OpenAPI router |
Calling Functions (RPC) In-Process
Section titled “Calling Functions (RPC) In-Process”Beyond serving REST endpoints, pgvis lets you call database functions directly from Rust — no HTTP round-trip required. This is the programmatic equivalent of POST /rpc/{function}, but runs in-process through the same pipeline (plan → render → execute) with full Row-Level Security support.
Setup: Shared AppState
Section titled “Setup: Shared AppState”To use in-process RPC alongside HTTP serving, construct an AppState once and share it between the router and your application code:
use pgvis_lib::Builder;use pgvis_lib::pgvis_router::{AppState, CallerIdentity, build_router};
#[tokio::main]async fn main() -> anyhow::Result<()> { let components = Builder::new("postgres://localhost/mydb") .schemas(vec!["public"]) .build_components() .await?;
// Build shared state — used by both the router and in-process calls let state = AppState::new( components.cache, components.config, components.dialect, components.backend, );
// Wire the HTTP router from the shared state let router = build_router(state.clone());
// Spawn the HTTP server let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; tokio::spawn(async move { axum::serve(listener, router).await.ok(); });
// Call a function in-process (same pipeline, no HTTP) let result = state .call_rpc("public", "add", serde_json::json!({"a": 3, "b": 5}), &CallerIdentity::anonymous()) .await?; println!("3 + 5 = {}", result.body); // 3 + 5 = [{"result":8}]
Ok(())}
build_router(state)vsbuild_app(...)— Usebuild_routerwhen you need to keep a reference to theAppStatefor in-process RPC.build_appis a convenience that constructs the state internally and returns only the router.
call_rpc — Raw Result
Section titled “call_rpc — Raw Result”let result = state.call_rpc( "public", // schema "search_items", // function name serde_json::json!({"query": "widget"}), // named arguments &CallerIdentity::anonymous(),).await?;
// result.body is a serde_json::Value (array for set-returning, object for scalar)let items: Vec<serde_json::Value> = serde_json::from_value(result.body)?;println!("Found {} items", items.len());call_rpc_as<T> — Typed Convenience
Section titled “call_rpc_as<T> — Typed Convenience”Deserializes the result body directly into your struct:
#[derive(serde::Deserialize)]struct Item { id: i64, name: String, price: f64,}
// Set-returning function → Vec<Item>let items: Vec<Item> = state .call_rpc_as("public", "get_items", serde_json::json!({}), &CallerIdentity::anonymous()) .await?;
// Scalar function → Vec with one element containing "result" key#[derive(serde::Deserialize)]struct ScalarResult { result: i64 }
let rows: Vec<ScalarResult> = state .call_rpc_as("public", "add", serde_json::json!({"a": 10, "b": 20}), &CallerIdentity::anonymous()) .await?;assert_eq!(rows[0].result, 30);CallerIdentity — Row-Level Security
Section titled “CallerIdentity — Row-Level Security”CallerIdentity controls the database role and JWT claims applied inside the transaction, exactly as if the call came from an authenticated HTTP request:
use pgvis_lib::pgvis_router::CallerIdentity;
// Anonymous — no role switch, no claimslet anon = CallerIdentity::anonymous();
// Role-only — SET LOCAL role = 'authenticated'let user = CallerIdentity::with_role("authenticated");
// Full identity — role + JWT claims propagated as GUClet admin = CallerIdentity { role: Some("admin".into()), claims: Some(serde_json::json!({ "sub": "user-123", "role": "admin", "org_id": "org-456", })),};
// RLS policies using current_setting('request.jwt.claims') will see these claimslet result = state .call_rpc("public", "get_my_data", serde_json::json!({}), &admin) .await?;API Reference
Section titled “API Reference”| Method | Signature | Returns |
|---|---|---|
call_rpc | (&self, schema, function, args, caller) → Result<QueryResult, Error> | Raw result with body, total_count, page_total |
call_rpc_as<T> | (&self, schema, function, args, caller) → Result<T, Error> | Deserialized T from the JSON body |
CallerIdentity | Description |
|---|---|
::anonymous() | No role switch, no claims |
::with_role("name") | SET LOCAL role, no claims |
{ role, claims } | Full identity with GUC propagation |
Error Handling
Section titled “Error Handling”call_rpc returns pgvis_core::error::Error:
use pgvis_core::error::Error;
match state.call_rpc("public", "nonexistent_fn", serde_json::json!({}), &CallerIdentity::anonymous()).await { Ok(result) => { /* use result.body */ }, Err(Error::NotFound { .. }) => eprintln!("Function not found"), Err(Error::DatabaseError(msg)) => eprintln!("DB error: {msg}"), Err(e) => eprintln!("Other error: {e}"),}Composing with Your Own Routes
Section titled “Composing with Your Own Routes”Since pgvis returns a standard axum::Router, you can compose it with your own routes:
use axum::{Router, routing::get};use pgvis_lib::Builder;
#[tokio::main]async fn main() -> anyhow::Result<()> { let pgvis_router = Builder::new("postgres://localhost/mydb") .build() .await?;
let app = Router::new() .route("/health", get(|| async { "ok" })) .route("/custom", get(|| async { "my custom endpoint" })) .merge(pgvis_router);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; axum::serve(listener, app).await?; Ok(())}Error Handling
Section titled “Error Handling”Builder::build() and build_components() return Result<_, pgvis_core::error::Error>. Common failure cases:
- Database connection failed (wrong DSN, server unreachable)
- Introspection failed (insufficient permissions)
- No tables found in the specified schemas
match Builder::new("postgres://localhost/mydb").build().await { Ok(router) => { /* serve */ }, Err(e) => { eprintln!("pgvis initialization failed: {e}"); std::process::exit(1); }}Architecture
Section titled “Architecture”The embed API (pgvis-lib) is the same code path the CLI uses. There is no “server-only” logic — the Builder assembles:
- A database backend (connection pool + introspection)
- A schema cache (tables, columns, relationships, routines, constraints)
- A dialect (Postgres or SQLite capability flags)
- Configuration
- An axum
Router(REST + OpenAPI) or MCPServer
All wired together with the same I/O-free core engine. The pipeline is:
ApiRequest → plan_request() → ActionPlan → render() → SQL → execute() → QueryResultThis pipeline is shared between REST dispatch and MCP tool execution — behaviour is identical regardless of surface.
Pub/Sub (Real-time Messaging)
Section titled “Pub/Sub (Real-time Messaging)”When pub/sub is enabled in your config, the Components struct exposes a pubsub field — an Arc<PubSubHub> you can use directly:
use pgvis_lib::Builder;use pgvis_core::pubsub::PubSubConfig;use pgvis_core::config::Config;
let config = Config { pubsub: PubSubConfig { enabled: true, ..Default::default() }, ..Default::default()};
let components = Builder::new("postgres://localhost/mydb") .config(config) .build_components() .await?;
if let Some(hub) = &components.pubsub { // Publish hub.publish("events", r#"{"type":"user_signup"}"#).await?;
// Subscribe let mut rx = hub.subscribe("events").await?; tokio::spawn(async move { while let Ok(msg) = rx.recv().await { println!("{}: {}", msg.channel, msg.payload); } });}The REST router already includes /pubsub/* endpoints automatically when enabled. See the full Pub/Sub guide for configuration, patterns, and limitations.