Skip to content

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.

[dependencies]
pgvis-lib = "0.1"
tokio = { version = "1", features = ["full"] }

The pgvis-lib crate includes all backends and MCP by default. Feature flags:

FeatureDefaultDescription
postgresPostgreSQL backend
sqliteSQLite backend
mcpMCP server support
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(())
}
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(())
}

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(())
}

The backend is auto-detected from the DSN:

use pgvis_lib::Builder;
// File-based SQLite
let 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.

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?;

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 cache
let 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 spec
let spec = pgvis_lib::pgvis_router::openapi::generate_spec(&cache, &components.config);
let json = serde_json::to_string_pretty(&spec)?;
println!("{json}");
// Serve the router
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, components.router).await?;
FieldTypeDescription
backendArc<dyn Backend>Database backend (execute queries)
cacheArc<ArcSwap<SchemaCache>>Hot-swappable schema cache
configArc<Config>Shared configuration
dialectArc<Dialect>SQL dialect (Postgres/SQLite)
routeraxum::RouterThe fully-wired REST + OpenAPI router

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.

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) vs build_app(...) — Use build_router when you need to keep a reference to the AppState for in-process RPC. build_app is a convenience that constructs the state internally and returns only the router.

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());

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 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 claims
let anon = CallerIdentity::anonymous();
// Role-only — SET LOCAL role = 'authenticated'
let user = CallerIdentity::with_role("authenticated");
// Full identity — role + JWT claims propagated as GUC
let 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 claims
let result = state
.call_rpc("public", "get_my_data", serde_json::json!({}), &admin)
.await?;
MethodSignatureReturns
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
CallerIdentityDescription
::anonymous()No role switch, no claims
::with_role("name")SET LOCAL role, no claims
{ role, claims }Full identity with GUC propagation

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}"),
}

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(())
}

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);
}
}

The embed API (pgvis-lib) is the same code path the CLI uses. There is no “server-only” logic — the Builder assembles:

  1. A database backend (connection pool + introspection)
  2. A schema cache (tables, columns, relationships, routines, constraints)
  3. A dialect (Postgres or SQLite capability flags)
  4. Configuration
  5. An axum Router (REST + OpenAPI) or MCP Server

All wired together with the same I/O-free core engine. The pipeline is:

ApiRequest → plan_request() → ActionPlan → render() → SQL → execute() → QueryResult

This pipeline is shared between REST dispatch and MCP tool execution — behaviour is identical regardless of surface.

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.