Rust client SDK
Consume the platform from Rust — headless OIDC auth, a retrying JSON client that resolves named services, and the five access gates.
The platform, consumed from Rust
The Rust client SDK is the crate egav-sdk (library name egav_sdk) — the native way to call the SynaptaGrid platform over HTTP from a Rust program. It is one of the four client SDKs (Go, Rust, PHP, Node), all the same shape: a headless OIDC token manager, an authenticated retrying JSON client, typed response envelopes, and the five access gates. It is a consumer SDK — it deliberately omits the app-building runtime (no app factory, tenant middleware, outbox, or scoped CRUD); that lives in the two app SDKs.
Four things come in the box, one per module:
| Module | What it provides |
|---|---|
auth | OIDCHeadlessTokenManager logs in via the platform's 3-step OIDC headless flow with PKCE, caches the access token until shortly before it expires, and re-logs-in on expiry. Plus the TokenSource enum and OIDCConfig. |
client | Client — authenticated JSON requests to a named service resolved through a URL map, with retry-on-transient/5xx and typed decoding. Plus ClientConfig. |
envelope | BaseResponse<T>, ErrorResponse, and Meta — the platform's success and error wire shapes, decoded with serde. |
gates | The five access Gates, their typed GateError, and the Entitlements / Identity values they resolve. |
The token manager and client mirror the Go reference SDK exactly — same flow, same retry policy, same envelope. Porting a call fromegav-sdk-gotoegav-sdkis a mechanical rename.
Install and distribution status
The crate is egav-sdk, the library you import is egav_sdk, and the current version is 0.1.0 (edition 2021, minimum Rust 1.74). It is not yet published to crates.io — that publish is pending. Today you consume it as a path dependency against a local checkout of the SDK repo, exactly as the egav-sdk-rust-example app does. When it is tagged and published this becomes an ordinary version dependency and the path line drops away — the import lines in your code do not change.
[dependencies]
# crate: egav-sdk, library: egav_sdk. Path dep until crates.io publish lands.
egav-sdk = { path = "../egav-sdk-rust" }
# The SDK is async over reqwest; you supply the runtime and the HTTP client.
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }The public surface is re-exported from the crate root, so one use line reaches everything: Client, ClientConfig, OIDCConfig, OIDCHeadlessTokenManager, TokenSource, BaseResponse, ErrorResponse, Meta, ApiError, EgavError, and the gate types (Gates, GatesConfig, GateError, Entitlements, Identity, Feature, AccessGate, GateAction).
A worked example — log in, then make a typed call
The flow is always the same: build one reqwest::Client, wrap AuthN login in an OIDCHeadlessTokenManager, hand that to a Client as its TokenSource::Oidc(...), then call get / post with the target type. OIDCHeadlessTokenManager::new returns an Arc<OIDCHeadlessTokenManager>, so the token source is cheap to clone and safe to share across tasks (it is guarded by a tokio::sync::Mutex).
use std::collections::HashMap;
use serde::Deserialize;
use egav_sdk::{
BaseResponse, Client, ClientConfig, EgavError,
OIDCConfig, OIDCHeadlessTokenManager, TokenSource,
};
// The typed payload you expect inside BaseResponse<T>.data.
#[derive(Debug, Deserialize)]
struct Me {
user_guid: String,
email: String,
org_guid: String,
}
#[tokio::main]
async fn main() -> Result<(), EgavError> {
// One shared HTTP client for the token manager and the API client.
let http = reqwest::Client::builder()
.danger_accept_invalid_certs(true) // local self-signed certs ONLY — never in prod
.build()
.map_err(|e| EgavError::Config(e.to_string()))?;
// Headless OIDC login against AuthN (3-step PKCE). Returns an Arc.
let tm = OIDCHeadlessTokenManager::new(
OIDCConfig::new(
"https://authn-api.your-domain.example:5209", // authn_base_url
"public_web_67a894b8cb0e", // client_id (a registered public OIDC client)
"https://your-domain.example:3200/callback", // redirect_uri
"me@example.com", // email
"pw", // password
),
http.clone(),
)?;
// Client-side equivalent of CP's resolve_service: one map, one source of truth.
let mut service_urls = HashMap::new();
service_urls.insert(
"control-plane".to_string(),
"https://cp-api.your-domain.example:5207".to_string(),
);
let client = Client::with_http(
ClientConfig {
service_urls,
token_source: TokenSource::Oidc(tm),
insecure_skip_verify: true, // local dev only
..Default::default() // timeout 30s, max_retries 2 (3 attempts)
},
http,
)?;
// Typed GET: decodes the BaseResponse<Me> envelope; a >= 400 becomes EgavError::Api(ApiError).
let me: BaseResponse<Me> = client
.get("control-plane", "/v1/control-plane/portal/me")
.await?;
if let Some(user) = me.data {
println!("signed in as {} ({}) in org {}", user.email, user.user_guid, user.org_guid);
}
Ok(())
}The full response schema of /portal/me — and every other way to read the current user, their organization, and other members — is documented in Users & organizations.
OIDCConfig::new(authn_base_url, client_id, redirect_uri, email, password) fills in the defaults — scope "openid profile email" and a 30-second expiry skew. The manager runs the three AuthN calls in order: POST /v1/authn/public/oidc/authorize/headless for a session_id, POST /v1/authn/public/oidc/headless/credentials for an authorization code, then POST /v1/authn/public/oidc/token (form) for the access_token and expires_in. It caches the token and only re-runs the flow once the skew window before expiry is reached; the platform issues no refresh token unless you add "offline_access" to the scope, so re-login is the default renewal path. An account with MFA enabled returns mfa_required, which the manager surfaces as an EgavError::Token rather than attempting an interactive challenge. See Auth & the five gates for the full picture of the login flow.
ClientConfig is a plain struct with a Default, so you set only the fields you need:
| Field | Type | Default | Purpose |
|---|---|---|---|
service_urls | HashMap<String, String> | empty (required) | App code → base URL. The only place service URLs live; call sites name the app, never a URL. |
token_source | TokenSource | None | Oidc(Arc<…>) for headless login, Static(String) for a forwarded bearer, or None for public-only. |
default_headers | HashMap<String, String> | empty | Sent on every request — typically X-App-Instance-Guid and X-App-Code. |
timeout | Duration | 30s | Per-request timeout. |
insecure_skip_verify | bool | false | Skips TLS verification for local self-signed certs. Never enable in production. |
max_retries | u32 | 2 | Retries on transport error / timeout / 5xx (so 3 attempts total). 4xx are never retried. |
Client::with_http(cfg, http) builds the client over an HTTP client you already own — the pattern above, so login and API calls share one TLS-configured reqwest::Client. Client::new(cfg) is the alternative that builds its own internal client from timeout + insecure_skip_verify. Requests retry transient failures and 5xx with exponential backoff (starting at 200 ms, doubling each attempt); a 4xx is returned immediately for you to surface. The lower-level do_request(app, method, path, body) returns the raw (status, bytes) when you want to decode yourself; get and post are the typed wrappers.
The response envelope — BaseResponse and ApiError
Every SynaptaGrid service wraps a success in BaseResponse and an error in ErrorResponse (platform Quality Rule 5.1). The SDK models both, and Client::get / Client::post decode them for you: a 2xx deserializes into BaseResponse<T>, and any status >= 400 becomes an EgavError::Api(ApiError) carrying both the HTTP status and the platform's machine-readable error_code.
// Success envelope — data is optional; list endpoints also carry meta.
pub struct BaseResponse<T> {
pub message: String,
pub success: bool,
pub status: String,
pub data: Option<T>,
pub meta: Option<Meta>, // total / page / per_page / total_pages
}
// Any status >= 400 lands here (never a partial success).
pub struct ApiError {
pub status_code: u16, // the HTTP status
pub error_code: i64, // egav_base.ErrorResponse.error_code
pub message: String, // a stable, user-safe string (never a raw traceback)
}Match on EgavError to separate an API-level rejection from a transport or config problem — the variants are Api(ApiError), Transport(String), Decode(String), Config(String), UnknownApp(String) (an app code missing from service_urls), and Token(String) (login failure). Because the envelope decode never invents a partial success, a Some(data) on a BaseResponse is always a real payload.
match client.get::<Me>("control-plane", "/v1/control-plane/portal/me").await {
Ok(resp) => { /* resp.data: Option<Me> */ }
Err(EgavError::Api(api)) if api.status_code == 401 => { /* re-authenticate */ }
Err(EgavError::Api(api)) => eprintln!("api rejected: {} (code {})", api.status_code, api.error_code),
Err(EgavError::UnknownApp(app)) => eprintln!("{app} not in service_urls"),
Err(other) => eprintln!("transport/config: {other}"),
}The five gates in Rust
When your Rust program is itself a platform-native backend protecting an endpoint, the gates module ports the same five access checks every SDK ships, applied in the same order and with the same fail-closed contract. Construct one Gates at start-up with Gates::new(GatesConfig { .. }) (or Gates::with_http to share your reqwest client), then call the gates per request.
| # | Method | On failure | HTTP |
|---|---|---|---|
| 1 | authenticate(user_bearer) → Identity | GateError::Unauthenticated | 401 |
| 2 | require_permission(&id, permission_code, resource_type) | GateError::PermissionDenied (error_code 4031) | 403 |
| 3 | require_active_subscription(&ent) | GateError::SubscriptionInactive | 402 |
| 4 | require_feature(&ent, code) | GateError::FeatureDisabled | 403 |
| 5 | enforce_quota(&ent, code, current, cost) → limit | GateError::QuotaExceeded | 429 |
Gates 3–5 are pure functions over an Entitlements value that you fetch once per request with gates.entitlements(&org_guid) — one Control Plane call powers all three. Every gate fails closed: an unreachable Control Plane or AuthZ, a 5xx, or a malformed payload becomes GateError::Unavailable (HTTP 503), never a silent default-allow. GateError::http_status(), error_code(), and kind() map any failure straight onto a response.
use egav_sdk::{GateError, Gates, GatesConfig};
async fn create_project(
gates: &Gates,
user_bearer: &str,
current_project_count: i64,
) -> Result<String, GateError> {
// 1. auth — validate the end-user bearer, resolve (user_guid, org_guid).
let id = gates.authenticate(user_bearer).await?; // 401 / 503
// 2. permission — AuthZ must allow the action (resource_type defaults to "app").
gates.require_permission(&id, "project.create", None).await?; // 403 / 503
// One CP fetch powers gates 3–5.
let ent = gates.entitlements(&id.org_guid).await?; // 503 on empty/unreachable
// 3. subscription — the org's subscription must be active or trialing.
gates.require_active_subscription(&ent)?; // 402
// 4. feature — the org's plan must include the "projects" feature.
gates.require_feature(&ent, "projects")?; // 403
// 5. quota — the org must be under its "max_projects" limit (cost = 1).
gates.enforce_quota(&ent, "max_projects", current_project_count, 1)?; // 429
Ok(format!("project created for org {}", id.org_guid))
}The two-credential trust model is the important part, and it is not a shortcut. A gate runs inside your server, so it legitimately holds both the per-request end-user bearer and the app's own service x-api-key — and it keeps them apart. The user bearer is sent only for gate 1 (GET /v1/control-plane/portal/me); the app's x-api-key authenticates the service-to-service /internal/* calls that back gate 2 (POST /v1/authz/internal/authorize) and the entitlements fetch for gates 3–5. The user bearer never reaches an /internal/* endpoint, and the app key never reaches a /portal/* one.
let gates = Gates::new(GatesConfig {
control_plane_url: "https://cp-api.your-domain.example:5207".into(),
authz_url: "https://authz-api.your-domain.example:5206".into(),
cp_key: std::env::var("CP_API_KEY").unwrap_or_default(), // portal/me + effective-features
authz_key: std::env::var("AUTHZ_API_KEY").unwrap_or_default(), // authorize (falls back to cp_key)
..Default::default() // timeout 30s
})?;GatesConfig requires control_plane_url, authz_url, and cp_key; authz_key falls back to cp_key when empty, for single-key deployments. The status contract — 401 auth, 402 subscription lapsed, 403 permission or feature denied, 429 quota exceeded, 503 dependency unreachable — is stable across all four client SDKs and matches the Python composition (RequireAuthz plus the feature_gates). See the shared client-SDK concept and package table for how the same gates read in Go, PHP, and Node, and Auth & the five gates for the platform-side contract.