Skip to main content

Documentation

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:

ModuleWhat it provides
authOIDCHeadlessTokenManager 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.
clientClient — authenticated JSON requests to a named service resolved through a URL map, with retry-on-transient/5xx and typed decoding. Plus ClientConfig.
envelopeBaseResponse<T>, ErrorResponse, and Meta — the platform's success and error wire shapes, decoded with serde.
gatesThe 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 from egav-sdk-go to egav-sdk is 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.

Cargo.tomltoml
[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).

main.rsrust
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:

FieldTypeDefaultPurpose
service_urlsHashMap<String, String>empty (required)App code → base URL. The only place service URLs live; call sites name the app, never a URL.
token_sourceTokenSourceNoneOidc(Arc<…>) for headless login, Static(String) for a forwarded bearer, or None for public-only.
default_headersHashMap<String, String>emptySent on every request — typically X-App-Instance-Guid and X-App-Code.
timeoutDuration30sPer-request timeout.
insecure_skip_verifyboolfalseSkips TLS verification for local self-signed certs. Never enable in production.
max_retriesu322Retries 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.

envelope.rsrust
// 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.

handle.rsrust
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.

#MethodOn failureHTTP
1authenticate(user_bearer) → IdentityGateError::Unauthenticated401
2require_permission(&id, permission_code, resource_type)GateError::PermissionDenied (error_code 4031)403
3require_active_subscription(&ent)GateError::SubscriptionInactive402
4require_feature(&ent, code)GateError::FeatureDisabled403
5enforce_quota(&ent, code, current, cost) → limitGateError::QuotaExceeded429

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.

route.rsrust
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.

boot.rsrust
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.