diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..c8940b5 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,21 @@ +# .gitleaks.toml — gitleaks configuration for stygian +# Docs: https://github.com/gitleaks/gitleaks#configuration + +title = "stygian gitleaks config" + +[extend] +# Extend the default ruleset shipped with gitleaks +useDefault = true + +[allowlist] +description = "Global allowlist" +paths = [ + # Cargo lock / generated files — no secrets + "Cargo.lock", + # Changelogs may reference token-like version strings + "CHANGELOG.md", + # Test fixtures and example configs are not real credentials + '''examples/.*''', + '''tests/.*''', + '''target/.*''', +] diff --git a/CHANGELOG.md b/CHANGELOG.md index 8972e4f..4ed26cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.12] - 2026-03-04 + +### Added + +- `stygian-graph`: `AuthPort` trait for runtime credential management — load, expiry-check, and refresh tokens without pre-loading static credentials; includes `ErasedAuthPort` object-safe wrapper (`Arc`) with a blanket impl, `EnvAuthPort` convenience implementation (reads from an env var), and `resolve_token` helper +- `stygian-graph`: `CostThrottleConfig` (port layer) + `LiveBudget` / `PluginBudget` proactive throttle system — tracks the rolling point budget from Shopify/Jobber-style `extensions.cost.throttleStatus` response envelopes; `pre_flight_delay` sleeps before each request if the projected budget is too low, eliminating wasted throttled requests; `reactive_backoff_ms` computes exponential back-off from a throttle response +- `stygian-graph`: `GenericGraphQlPlugin` builder API — construct a fully configured `GraphQlTargetPlugin` without writing a dedicated struct; fluent builder with `.name()`, `.endpoint()`, `.bearer_auth()`, `.auth()`, `.header()`, `.headers()`, `.cost_throttle()`, `.page_size()`, `.description()`, `.build()` +- `stygian-graph`: `GraphQlService::with_auth_port()` — attach a runtime `ErasedAuthPort` to the service; token is resolved lazily per-request and overridden by any static per-plugin auth +- `stygian-graph`: `GraphQlTargetPlugin::cost_throttle_config()` default method — plugins opt in to proactive throttling by returning a `CostThrottleConfig` + +### Removed + +- `stygian-graph`: `JobberPlugin` and `jobber_integration` tests removed from the library — consumer-specific plugins belong in the consuming application; use `GenericGraphQlPlugin` or implement `GraphQlTargetPlugin` directly + ## [0.1.11] - 2026-03-02 ### Added diff --git a/Cargo.lock b/Cargo.lock index 166e3ce..87cf333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3674,7 +3674,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "stygian-browser" -version = "0.1.11" +version = "0.1.12" dependencies = [ "anyhow", "base64", @@ -3694,7 +3694,7 @@ dependencies = [ [[package]] name = "stygian-graph" -version = "0.1.11" +version = "0.1.12" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index e1b06f8..978871c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ ] [workspace.package] -version = "0.1.11" +version = "0.1.12" edition = "2024" rust-version = "1.93.1" authors = ["Nick Campbell "] diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index c05234f..8b47d57 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -9,6 +9,7 @@ - [Architecture](./graph/architecture.md) - [Building Pipelines](./graph/pipelines.md) - [Built-in Adapters](./graph/adapters.md) +- [GraphQL Plugins](./graph/graphql-plugins.md) - [Custom Adapters](./graph/custom-adapters.md) - [Distributed Execution](./graph/distributed.md) - [Observability](./graph/observability.md) diff --git a/book/src/graph/adapters.md b/book/src/graph/adapters.md index 0087064..c594e83 100644 --- a/book/src/graph/adapters.md +++ b/book/src/graph/adapters.md @@ -221,3 +221,42 @@ use std::time::Duration; let cache = DashMapCache::new(Duration::from_secs(300)); // 5-minute default TTL ``` + +--- + +## GraphQL adapter + +The `GraphQlService` adapter executes queries against any GraphQL endpoint using +`GraphQlTargetPlugin` implementations registered in a `GraphQlPluginRegistry`. + +For most APIs, use `GenericGraphQlPlugin` via the fluent builder rather than writing +a dedicated struct: + +```rust +use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; +use stygian_graph::adapters::graphql_throttle::CostThrottleConfig; + +let plugin = GenericGraphQlPlugin::builder() + .name("github") + .endpoint("https://api.github.com/graphql") + .bearer_auth("${env:GITHUB_TOKEN}") + .header("X-Github-Next-Global-ID", "1") + .cost_throttle(CostThrottleConfig::default()) + .page_size(30) + .build() + .expect("name and endpoint required"); +``` + +For runtime-rotating credentials inject an `AuthPort`: + +```rust +use std::sync::Arc; +use stygian_graph::adapters::graphql::{GraphQlConfig, GraphQlService}; +use stygian_graph::ports::auth::{EnvAuthPort, ErasedAuthPort}; + +let service = GraphQlService::new(GraphQlConfig::default(), Some(Arc::new(registry))) + .with_auth_port(Arc::new(EnvAuthPort::new("MY_API_TOKEN")) as Arc); +``` + +See the [GraphQL Plugins](./graphql-plugins.md) page for the full builder reference, +`AuthPort` implementation guide, proactive cost throttling, and custom plugin examples. diff --git a/book/src/graph/graphql-plugins.md b/book/src/graph/graphql-plugins.md new file mode 100644 index 0000000..bb9f4ad --- /dev/null +++ b/book/src/graph/graphql-plugins.md @@ -0,0 +1,249 @@ +# GraphQL Plugins + +`stygian-graph` ships a generic, builder-based GraphQL plugin system built on top of +the `GraphQlTargetPlugin` port trait. Instead of writing a dedicated struct for each +API you want to query, reach for `GenericGraphQlPlugin`. + +--- + +## GenericGraphQlPlugin + +`GenericGraphQlPlugin` implements `GraphQlTargetPlugin` and is configured entirely +via a fluent builder. Only `name` and `endpoint` are required; everything else is +optional with sensible defaults. + +```rust +use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; +use stygian_graph::adapters::graphql_throttle::CostThrottleConfig; + +let plugin = GenericGraphQlPlugin::builder() + .name("github") + .endpoint("https://api.github.com/graphql") + .bearer_auth("${env:GITHUB_TOKEN}") + .header("X-Github-Next-Global-ID", "1") + .cost_throttle(CostThrottleConfig::default()) + .page_size(30) + .description("GitHub GraphQL API v4") + .build() + .expect("name and endpoint are required"); +``` + +### Builder reference + +| Method | Required | Description | +|---|---|---| +| `.name(impl Into)` | **yes** | Plugin identifier used in the registry | +| `.endpoint(impl Into)` | **yes** | Full GraphQL endpoint URL | +| `.bearer_auth(impl Into)` | no | Shorthand: sets a `Bearer` auth token | +| `.auth(GraphQlAuth)` | no | Full auth struct (Bearer, API key, or custom header) | +| `.header(key, value)` | no | Add a single request header (repeatable) | +| `.headers(HashMap)` | no | Bulk-replace all headers | +| `.cost_throttle(CostThrottleConfig)` | no | Enable proactive point-budget throttling | +| `.page_size(usize)` | no | Default page size for paginated queries (default `50`) | +| `.description(impl Into)` | no | Human-readable description | +| `.build()` | — | Returns `Result` | + +### Auth options + +```rust +use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind}; + +// Bearer token (most common) +let plugin = GenericGraphQlPlugin::builder() + .name("shopify") + .endpoint("https://my-store.myshopify.com/admin/api/2025-01/graphql.json") + .bearer_auth("${env:SHOPIFY_ACCESS_TOKEN}") + .build() + .unwrap(); + +// Custom header (e.g. X-Shopify-Access-Token) +let plugin = GenericGraphQlPlugin::builder() + .name("shopify-legacy") + .endpoint("https://my-store.myshopify.com/admin/api/2025-01/graphql.json") + .auth(GraphQlAuth { + kind: GraphQlAuthKind::Header, + token: "${env:SHOPIFY_ACCESS_TOKEN}".to_string(), + header_name: Some("X-Shopify-Access-Token".to_string()), + }) + .build() + .unwrap(); +``` + +Tokens containing `${env:VAR_NAME}` are expanded by the pipeline template processor +(`expand_template`) or by `GraphQlService::apply_auth`, not by `EnvAuthPort` itself. +`EnvAuthPort` reads the env var value directly — no template syntax is needed when +using it as a runtime auth port. + +--- + +## AuthPort — runtime credential management + +For credentials that rotate, expire, or need a refresh flow, implement the +`AuthPort` trait and inject it into `GraphQlService`. + +```rust +use stygian_graph::ports::auth::{AuthPort, AuthError, TokenSet}; + +pub struct MyOAuthPort { /* ... */ } + +impl AuthPort for MyOAuthPort { + async fn load_token(&self) -> Result, AuthError> { + // Return None if no cached token exists; Some(token) if you have one. + Ok(Some(TokenSet { + access_token: fetch_stored_token().await?, + refresh_token: Some(fetch_stored_refresh_token().await?), + expires_at: Some(std::time::SystemTime::now() + + std::time::Duration::from_secs(3600)), + scopes: vec!["read".to_string()], + })) + } + + async fn refresh_token(&self) -> Result { + // Exchange the refresh token for a new access token. + Ok(TokenSet { + access_token: exchange_refresh_token().await?, + refresh_token: Some(fetch_new_refresh_token().await?), + expires_at: Some(std::time::SystemTime::now() + + std::time::Duration::from_secs(3600)), + scopes: vec!["read".to_string()], + }) + } +} +``` + +### Wiring into GraphQlService + +```rust +use std::sync::Arc; +use stygian_graph::adapters::graphql::{GraphQlConfig, GraphQlService}; +use stygian_graph::ports::auth::ErasedAuthPort; + +let service = GraphQlService::new(GraphQlConfig::default(), None) + .with_auth_port(Arc::new(MyOAuthPort { /* ... */ }) as Arc); +``` + +The service calls `resolve_token` before each request. If the token is expired (or +within 60 seconds of expiry), `refresh_token` is called automatically. + +### EnvAuthPort — zero-config static token + +For non-rotating tokens, `EnvAuthPort` reads a bearer token from an environment +variable at load time: + +```rust +use stygian_graph::ports::auth::EnvAuthPort; + +let auth = EnvAuthPort::new("GITHUB_TOKEN"); +``` + +If `GITHUB_TOKEN` is not set, `EnvAuthPort::load_token` returns `Ok(None)`. An error +(`AuthError::TokenNotFound`) is only raised later when `resolve_token` is called and +finds no token available. + +--- + +## Cost throttling + +GraphQL APIs that expose `extensions.cost.throttleStatus` (Shopify Admin API, +Jobber, and others) can be configured for proactive point-budget management. + +### CostThrottleConfig + +```rust +use stygian_graph::ports::graphql_plugin::CostThrottleConfig; + +let config = CostThrottleConfig { + max_points: 10_000.0, // bucket capacity + restore_per_sec: 500.0, // points restored per second + min_available: 50.0, // don't send if fewer points remain + max_delay_ms: 30_000, // wait at most 30 s before giving up + estimated_cost_per_request: 100.0, // pessimistic per-request reservation +}; +``` + +| Field | Default | Description | +|---|---|---| +| `max_points` | `10_000.0` | Total bucket capacity | +| `restore_per_sec` | `500.0` | Points/second restored | +| `min_available` | `50.0` | Points threshold below which we pre-sleep | +| `max_delay_ms` | `30_000` | Hard ceiling on proactive sleep duration (ms) | +| `estimated_cost_per_request` | `100.0` | Pessimistic reservation per request to prevent concurrent tasks from all passing the pre-flight check simultaneously | + +Attach config to a plugin via `.cost_throttle(config)` on the builder, or override +`GraphQlTargetPlugin::cost_throttle_config()` on a custom plugin implementation. + +### How budget tracking works + +1. **Pre-flight reserve**: `pre_flight_reserve` inspects the current `LiveBudget` + for the plugin. If the projected available points (net of in-flight + reservations) fall below `min_available + estimated_cost_per_request`, it + sleeps for the exact duration needed to restore enough points, up to + `max_delay_ms`. It then atomically reserves `estimated_cost_per_request` + points so concurrent tasks immediately see a reduced balance and cannot all + pass the pre-flight check simultaneously. +2. **Post-response**: `update_budget` parses `extensions.cost.throttleStatus` + out of the response JSON and updates the per-plugin `LiveBudget` to the true + server-reported balance. `release_reservation` is then called to remove the + in-flight reservation. +3. **Reactive back-off**: If a request is throttled anyway (HTTP 429 or + `extensions.cost` signals exhaustion), `reactive_backoff_ms` computes an + exponential delay. + +The budgets are stored in a `HashMap` keyed by plugin name +and protected by a `tokio::sync::RwLock`, so all concurrent requests share the +same view of remaining points. + +--- + +## Writing a custom plugin + +For complex APIs — multi-tenant endpoints, per-request header mutations, non-standard +auth flows — implement `GraphQlTargetPlugin` directly: + +```rust +use std::collections::HashMap; +use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind}; +use stygian_graph::ports::graphql_plugin::{CostThrottleConfig, GraphQlTargetPlugin}; + +pub struct AcmeApi { + token: String, +} + +impl GraphQlTargetPlugin for AcmeApi { + fn name(&self) -> &str { "acme" } + fn endpoint(&self) -> &str { "https://api.acme.io/graphql" } + + fn version_headers(&self) -> HashMap { + [("Acme-Api-Version".to_string(), "2025-01".to_string())] + .into_iter() + .collect() + } + + fn default_auth(&self) -> Option { + Some(GraphQlAuth { + kind: GraphQlAuthKind::Bearer, + token: self.token.clone(), + header_name: None, + }) + } + + fn default_page_size(&self) -> usize { 25 } + fn description(&self) -> &str { "Acme Corp GraphQL API" } + fn supports_cursor_pagination(&self) -> bool { true } + + // opt-in to proactive throttling + fn cost_throttle_config(&self) -> Option { + Some(CostThrottleConfig::default()) + } +} +``` + +Register it the same way as any built-in plugin: + +```rust +use std::sync::Arc; +use stygian_graph::application::graphql_plugin_registry::GraphQlPluginRegistry; + +let mut registry = GraphQlPluginRegistry::new(); +registry.register(Arc::new(AcmeApi { token: /* ... */ })); +``` diff --git a/crates/stygian-graph/src/adapters.rs b/crates/stygian-graph/src/adapters.rs index 2ef4c74..59bedf3 100644 --- a/crates/stygian-graph/src/adapters.rs +++ b/crates/stygian-graph/src/adapters.rs @@ -37,6 +37,9 @@ pub mod mock_ai; /// GraphQL API adapter — generic ScrapingService for any GraphQL endpoint pub mod graphql; +/// Proactive cost-throttle management for GraphQL APIs +pub mod graphql_throttle; + /// Distributed work queue and executor adapters pub mod distributed; diff --git a/crates/stygian-graph/src/adapters/graphql.rs b/crates/stygian-graph/src/adapters/graphql.rs index 00416aa..b1796a6 100644 --- a/crates/stygian-graph/src/adapters/graphql.rs +++ b/crates/stygian-graph/src/adapters/graphql.rs @@ -15,10 +15,15 @@ use std::time::Duration; use async_trait::async_trait; use serde_json::{Value, json}; +use tokio::sync::RwLock; +use crate::adapters::graphql_throttle::{ + PluginBudget, pre_flight_reserve, reactive_backoff_ms, release_reservation, update_budget, +}; use crate::application::graphql_plugin_registry::GraphQlPluginRegistry; use crate::application::pipeline_parser::expand_template; use crate::domain::error::{Result, ServiceError, StygianError}; +use crate::ports::auth::ErasedAuthPort; use crate::ports::{GraphQlAuth, GraphQlAuthKind, ScrapingService, ServiceInput, ServiceOutput}; // ───────────────────────────────────────────────────────────────────────────── @@ -95,6 +100,10 @@ pub struct GraphQlService { client: reqwest::Client, config: GraphQlConfig, plugins: Option>, + /// Optional runtime auth port — used when no static auth is configured. + auth_port: Option>, + /// Per-plugin proactive cost-throttle budgets, keyed by plugin name. + budgets: Arc>>, } impl GraphQlService { @@ -121,9 +130,34 @@ impl GraphQlService { client, config, plugins, + auth_port: None, + budgets: Arc::new(RwLock::new(HashMap::new())), } } + /// Attach a runtime auth port. + /// + /// When set, the port's `erased_resolve_token()` will be called to obtain + /// a bearer token whenever `params.auth` is absent and the plugin supplies + /// no `default_auth`. + /// + /// # Example + /// + /// ```no_run + /// use std::sync::Arc; + /// use stygian_graph::adapters::graphql::{GraphQlService, GraphQlConfig}; + /// use stygian_graph::ports::auth::{EnvAuthPort, ErasedAuthPort}; + /// + /// let auth: Arc = Arc::new(EnvAuthPort::new("API_TOKEN")); + /// let service = GraphQlService::new(GraphQlConfig::default(), None) + /// .with_auth_port(auth); + /// ``` + #[must_use] + pub fn with_auth_port(mut self, port: Arc) -> Self { + self.auth_port = Some(port); + self + } + // ── Internal helpers ───────────────────────────────────────────────────── /// Apply auth to the request builder. @@ -297,10 +331,19 @@ impl GraphQlService { } /// Validate a parsed GraphQL body (errors array, missing `data` key, throttle). + /// + /// When a `budget` is supplied, uses `reactive_backoff_ms` (config-aware) + /// instead of the fixed-clamp fallback for throttle back-off. `attempt` + /// is the 0-based retry count; callers that implement a retry loop should + /// increment it on each attempt to get exponential back-off. #[allow(clippy::indexing_slicing)] - fn validate_body(body: &Value) -> Result<()> { + fn validate_body(body: &Value, budget: Option<&PluginBudget>, attempt: u32) -> Result<()> { // Throttle check takes priority so callers can retry with backoff. - if let Some(retry_after_ms) = Self::detect_throttle(body) { + if Self::detect_throttle(body).is_some() { + let retry_after_ms = budget.map_or_else( + || Self::throttle_backoff(body), + |b| reactive_backoff_ms(b.config(), body, attempt), + ); return Err(StygianError::Service(ServiceError::RateLimited { retry_after_ms, })); @@ -392,7 +435,57 @@ impl ScrapingService for GraphQlService { let auth: Option = if !params["auth"].is_null() && params["auth"].is_object() { Self::parse_auth(¶ms["auth"]) } else { - plugin.as_ref().and_then(|p| p.default_auth()) + // Prefer plugin-provided default auth when present; only fall back to + // the runtime auth port when the plugin returns None (or no plugin). + let plugin_auth = plugin.as_ref().and_then(|p| p.default_auth()); + if plugin_auth.is_some() { + plugin_auth + } else if let Some(ref port) = self.auth_port { + match port.erased_resolve_token().await { + Ok(token) => Some(GraphQlAuth { + kind: GraphQlAuthKind::Bearer, + token, + header_name: None, + }), + Err(e) => { + let msg = format!("auth port failed to resolve token: {e}"); + tracing::error!("{msg}"); + return Err(StygianError::Service(ServiceError::AuthenticationFailed( + msg, + ))); + } + } + } else { + None + } + }; + + // ── 4b. Lazy-init and acquire per-plugin budget ─────────────────── + let maybe_budget: Option = if let Some(ref p) = plugin { + if let Some(throttle_cfg) = p.cost_throttle_config() { + let name = p.name().to_string(); + let budget = { + let read = self.budgets.read().await; + if let Some(b) = read.get(&name) { + b.clone() + } else { + drop(read); + // Slow path: initialise under write lock with double-check + // to prevent two concurrent requests both inserting a fresh + // budget and one overwriting any updates the other has applied. + let mut write = self.budgets.write().await; + write + .entry(name) + .or_insert_with(|| PluginBudget::new(throttle_cfg)) + .clone() + } + }; + Some(budget) + } else { + None + } + } else { + None }; // ── 5. Build headers (extra + plugin version headers) ───────────── @@ -444,7 +537,17 @@ impl ScrapingService for GraphQlService { ))); } - let body = self + // NOTE: explicit release_reservation at every exit path is required + // because Rust 1.93.1 does not have AsyncDrop. + // TODO(async-drop): replace with BudgetReservation RAII guard once + // AsyncDrop is stabilised on stable. + let reserved_cost = if let Some(ref b) = maybe_budget { + pre_flight_reserve(b).await + } else { + 0.0 + }; + + let body = match self .post_query( &url, query, @@ -453,9 +556,29 @@ impl ScrapingService for GraphQlService { auth.as_ref(), &extra_headers, ) - .await?; + .await + { + Ok(b) => b, + Err(e) => { + if let Some(ref b) = maybe_budget { + release_reservation(b, reserved_cost).await; + } + return Err(e); + } + }; + + if let Err(e) = Self::validate_body(&body, maybe_budget.as_ref(), 0) { + if let Some(ref b) = maybe_budget { + release_reservation(b, reserved_cost).await; + } + return Err(e); + } - Self::validate_body(&body)?; + // Update proactive budget from response, then release reservation + if let Some(ref b) = maybe_budget { + update_budget(b, &body).await; + release_reservation(b, reserved_cost).await; + } // Accumulate edges let edges = Self::json_path(&body, &edges_path); @@ -484,7 +607,17 @@ impl ScrapingService for GraphQlService { }) } else { // Single-request mode - let body = self + // NOTE: explicit release_reservation at every exit path is required + // because Rust 1.93.1 does not have AsyncDrop. + // TODO(async-drop): replace with BudgetReservation RAII guard once + // AsyncDrop is stabilised on stable. + let reserved_cost = if let Some(ref b) = maybe_budget { + pre_flight_reserve(b).await + } else { + 0.0 + }; + + let body = match self .post_query( &url, query, @@ -493,9 +626,29 @@ impl ScrapingService for GraphQlService { auth.as_ref(), &extra_headers, ) - .await?; + .await + { + Ok(b) => b, + Err(e) => { + if let Some(ref b) = maybe_budget { + release_reservation(b, reserved_cost).await; + } + return Err(e); + } + }; - Self::validate_body(&body)?; + if let Err(e) = Self::validate_body(&body, maybe_budget.as_ref(), 0) { + if let Some(ref b) = maybe_budget { + release_reservation(b, reserved_cost).await; + } + return Err(e); + } + + // Update proactive budget from response, then release reservation + if let Some(ref b) = maybe_budget { + update_budget(b, &body).await; + release_reservation(b, reserved_cost).await; + } let cost_meta = Self::extract_cost_metadata(&body).unwrap_or(json!(null)); let metadata = json!({ "cost": cost_meta }); @@ -1002,4 +1155,38 @@ mod tests { "expected pagination cap error, got {err:?}" ); } + + #[tokio::test] + async fn auth_port_fallback_used_when_no_params_or_plugin_auth() { + use crate::ports::auth::{AuthError, ErasedAuthPort}; + + struct StaticAuthPort(&'static str); + + #[async_trait] + impl ErasedAuthPort for StaticAuthPort { + async fn erased_resolve_token(&self) -> std::result::Result { + Ok(self.0.to_string()) + } + } + + let body = simple_query_body(json!({})); + let request_bytes = MockGraphQlServer::run_capturing_request(body, |url| async move { + let svc = make_service(None).with_auth_port( + Arc::new(StaticAuthPort("port-token-xyz")) as Arc + ); + let input = ServiceInput { + url, + // No `auth` field and no plugin — auth_port should supply the token + params: json!({ "query": "{ x }" }), + }; + let _ = svc.execute(input).await; + }) + .await; + + let request_str = String::from_utf8_lossy(&request_bytes); + assert!( + request_str.contains("Bearer port-token-xyz"), + "auth_port bearer token not applied:\n{request_str}" + ); + } } diff --git a/crates/stygian-graph/src/adapters/graphql_plugins/generic.rs b/crates/stygian-graph/src/adapters/graphql_plugins/generic.rs new file mode 100644 index 0000000..b7ac8d2 --- /dev/null +++ b/crates/stygian-graph/src/adapters/graphql_plugins/generic.rs @@ -0,0 +1,459 @@ +//! Generic GraphQL target plugin with a fluent builder API. +//! +//! Use `GenericGraphQlPlugin` when you need a quick, ad-hoc plugin without +//! writing a dedicated implementation struct. Supply the endpoint, optional +//! auth, headers, and cost-throttle configuration via the builder. +//! +//! # Example +//! +//! ```rust +//! use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; +//! use stygian_graph::adapters::graphql_throttle::CostThrottleConfig; +//! use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind}; +//! use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin; +//! +//! let plugin = GenericGraphQlPlugin::builder() +//! .name("github") +//! .endpoint("https://api.github.com/graphql") +//! .auth(GraphQlAuth { +//! kind: GraphQlAuthKind::Bearer, +//! token: "${env:GITHUB_TOKEN}".to_string(), +//! header_name: None, +//! }) +//! .header("X-Github-Next-Global-ID", "1") +//! .cost_throttle(CostThrottleConfig::default()) +//! .page_size(30) +//! .description("GitHub GraphQL API") +//! .build() +//! .expect("required fields: name and endpoint"); +//! +//! assert_eq!(plugin.name(), "github"); +//! assert_eq!(plugin.default_page_size(), 30); +//! ``` + +use std::collections::HashMap; + +use crate::ports::graphql_plugin::{CostThrottleConfig, GraphQlTargetPlugin}; +use crate::ports::{GraphQlAuth, GraphQlAuthKind}; + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin struct +// ───────────────────────────────────────────────────────────────────────────── + +/// A fully generic GraphQL target plugin built via [`GenericGraphQlPluginBuilder`]. +/// +/// Implements [`GraphQlTargetPlugin`] and can be registered with +/// `GraphQlPluginRegistry` like any other plugin. +#[derive(Debug, Clone)] +pub struct GenericGraphQlPlugin { + name: String, + endpoint: String, + headers: HashMap, + auth: Option, + throttle: Option, + page_size: usize, + description: String, +} + +impl GenericGraphQlPlugin { + /// Return a fresh [`GenericGraphQlPluginBuilder`]. + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin; + /// + /// let plugin = GenericGraphQlPlugin::builder() + /// .name("my-api") + /// .endpoint("https://api.example.com/graphql") + /// .build() + /// .expect("name and endpoint are required"); + /// + /// assert_eq!(plugin.name(), "my-api"); + /// ``` + #[must_use] + pub fn builder() -> GenericGraphQlPluginBuilder { + GenericGraphQlPluginBuilder::default() + } + + /// Return the configured cost-throttle config if any. + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// use stygian_graph::adapters::graphql_throttle::CostThrottleConfig; + /// + /// let plugin = GenericGraphQlPlugin::builder() + /// .name("api") + /// .endpoint("https://api.example.com/graphql") + /// .cost_throttle(CostThrottleConfig::default()) + /// .build() + /// .expect("ok"); + /// + /// assert!(plugin.cost_throttle_config().is_some()); + /// ``` + #[must_use] + pub const fn cost_throttle_config(&self) -> Option<&CostThrottleConfig> { + self.throttle.as_ref() + } +} + +impl GraphQlTargetPlugin for GenericGraphQlPlugin { + fn name(&self) -> &str { + &self.name + } + + fn endpoint(&self) -> &str { + &self.endpoint + } + + fn version_headers(&self) -> HashMap { + self.headers.clone() + } + + fn default_auth(&self) -> Option { + self.auth.clone() + } + + fn default_page_size(&self) -> usize { + self.page_size + } + + fn description(&self) -> &str { + &self.description + } + + fn supports_cursor_pagination(&self) -> bool { + true + } + + fn cost_throttle_config(&self) -> Option { + self.throttle.clone() + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Builder +// ───────────────────────────────────────────────────────────────────────────── + +/// Builder for [`GenericGraphQlPlugin`]. +/// +/// Obtain via [`GenericGraphQlPlugin::builder()`]. The only required fields +/// are `name` and `endpoint`; everything else has sensible defaults. +#[derive(Debug, Default)] +pub struct GenericGraphQlPluginBuilder { + name: Option, + endpoint: Option, + headers: HashMap, + auth: Option, + throttle: Option, + page_size: usize, + description: String, +} + +impl GenericGraphQlPluginBuilder { + /// Set the plugin name (required). + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// + /// let _builder = GenericGraphQlPlugin::builder().name("my-api"); + /// ``` + #[must_use] + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Set the GraphQL endpoint URL (required). + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// + /// let _builder = GenericGraphQlPlugin::builder() + /// .endpoint("https://api.example.com/graphql"); + /// ``` + #[must_use] + pub fn endpoint(mut self, endpoint: impl Into) -> Self { + self.endpoint = Some(endpoint.into()); + self + } + + /// Add a single request header. + /// + /// May be called multiple times to accumulate headers. + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// + /// let _builder = GenericGraphQlPlugin::builder() + /// .header("X-Api-Version", "2025-01-01") + /// .header("Accept-Language", "en"); + /// ``` + #[must_use] + pub fn header(mut self, key: impl Into, value: impl Into) -> Self { + self.headers.insert(key.into(), value.into()); + self + } + + /// Replace all headers with a pre-built map. + /// + /// # Example + /// + /// ```rust + /// use std::collections::HashMap; + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// + /// let headers: HashMap<_, _> = [("X-Version", "1")].into_iter() + /// .map(|(k, v)| (k.to_string(), v.to_string())) + /// .collect(); + /// let _builder = GenericGraphQlPlugin::builder().headers(headers); + /// ``` + #[must_use] + pub fn headers(mut self, headers: HashMap) -> Self { + self.headers = headers; + self + } + + /// Set the default auth credentials. + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// use stygian_graph::ports::{GraphQlAuth, GraphQlAuthKind}; + /// + /// let _builder = GenericGraphQlPlugin::builder() + /// .auth(GraphQlAuth { + /// kind: GraphQlAuthKind::Bearer, + /// token: "${env:GITHUB_TOKEN}".to_string(), + /// header_name: None, + /// }); + /// ``` + #[must_use] + pub fn auth(mut self, auth: GraphQlAuth) -> Self { + self.auth = Some(auth); + self + } + + /// Convenience helper: set a Bearer-token auth from a string. + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// + /// let _builder = GenericGraphQlPlugin::builder() + /// .bearer_auth("${env:MY_TOKEN}"); + /// ``` + #[must_use] + pub fn bearer_auth(mut self, token: impl Into) -> Self { + self.auth = Some(GraphQlAuth { + kind: GraphQlAuthKind::Bearer, + token: token.into(), + header_name: None, + }); + self + } + + /// Attach a cost-throttle configuration for proactive pre-flight delays. + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// use stygian_graph::adapters::graphql_throttle::CostThrottleConfig; + /// + /// let _builder = GenericGraphQlPlugin::builder() + /// .cost_throttle(CostThrottleConfig::default()); + /// ``` + #[must_use] + pub const fn cost_throttle(mut self, throttle: CostThrottleConfig) -> Self { + self.throttle = Some(throttle); + self + } + + /// Override the default page size (default: `50`). + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// + /// let _builder = GenericGraphQlPlugin::builder().page_size(30); + /// ``` + #[must_use] + pub const fn page_size(mut self, page_size: usize) -> Self { + self.page_size = page_size; + self + } + + /// Set a human-readable description of the plugin. + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// + /// let _builder = GenericGraphQlPlugin::builder() + /// .description("GitHub public API v4"); + /// ``` + #[must_use] + pub fn description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + /// Consume the builder and produce a [`GenericGraphQlPlugin`]. + /// + /// Returns `Err` if `name` or `endpoint` were not set. + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::adapters::graphql_plugins::generic::GenericGraphQlPlugin; + /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin; + /// + /// let plugin = GenericGraphQlPlugin::builder() + /// .name("github") + /// .endpoint("https://api.github.com/graphql") + /// .build() + /// .expect("ok"); + /// + /// assert_eq!(plugin.name(), "github"); + /// ``` + pub fn build(self) -> Result { + Ok(GenericGraphQlPlugin { + name: self.name.ok_or(BuildError::MissingName)?, + endpoint: self.endpoint.ok_or(BuildError::MissingEndpoint)?, + headers: self.headers, + auth: self.auth, + throttle: self.throttle, + page_size: if self.page_size == 0 { + 50 + } else { + self.page_size + }, + description: self.description, + }) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// BuildError +// ───────────────────────────────────────────────────────────────────────────── + +/// Errors that can occur when building a [`GenericGraphQlPlugin`]. +#[derive(Debug, thiserror::Error)] +pub enum BuildError { + /// The `name` field was not set. + #[error("plugin name is required — call .name(\"...\")")] + MissingName, + /// The `endpoint` field was not set. + #[error("plugin endpoint is required — call .endpoint(\"...\")")] + MissingEndpoint, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn minimal_plugin() -> GenericGraphQlPlugin { + GenericGraphQlPlugin::builder() + .name("test") + .endpoint("https://api.example.com/graphql") + .build() + .unwrap() + } + + #[test] + fn builder_minimal_roundtrip() { + let p = minimal_plugin(); + assert_eq!(p.name(), "test"); + assert_eq!(p.endpoint(), "https://api.example.com/graphql"); + assert_eq!(p.default_page_size(), 50); // default + assert!(p.default_auth().is_none()); + assert!(p.cost_throttle_config().is_none()); + assert!(p.version_headers().is_empty()); + } + + #[test] + fn builder_full_roundtrip() { + let plugin = GenericGraphQlPlugin::builder() + .name("github") + .endpoint("https://api.github.com/graphql") + .bearer_auth("ghp_test") + .header("X-Github-Next-Global-ID", "1") + .cost_throttle(CostThrottleConfig::default()) + .page_size(30) + .description("GitHub v4") + .build() + .unwrap(); + + assert_eq!(plugin.name(), "github"); + assert_eq!(plugin.default_page_size(), 30); + assert_eq!(plugin.description(), "GitHub v4"); + assert!(plugin.default_auth().is_some()); + assert!(plugin.cost_throttle_config().is_some()); + let headers = plugin.version_headers(); + assert_eq!( + headers.get("X-Github-Next-Global-ID").map(String::as_str), + Some("1") + ); + } + + #[test] + fn builder_error_missing_name() { + let result = GenericGraphQlPlugin::builder() + .endpoint("https://api.example.com/graphql") + .build(); + assert!(matches!(result, Err(BuildError::MissingName))); + } + + #[test] + fn builder_error_missing_endpoint() { + let result = GenericGraphQlPlugin::builder().name("api").build(); + assert!(matches!(result, Err(BuildError::MissingEndpoint))); + } + + #[test] + fn page_size_zero_defaults_to_50() { + let plugin = GenericGraphQlPlugin::builder() + .name("api") + .endpoint("https://api.example.com/graphql") + .page_size(0) + .build() + .unwrap(); + assert_eq!(plugin.default_page_size(), 50); + } + + #[test] + fn headers_map_replacement() { + use std::collections::HashMap; + let mut map = HashMap::new(); + map.insert("X-Foo".to_string(), "bar".to_string()); + let plugin = GenericGraphQlPlugin::builder() + .name("api") + .endpoint("https://api.example.com/graphql") + .headers(map) + .build() + .unwrap(); + assert_eq!( + plugin.version_headers().get("X-Foo").map(String::as_str), + Some("bar") + ); + } +} diff --git a/crates/stygian-graph/src/adapters/graphql_plugins/jobber.rs b/crates/stygian-graph/src/adapters/graphql_plugins/jobber.rs deleted file mode 100644 index 069f75a..0000000 --- a/crates/stygian-graph/src/adapters/graphql_plugins/jobber.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! Jobber GraphQL plugin — see T36 for the full implementation. -//! -//! Jobber is a field-service management platform whose GraphQL API lives at -//! `https://api.getjobber.com/api/graphql` and requires the version header -//! `X-JOBBER-GRAPHQL-VERSION: 2025-04-16` on every request. - -use std::collections::HashMap; - -use crate::ports::graphql_plugin::GraphQlTargetPlugin; -use crate::ports::{GraphQlAuth, GraphQlAuthKind}; - -/// Jobber GraphQL API plugin. -/// -/// Supplies the endpoint, required version header, and default bearer-token -/// auth for all Jobber pipeline nodes. The access token is read from the -/// `JOBBER_ACCESS_TOKEN` environment variable **at construction time** via -/// [`JobberPlugin::new`] (or [`Default`]), so `default_auth` performs no -/// environment access after the plugin is built. Use [`JobberPlugin::with_token`] -/// to inject a token directly (useful in tests and programmatic usage). -/// -/// # Example -/// -/// ```rust -/// use stygian_graph::adapters::graphql_plugins::jobber::JobberPlugin; -/// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin; -/// -/// let plugin = JobberPlugin::new(); -/// assert_eq!(plugin.name(), "jobber"); -/// assert_eq!(plugin.endpoint(), "https://api.getjobber.com/api/graphql"); -/// ``` -pub struct JobberPlugin { - token: Option, -} - -impl JobberPlugin { - /// Creates a new [`JobberPlugin`], reading the access token from the - /// `JOBBER_ACCESS_TOKEN` environment variable. - /// - /// # Example - /// - /// ```rust - /// use stygian_graph::adapters::graphql_plugins::jobber::JobberPlugin; - /// - /// let plugin = JobberPlugin::new(); - /// ``` - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Creates a [`JobberPlugin`] with an explicit access token, bypassing the - /// environment entirely. - /// - /// This is useful when credentials are already available at call-site (e.g., - /// fetched from a secret store) or when writing tests without mutating - /// process environment variables. - /// - /// # Example - /// - /// ```rust - /// use stygian_graph::adapters::graphql_plugins::jobber::JobberPlugin; - /// use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin; - /// - /// let plugin = JobberPlugin::with_token("my-secret-token"); - /// assert!(plugin.default_auth().is_some()); - /// ``` - #[must_use] - pub fn with_token(token: impl Into) -> Self { - Self { - token: Some(token.into()), - } - } -} - -impl Default for JobberPlugin { - /// Creates a [`JobberPlugin`] by reading `JOBBER_ACCESS_TOKEN` from the - /// environment at construction time. - fn default() -> Self { - Self { - token: std::env::var("JOBBER_ACCESS_TOKEN").ok(), - } - } -} - -impl GraphQlTargetPlugin for JobberPlugin { - fn name(&self) -> &'static str { - "jobber" - } - - fn endpoint(&self) -> &'static str { - "https://api.getjobber.com/api/graphql" - } - - fn version_headers(&self) -> HashMap { - [( - "X-JOBBER-GRAPHQL-VERSION".to_string(), - "2025-04-16".to_string(), - )] - .into() - } - - fn default_auth(&self) -> Option { - self.token.as_ref().map(|token| GraphQlAuth { - kind: GraphQlAuthKind::Bearer, - token: token.clone(), - header_name: None, - }) - } - - fn description(&self) -> &'static str { - "Jobber field-service management GraphQL API" - } -} - -#[cfg(test)] -#[allow(clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn plugin_name_is_jobber() { - assert_eq!(JobberPlugin::new().name(), "jobber"); - } - - #[test] - fn endpoint_is_correct() { - assert_eq!( - JobberPlugin::new().endpoint(), - "https://api.getjobber.com/api/graphql" - ); - } - - #[test] - fn version_header_is_set() { - let headers = JobberPlugin::new().version_headers(); - assert_eq!( - headers.get("X-JOBBER-GRAPHQL-VERSION").map(String::as_str), - Some("2025-04-16") - ); - } - - #[test] - fn default_auth_with_injected_token() { - let plugin = JobberPlugin::with_token("test-token-abc"); - let auth = plugin.default_auth(); - assert!(auth.is_some(), "auth should be Some when token is injected"); - let auth = auth.expect("auth should be Some when token is injected"); - assert_eq!(auth.kind, GraphQlAuthKind::Bearer); - assert_eq!(auth.token, "test-token-abc"); - assert!(auth.header_name.is_none()); - } - - #[test] - fn default_auth_absent_when_no_token() { - let plugin = JobberPlugin { token: None }; - assert!(plugin.default_auth().is_none()); - } - - #[test] - fn description_is_non_empty() { - assert!(!JobberPlugin::new().description().is_empty()); - } -} diff --git a/crates/stygian-graph/src/adapters/graphql_plugins/mod.rs b/crates/stygian-graph/src/adapters/graphql_plugins/mod.rs index 78b93d7..46d4b4f 100644 --- a/crates/stygian-graph/src/adapters/graphql_plugins/mod.rs +++ b/crates/stygian-graph/src/adapters/graphql_plugins/mod.rs @@ -5,8 +5,11 @@ //! //! # Available plugins //! -//! | Module | Target | Env var | +//! | Module | Target | Notes | //! |--------|--------|---------| -//! | [`jobber`](graphql_plugins::jobber) | Jobber field-service management | `JOBBER_ACCESS_TOKEN` | +//! | [`generic`](graphql_plugins::generic) | Any GraphQL API | Fully configurable via builder | +//! +//! Consumer-specific plugins (e.g. Jobber) live in the consuming application, +//! not in this library. Use [`generic::GenericGraphQlPlugin`](crate::adapters::graphql_plugins::generic::GenericGraphQlPlugin) to build them. -pub mod jobber; +pub mod generic; diff --git a/crates/stygian-graph/src/adapters/graphql_throttle.rs b/crates/stygian-graph/src/adapters/graphql_throttle.rs new file mode 100644 index 0000000..8aea25d --- /dev/null +++ b/crates/stygian-graph/src/adapters/graphql_throttle.rs @@ -0,0 +1,509 @@ +//! Proactive GraphQL cost-throttle management. +//! +//! `LiveBudget` tracks the rolling point budget advertised by APIs that +//! implement the Shopify / Jobber-style cost-throttle extension envelope: +//! +//! ```json +//! { "extensions": { "cost": { +//! "requestedQueryCost": 12, +//! "actualQueryCost": 12, +//! "throttleStatus": { +//! "maximumAvailable": 10000.0, +//! "currentlyAvailable": 9988.0, +//! "restoreRate": 500.0 +//! } +//! }}} +//! ``` +//! +//! Before each request a *proactive* pre-flight delay is computed: if the +//! projected available budget (accounting for elapsed restore time and +//! in-flight reservations) will be too low, the caller sleeps until it +//! recovers. After the delay, [`pre_flight_reserve`] atomically reserves an +//! estimated cost against the budget so concurrent callers immediately see a +//! reduced balance. Call [`release_reservation`] on every exit path (success +//! and error) to keep the pending balance accurate. This eliminates wasted +//! requests that would otherwise return `THROTTLED`. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde_json::Value; +use tokio::sync::Mutex; + +/// Re-export from the ports layer — the canonical definition lives there. +pub use crate::ports::graphql_plugin::CostThrottleConfig; + +// ───────────────────────────────────────────────────────────────────────────── +// LiveBudget +// ───────────────────────────────────────────────────────────────────────────── + +/// Mutable runtime state tracking the current point budget. +/// +/// One `LiveBudget` should be shared across all requests to the same plugin +/// endpoint, wrapped in `Arc>` to serialise updates. +#[derive(Debug)] +pub struct LiveBudget { + currently_available: f64, + maximum_available: f64, + restore_rate: f64, // points/second + last_updated: Instant, + /// Points reserved for requests currently in-flight. + pending: f64, +} + +impl LiveBudget { + /// Create a new budget initialised from `config` defaults. + #[must_use] + pub fn new(config: &CostThrottleConfig) -> Self { + Self { + currently_available: config.max_points, + maximum_available: config.max_points, + restore_rate: config.restore_per_sec, + last_updated: Instant::now(), + pending: 0.0, + } + } + + /// Update the budget from a throttle-status object. + /// + /// The JSON path is `extensions.cost.throttleStatus` in the GraphQL response body. + /// + /// # Example + /// + /// ```rust + /// use serde_json::json; + /// use stygian_graph::adapters::graphql_throttle::{CostThrottleConfig, LiveBudget}; + /// + /// let config = CostThrottleConfig::default(); + /// let mut budget = LiveBudget::new(&config); + /// + /// let status = json!({ + /// "maximumAvailable": 10000.0, + /// "currentlyAvailable": 4200.0, + /// "restoreRate": 500.0, + /// }); + /// budget.update_from_response(&status); + /// ``` + pub fn update_from_response(&mut self, throttle_status: &Value) { + if let Some(max) = throttle_status["maximumAvailable"].as_f64() { + self.maximum_available = max; + } + if let Some(cur) = throttle_status["currentlyAvailable"].as_f64() { + self.currently_available = cur; + } + if let Some(rate) = throttle_status["restoreRate"].as_f64() { + self.restore_rate = rate; + } + self.last_updated = Instant::now(); + } + + /// Compute the projected available budget accounting for elapsed restore + /// time and in-flight reservations. + fn projected_available(&self) -> f64 { + let elapsed = self.last_updated.elapsed().as_secs_f64(); + let restored = elapsed * self.restore_rate; + let gross = (self.currently_available + restored).min(self.maximum_available); + (gross - self.pending).max(0.0) + } + + /// Reserve `cost` points for an in-flight request. + fn reserve(&mut self, cost: f64) { + self.pending += cost; + } + + /// Release a previous [`reserve`] once the request has completed. + fn release(&mut self, cost: f64) { + self.pending = (self.pending - cost).max(0.0); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-plugin budget store +// ───────────────────────────────────────────────────────────────────────────── + +/// A shareable, cheaply-cloneable handle to a per-plugin `LiveBudget`. +/// +/// Create one per registered plugin and pass it to [`pre_flight_delay`] before +/// each request. +/// +/// # Example +/// +/// ```rust +/// use stygian_graph::adapters::graphql_throttle::{CostThrottleConfig, PluginBudget}; +/// +/// let budget = PluginBudget::new(CostThrottleConfig::default()); +/// let budget2 = budget.clone(); // cheap Arc clone +/// ``` +#[derive(Clone, Debug)] +pub struct PluginBudget { + inner: Arc>, + config: CostThrottleConfig, +} + +impl PluginBudget { + /// Create a new `PluginBudget` initialised from `config`. + #[must_use] + pub fn new(config: CostThrottleConfig) -> Self { + let budget = LiveBudget::new(&config); + Self { + inner: Arc::new(Mutex::new(budget)), + config, + } + } + + /// Return the `CostThrottleConfig` this budget was initialised from. + #[must_use] + pub const fn config(&self) -> &CostThrottleConfig { + &self.config + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/// Sleep if the projected budget is too low, then atomically reserve an +/// estimated cost for the upcoming request. +/// +/// Returns the reserved point amount. **Every** exit path after this call — +/// both success and error — must call [`release_reservation`] with the returned +/// value to prevent the pending balance growing indefinitely. +/// +/// The `Mutex` guard is released before the `.await` to satisfy `Send` bounds. +/// +/// # Example +/// +/// ```rust +/// use stygian_graph::adapters::graphql_throttle::{ +/// CostThrottleConfig, PluginBudget, pre_flight_reserve, release_reservation, +/// }; +/// +/// # async fn example() { +/// let budget = PluginBudget::new(CostThrottleConfig::default()); +/// let reserved = pre_flight_reserve(&budget).await; +/// // ... send the request ... +/// release_reservation(&budget, reserved).await; +/// # } +/// ``` +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +pub async fn pre_flight_reserve(budget: &PluginBudget) -> f64 { + let estimated_cost = budget.config.estimated_cost_per_request; + let delay = { + let mut guard = budget.inner.lock().await; + let projected = guard.projected_available(); + let rate = guard.restore_rate.max(1.0); + let min = budget.config.min_available; + let delay = if projected < min + estimated_cost { + let deficit = (min + estimated_cost) - projected; + let secs = (deficit / rate) * 1.1; + let ms = (secs * 1_000.0) as u64; + Some(Duration::from_millis(ms.min(budget.config.max_delay_ms))) + } else { + None + }; + // Reserve while the lock is held so concurrent callers immediately + // see the reduced projected balance. + guard.reserve(estimated_cost); + delay + }; + + if let Some(d) = delay { + tracing::debug!( + delay_ms = d.as_millis(), + "graphql throttle: pre-flight delay" + ); + tokio::time::sleep(d).await; + } + + estimated_cost +} + +/// Release a reservation made by [`pre_flight_reserve`]. +/// +/// Must be called on every exit path after [`pre_flight_reserve`] — both +/// success and error — to keep the pending balance accurate. On the success +/// path, call [`update_budget`] first so the live balance is reconciled from +/// the server-reported `currentlyAvailable` before the reservation is removed. +/// +/// # Example +/// +/// ```rust +/// use stygian_graph::adapters::graphql_throttle::{ +/// CostThrottleConfig, PluginBudget, pre_flight_reserve, release_reservation, +/// }; +/// +/// # async fn example() { +/// let budget = PluginBudget::new(CostThrottleConfig::default()); +/// let reserved = pre_flight_reserve(&budget).await; +/// release_reservation(&budget, reserved).await; +/// # } +/// ``` +pub async fn release_reservation(budget: &PluginBudget, cost: f64) { + let mut guard = budget.inner.lock().await; + guard.release(cost); +} + +/// Update the `PluginBudget` from a completed response body. +/// +/// Extracts `extensions.cost.throttleStatus` if present and forwards to +/// [`LiveBudget::update_from_response`]. +/// +/// # Example +/// +/// ```rust +/// use serde_json::json; +/// use stygian_graph::adapters::graphql_throttle::{CostThrottleConfig, PluginBudget, update_budget}; +/// +/// # async fn example() { +/// let budget = PluginBudget::new(CostThrottleConfig::default()); +/// let response = json!({ +/// "data": {}, +/// "extensions": { "cost": { "throttleStatus": { +/// "maximumAvailable": 10000.0, +/// "currentlyAvailable": 8000.0, +/// "restoreRate": 500.0, +/// }}} +/// }); +/// update_budget(&budget, &response).await; +/// # } +/// ``` +pub async fn update_budget(budget: &PluginBudget, response_body: &Value) { + let Some(status) = response_body.pointer("/extensions/cost/throttleStatus") else { + return; + }; + if status.is_object() { + let mut guard = budget.inner.lock().await; + guard.update_from_response(status); + } +} + +/// Compute the reactive back-off delay from a throttle response body. +/// +/// Use this when `extensions.cost.throttleStatus` signals `THROTTLED` rather +/// than projecting from the `LiveBudget`. +/// +/// ```text +/// deficit = max_available − currently_available +/// base_ms = deficit / restore_rate * 1100 +/// ms = (base_ms * 1.5^attempt).clamp(500, max_delay_ms) +/// ``` +/// +/// # Example +/// +/// ```rust +/// use serde_json::json; +/// use stygian_graph::adapters::graphql_throttle::{CostThrottleConfig, reactive_backoff_ms}; +/// +/// let config = CostThrottleConfig::default(); +/// let body = json!({ "extensions": { "cost": { "throttleStatus": { +/// "maximumAvailable": 10000.0, +/// "currentlyAvailable": 0.0, +/// "restoreRate": 500.0, +/// }}}}); +/// let ms = reactive_backoff_ms(&config, &body, 0); +/// assert!(ms >= 500); +/// ``` +#[must_use] +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_possible_wrap +)] +pub fn reactive_backoff_ms(config: &CostThrottleConfig, body: &Value, attempt: u32) -> u64 { + let status = body.pointer("/extensions/cost/throttleStatus"); + let max_avail = status + .and_then(|s| s.get("maximumAvailable")) + .and_then(Value::as_f64) + .unwrap_or(config.max_points); + let cur_avail = status + .and_then(|s| s.get("currentlyAvailable")) + .and_then(Value::as_f64) + .unwrap_or(0.0); + let restore_rate = status + .and_then(|s| s.get("restoreRate")) + .and_then(Value::as_f64) + .unwrap_or(config.restore_per_sec) + .max(1.0); + let deficit = (max_avail - cur_avail).max(0.0); + let base_secs = if deficit > 0.0 { + (deficit / restore_rate) * 1.1 + } else { + 0.5 + }; + let backoff = base_secs * 1.5_f64.powi(attempt as i32); + let ms = (backoff * 1_000.0) as u64; + ms.clamp(500, config.max_delay_ms) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow( + clippy::float_cmp, + clippy::unwrap_used, + clippy::significant_drop_tightening +)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn live_budget_initialises_from_config() { + let config = CostThrottleConfig { + max_points: 5_000.0, + restore_per_sec: 250.0, + min_available: 50.0, + max_delay_ms: 10_000, + estimated_cost_per_request: 100.0, + }; + let budget = LiveBudget::new(&config); + assert_eq!(budget.currently_available, 5_000.0); + assert_eq!(budget.maximum_available, 5_000.0); + assert_eq!(budget.restore_rate, 250.0); + } + + #[test] + fn live_budget_updates_from_response() { + let config = CostThrottleConfig::default(); + let mut budget = LiveBudget::new(&config); + + let status = json!({ + "maximumAvailable": 10_000.0, + "currentlyAvailable": 3_000.0, + "restoreRate": 500.0, + }); + budget.update_from_response(&status); + + assert_eq!(budget.currently_available, 3_000.0); + assert_eq!(budget.maximum_available, 10_000.0); + } + + #[test] + fn projected_available_accounts_for_restore() { + let config = CostThrottleConfig { + max_points: 10_000.0, + restore_per_sec: 1_000.0, // fast restore for test + ..Default::default() + }; + let mut budget = LiveBudget::new(&config); + // Simulate a low budget + budget.currently_available = 0.0; + // Immediately after update, projected = 0 + small_elapsed * 1000 + // which is ~ 0 (sub-millisecond). Just confirm it doesn't panic. + let p = budget.projected_available(); + assert!(p >= 0.0); + assert!(p <= 10_000.0); + } + + #[test] + fn projected_available_caps_at_maximum() { + let config = CostThrottleConfig::default(); + let budget = LiveBudget::new(&config); + // Fresh budget is already at maximum + assert!(budget.projected_available() <= budget.maximum_available); + } + + #[tokio::test] + async fn pre_flight_reserve_does_not_sleep_when_budget_healthy() { + let budget = PluginBudget::new(CostThrottleConfig::default()); + // Budget starts full — no delay expected. + let before = Instant::now(); + let reserved = pre_flight_reserve(&budget).await; + assert!(before.elapsed().as_millis() < 100, "unexpected delay"); + assert_eq!( + reserved, + CostThrottleConfig::default().estimated_cost_per_request + ); + release_reservation(&budget, reserved).await; + } + + #[tokio::test] + async fn update_budget_parses_throttle_status() { + let budget = PluginBudget::new(CostThrottleConfig::default()); + let response = json!({ + "data": {}, + "extensions": { "cost": { "throttleStatus": { + "maximumAvailable": 10_000.0, + "currentlyAvailable": 2_500.0, + "restoreRate": 500.0, + }}} + }); + update_budget(&budget, &response).await; + let guard = budget.inner.lock().await; + assert_eq!(guard.currently_available, 2_500.0); + } + + #[tokio::test] + async fn concurrent_reservations_reduce_projected_available() { + let config = CostThrottleConfig { + max_points: 1_000.0, + estimated_cost_per_request: 200.0, + ..Default::default() + }; + let budget = PluginBudget::new(config); + + // Each pre_flight_reserve atomically deducts from pending, so the + // second caller sees a lower projected balance than the first. + let r1 = pre_flight_reserve(&budget).await; + let r2 = pre_flight_reserve(&budget).await; + + { + let guard = budget.inner.lock().await; + // Two reservations of 200 → pending = 400 + assert!((guard.pending - 400.0).abs() < f64::EPSILON); + // projected = 1000 - 400 = 600 (approximately, ignoring sub-ms restore) + let projected = guard.projected_available(); + assert!((599.0..=601.0).contains(&projected)); + } + + release_reservation(&budget, r1).await; + release_reservation(&budget, r2).await; + + let guard = budget.inner.lock().await; + assert!(guard.pending < f64::EPSILON); + } + + #[test] + fn reactive_backoff_ms_clamps_to_500ms_floor() { + let config = CostThrottleConfig::default(); + let body = json!({ "extensions": { "cost": { "throttleStatus": { + "maximumAvailable": 10_000.0, + "currentlyAvailable": 9_999.0, + "restoreRate": 500.0, + }}}}); + let ms = reactive_backoff_ms(&config, &body, 0); + assert_eq!(ms, 500); // Very small deficit rounds up to floor + } + + #[test] + fn reactive_backoff_ms_increases_with_attempt() { + let config = CostThrottleConfig::default(); + let body = json!({ "extensions": { "cost": { "throttleStatus": { + "maximumAvailable": 10_000.0, + "currentlyAvailable": 5_000.0, + "restoreRate": 500.0, + }}}}); + let ms0 = reactive_backoff_ms(&config, &body, 0); + let ms1 = reactive_backoff_ms(&config, &body, 1); + let ms2 = reactive_backoff_ms(&config, &body, 2); + assert!(ms1 > ms0); + assert!(ms2 > ms1); + } + + #[test] + fn reactive_backoff_ms_caps_at_max_delay() { + let config = CostThrottleConfig { + max_delay_ms: 1_000, + ..Default::default() + }; + let body = json!({ "extensions": { "cost": { "throttleStatus": { + "maximumAvailable": 10_000.0, + "currentlyAvailable": 0.0, + "restoreRate": 1.0, // very slow restore → huge deficit + }}}}); + let ms = reactive_backoff_ms(&config, &body, 10); + assert_eq!(ms, 1_000); + } +} diff --git a/crates/stygian-graph/src/ports.rs b/crates/stygian-graph/src/ports.rs index bc35118..70c2eed 100644 --- a/crates/stygian-graph/src/ports.rs +++ b/crates/stygian-graph/src/ports.rs @@ -757,3 +757,6 @@ pub mod wasm_plugin; /// Storage port — persist and retrieve pipeline results pub mod storage; + +/// Auth port — runtime token loading, expiry checking, and refresh. +pub mod auth; diff --git a/crates/stygian-graph/src/ports/auth.rs b/crates/stygian-graph/src/ports/auth.rs new file mode 100644 index 0000000..3db370b --- /dev/null +++ b/crates/stygian-graph/src/ports/auth.rs @@ -0,0 +1,468 @@ +//! `AuthPort` — runtime token loading, expiry checking, and refresh. +//! +//! Implement this trait to inject live credentials into pipeline execution +//! without pre-loading a static token. Designed to integrate with +//! `stygian-browser`'s OAuth2 PKCE token store. + +use std::future::Future; +use std::time::{Duration, SystemTime}; + +use async_trait::async_trait; + +use crate::domain::error::{ServiceError, StygianError}; + +// ───────────────────────────────────────────────────────────────────────────── +// Token +// ───────────────────────────────────────────────────────────────────────────── + +/// A resolved `OAuth2` / API bearer token with optional expiry metadata. +/// +/// `TokenSet` deliberately does **not** implement `Display` — only `Debug` — +/// to prevent accidental log or format-string leakage of access tokens. +/// +/// # Example +/// +/// ```rust +/// use stygian_graph::ports::auth::TokenSet; +/// use std::time::{SystemTime, Duration}; +/// +/// let ts = TokenSet { +/// access_token: "tok_abc123".to_string(), +/// refresh_token: Some("ref_xyz".to_string()), +/// expires_at: SystemTime::now().checked_add(Duration::from_secs(3600)), +/// scopes: vec!["read:user".to_string()], +/// }; +/// assert!(!ts.is_expired()); +/// ``` +#[derive(Debug, Clone)] +pub struct TokenSet { + /// Bearer token to inject into requests + pub access_token: String, + /// Refresh token (may be absent for non-OAuth2 API keys) + pub refresh_token: Option, + /// Absolute expiry time; `None` means the token does not expire + pub expires_at: Option, + /// `OAuth2` scopes granted to this token + pub scopes: Vec, +} + +impl TokenSet { + /// Returns `true` if the token has expired (with a 60-second safety margin). + /// + /// A token without an `expires_at` is considered perpetually valid. + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::ports::auth::TokenSet; + /// use std::time::{SystemTime, Duration}; + /// + /// let expired = TokenSet { + /// access_token: "tok".to_string(), + /// refresh_token: None, + /// expires_at: SystemTime::now().checked_sub(Duration::from_secs(300)), + /// scopes: vec![], + /// }; + /// assert!(expired.is_expired()); + /// ``` + #[must_use] + pub fn is_expired(&self) -> bool { + let Some(exp) = self.expires_at else { + return false; + }; + let threshold = SystemTime::now() + .checked_add(Duration::from_secs(60)) + .unwrap_or(SystemTime::UNIX_EPOCH); + exp <= threshold + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Error +// ───────────────────────────────────────────────────────────────────────────── + +/// Errors produced by [`AuthPort`] implementations. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + /// No token is stored; the user must complete the auth flow first. + #[error("no token found — please run the auth flow")] + TokenNotFound, + + /// The stored token has expired and could not be refreshed. + #[error("token expired")] + TokenExpired, + + /// The refresh request failed. + #[error("token refresh failed: {0}")] + RefreshFailed(String), + + /// The token store could not be read or written. + #[error("token storage failed: {0}")] + StorageFailed(String), + + /// The PKCE / interactive auth flow failed. + #[error("auth flow failed: {0}")] + AuthFlowFailed(String), + + /// The token was present but malformed. + #[error("invalid token: {0}")] + InvalidToken(String), +} + +impl From for StygianError { + fn from(e: AuthError) -> Self { + Self::Service(ServiceError::AuthenticationFailed(e.to_string())) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Trait +// ───────────────────────────────────────────────────────────────────────────── + +/// Port for runtime credential management. +/// +/// Implement this trait to supply live tokens to pipeline execution. +/// `stygian-browser`'s encrypted disk token store is the primary reference +/// implementation, but in-memory and environment-variable backed variants +/// are also common. +/// +/// The trait uses native `async fn` in traits (Rust 2024 edition) so it is +/// *not* object-safe. Use `Arc` or generics rather than +/// `Arc`. +/// +/// # Example implementation (in-memory) +/// +/// ```rust +/// use stygian_graph::ports::auth::{AuthPort, AuthError, TokenSet}; +/// +/// struct StaticTokenAuth { token: String } +/// +/// impl AuthPort for StaticTokenAuth { +/// async fn load_token(&self) -> std::result::Result, AuthError> { +/// Ok(Some(TokenSet { +/// access_token: self.token.clone(), +/// refresh_token: None, +/// expires_at: None, +/// scopes: vec![], +/// })) +/// } +/// async fn refresh_token(&self) -> std::result::Result { +/// Err(AuthError::TokenNotFound) +/// } +/// } +/// ``` +pub trait AuthPort: Send + Sync { + /// Load the current token from the backing store. + /// + /// Returns `Ok(None)` if no token has been stored yet. + /// + /// # Errors + /// + /// Returns [`AuthError::StorageFailed`] if the backing store is unavailable. + fn load_token( + &self, + ) -> impl Future, AuthError>> + Send; + + /// Obtain a fresh token by exchanging the stored refresh token with the + /// authorization server, then persist it. + /// + /// Implementations should persist the refreshed token before returning so + /// that concurrent callers get a consistent view. + /// + /// # Errors + /// + /// Returns [`AuthError::RefreshFailed`] when the token endpoint rejects the + /// request, or [`AuthError::TokenNotFound`] when no refresh token is + /// available. + fn refresh_token( + &self, + ) -> impl Future> + Send; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helper +// ───────────────────────────────────────────────────────────────────────────── + +/// Resolve a live access-token string from an `AuthPort`. +/// +/// 1. Calls `load_token()`. +/// 2. If the token is expired, calls `refresh_token()`. +/// 3. Returns the raw access token string, ready to be injected into a request. +/// +/// # Errors +/// +/// Returns `Err` if no token exists, storage is unavailable, or refresh fails. +/// +/// # Example +/// +/// ```rust +/// # use stygian_graph::ports::auth::{AuthPort, AuthError, TokenSet, resolve_token}; +/// # struct Env; +/// # impl AuthPort for Env { +/// # async fn load_token(&self) -> std::result::Result, AuthError> { +/// # Ok(Some(TokenSet { access_token: "abc".to_string(), refresh_token: None, expires_at: None, scopes: vec![] })) +/// # } +/// # async fn refresh_token(&self) -> std::result::Result { Err(AuthError::TokenNotFound) } +/// # } +/// # async fn run() -> std::result::Result { +/// let auth = Env; +/// let token = resolve_token(&auth).await?; +/// println!("Bearer {token}"); +/// # Ok(token) +/// # } +/// ``` +pub async fn resolve_token(port: &impl AuthPort) -> std::result::Result { + let ts = port.load_token().await?.ok_or(AuthError::TokenNotFound)?; + + if ts.is_expired() { + let refreshed = port.refresh_token().await?; + return Ok(refreshed.access_token); + } + + Ok(ts.access_token) +} + +// ───────────────────────────────────────────────────────────────────────────── +// ErasedAuthPort — object-safe wrapper for use with Arc +// ───────────────────────────────────────────────────────────────────────────── + +/// Object-safe version of [`AuthPort`] for runtime dispatch. +/// +/// [`AuthPort`] uses native `async fn in trait` (Rust 2024) and is NOT +/// object-safe. `ErasedAuthPort` wraps the same logic via `async_trait`, +/// producing `Pin>` return types that `Arc` requires. +/// +/// A blanket `impl ErasedAuthPort for T` is provided — you never +/// need to implement this trait directly. +/// +/// # Example +/// +/// ```rust +/// use std::sync::Arc; +/// use stygian_graph::ports::auth::{ErasedAuthPort, EnvAuthPort}; +/// +/// let port: Arc = Arc::new(EnvAuthPort::new("GITHUB_TOKEN")); +/// // Pass `port` to GraphQlService::with_auth_port(port) +/// ``` +#[async_trait] +pub trait ErasedAuthPort: Send + Sync { + /// Resolve a live access-token string — load, check expiry, refresh if needed. + /// + /// # Errors + /// + /// Returns `Err` if no token exists, storage is unavailable, or refresh fails. + async fn erased_resolve_token(&self) -> std::result::Result; +} + +#[async_trait] +impl ErasedAuthPort for T { + async fn erased_resolve_token(&self) -> std::result::Result { + resolve_token(self).await + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// EnvAuthPort — convenience impl backed by an environment variable +// ───────────────────────────────────────────────────────────────────────────── + +/// An [`AuthPort`] that reads a static token from an environment variable. +/// +/// Tokens from environment variables never expire; `refresh_token` always +/// returns [`AuthError::TokenNotFound`]. +/// +/// # Example +/// +/// ```rust +/// use stygian_graph::ports::auth::EnvAuthPort; +/// +/// let auth = EnvAuthPort::new("GITHUB_TOKEN"); +/// // At pipeline execution time, `load_token()` will read $GITHUB_TOKEN. +/// ``` +pub struct EnvAuthPort { + var_name: String, +} + +impl EnvAuthPort { + /// Create an `EnvAuthPort` that will read `var_name` from the environment + /// at token-load time. + /// + /// # Example + /// + /// ```rust + /// use stygian_graph::ports::auth::EnvAuthPort; + /// + /// let auth = EnvAuthPort::new("GITHUB_TOKEN"); + /// ``` + #[must_use] + pub fn new(var_name: impl Into) -> Self { + Self { + var_name: var_name.into(), + } + } +} + +impl AuthPort for EnvAuthPort { + async fn load_token(&self) -> std::result::Result, AuthError> { + match std::env::var(&self.var_name) { + Ok(token) if !token.is_empty() => Ok(Some(TokenSet { + access_token: token, + refresh_token: None, + expires_at: None, + scopes: vec![], + })), + Ok(_) | Err(_) => Ok(None), + } + } + + async fn refresh_token(&self) -> std::result::Result { + // Static env-var tokens don't support refresh. + Err(AuthError::TokenNotFound) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, unsafe_code)] // env::set_var / remove_var are unsafe in Rust ≥1.79 + use super::*; + + struct FixedToken(String); + + impl AuthPort for FixedToken { + async fn load_token(&self) -> std::result::Result, AuthError> { + Ok(Some(TokenSet { + access_token: self.0.clone(), + refresh_token: None, + expires_at: None, + scopes: vec![], + })) + } + + async fn refresh_token(&self) -> std::result::Result { + Err(AuthError::RefreshFailed("no refresh token".to_string())) + } + } + + struct NoToken; + + impl AuthPort for NoToken { + async fn load_token(&self) -> std::result::Result, AuthError> { + Ok(None) + } + + async fn refresh_token(&self) -> std::result::Result { + Err(AuthError::TokenNotFound) + } + } + + struct ExpiredToken { + new_token: String, + } + + impl AuthPort for ExpiredToken { + async fn load_token(&self) -> std::result::Result, AuthError> { + Ok(Some(TokenSet { + access_token: "old_token".to_string(), + refresh_token: Some("ref".to_string()), + expires_at: SystemTime::now().checked_sub(Duration::from_secs(3600)), + scopes: vec![], + })) + } + + async fn refresh_token(&self) -> std::result::Result { + Ok(TokenSet { + access_token: self.new_token.clone(), + refresh_token: None, + expires_at: None, + scopes: vec![], + }) + } + } + + #[test] + fn not_expired_when_no_expiry() { + let ts = TokenSet { + access_token: "tok".to_string(), + refresh_token: None, + expires_at: None, + scopes: vec![], + }; + assert!(!ts.is_expired()); + } + + #[test] + fn expired_when_past_expiry() { + let ts = TokenSet { + access_token: "tok".to_string(), + refresh_token: None, + expires_at: SystemTime::now().checked_sub(Duration::from_secs(300)), + scopes: vec![], + }; + assert!(ts.is_expired()); + } + + #[test] + fn not_expired_within_60s_margin() { + // Expires in 30s — within the 60s safety margin, so treated as expired. + let ts = TokenSet { + access_token: "tok".to_string(), + refresh_token: None, + expires_at: SystemTime::now().checked_add(Duration::from_secs(30)), + scopes: vec![], + }; + assert!(ts.is_expired()); + } + + #[test] + fn not_expired_outside_60s_margin() { + let ts = TokenSet { + access_token: "tok".to_string(), + refresh_token: None, + expires_at: SystemTime::now().checked_add(Duration::from_secs(120)), + scopes: vec![], + }; + assert!(!ts.is_expired()); + } + + #[tokio::test] + async fn resolve_token_returns_access_token() { + let auth = FixedToken("tok_abc".to_string()); + let token = resolve_token(&auth).await.unwrap(); + assert_eq!(token, "tok_abc"); + } + + #[tokio::test] + async fn resolve_token_returns_err_when_no_token() { + let auth = NoToken; + assert!(resolve_token(&auth).await.is_err()); + } + + #[tokio::test] + async fn resolve_token_refreshes_when_expired() { + let auth = ExpiredToken { + new_token: "fresh_tok".to_string(), + }; + let token = resolve_token(&auth).await.unwrap(); + assert_eq!(token, "fresh_tok"); + } + + #[tokio::test] + async fn env_auth_port_loads_from_env() { + // Safety: test-only env mutation under #[tokio::test] + unsafe { std::env::set_var("_STYGIAN_TEST_TOKEN_1", "env_tok_xyz") }; + let auth = EnvAuthPort::new("_STYGIAN_TEST_TOKEN_1"); + let token = resolve_token(&auth).await.unwrap(); + assert_eq!(token, "env_tok_xyz"); + unsafe { std::env::remove_var("_STYGIAN_TEST_TOKEN_1") }; + } + + #[tokio::test] + async fn env_auth_port_returns_none_when_unset() { + let auth = EnvAuthPort::new("_STYGIAN_TEST_MISSING_VAR_9999"); + let ts = auth.load_token().await.unwrap(); + assert!(ts.is_none()); + } +} diff --git a/crates/stygian-graph/src/ports/graphql_plugin.rs b/crates/stygian-graph/src/ports/graphql_plugin.rs index cb9d313..03bd2c7 100644 --- a/crates/stygian-graph/src/ports/graphql_plugin.rs +++ b/crates/stygian-graph/src/ports/graphql_plugin.rs @@ -10,6 +10,69 @@ use std::collections::HashMap; use crate::ports::GraphQlAuth; +// ───────────────────────────────────────────────────────────────────────────── +// CostThrottleConfig +// ───────────────────────────────────────────────────────────────────────────── + +/// Static cost-throttle parameters for a GraphQL API target. +/// +/// Set these to match the API documentation. After the first successful +/// response the [`LiveBudget`](crate::adapters::graphql_throttle::LiveBudget) +/// will update itself from the `extensions.cost.throttleStatus` envelope. +/// +/// # Example +/// +/// ```rust +/// use stygian_graph::ports::graphql_plugin::CostThrottleConfig; +/// +/// let config = CostThrottleConfig { +/// max_points: 10_000.0, +/// restore_per_sec: 500.0, +/// min_available: 50.0, +/// max_delay_ms: 30_000, +/// estimated_cost_per_request: 100.0, +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct CostThrottleConfig { + /// Maximum point budget (e.g. `10_000.0` for Jobber / Shopify). + pub max_points: f64, + /// Points restored per second (e.g. `500.0`). + pub restore_per_sec: f64, + /// Minimum available points before a pre-flight delay is applied + /// (default: `50.0`). + pub min_available: f64, + /// Upper bound on any computed pre-flight delay in milliseconds + /// (default: `30_000`). + pub max_delay_ms: u64, + /// Pessimistic per-request cost reserved before each request is sent. + /// + /// The actual cost is only known from the response's + /// `extensions.cost.requestedQueryCost`. Reserving this estimate before + /// sending prevents concurrent tasks from all passing the pre-flight check + /// against the same stale balance. Tune this to match your API's typical + /// query cost (default: `100.0`). + /// + // TODO(async-drop): When `AsyncDrop` is stabilised on the stable toolchain + // (tracked at ), replace + // the explicit `release_reservation` call sites in `graphql.rs` with a + // `BudgetReservation` RAII guard, eliminating manual cleanup at every + // early-return path. + pub estimated_cost_per_request: f64, +} + +impl Default for CostThrottleConfig { + fn default() -> Self { + Self { + max_points: 10_000.0, + restore_per_sec: 500.0, + min_available: 50.0, + max_delay_ms: 30_000, + estimated_cost_per_request: 100.0, + } + } +} + /// A named GraphQL target that supplies connection defaults for a specific API. /// /// Plugins are identified by their [`name`](Self::name) and loaded from the @@ -111,6 +174,34 @@ pub trait GraphQlTargetPlugin: Send + Sync { fn description(&self) -> &str { "" } + + /// Optional cost-throttle configuration for proactive pre-flight delays. + /// + /// Return a populated [`CostThrottleConfig`] to enable the + /// [`PluginBudget`](crate::adapters::graphql_throttle::PluginBudget) + /// pre-flight delay mechanism in `GraphQlService`. + /// + /// The default implementation returns `None` (no proactive throttling). + /// + /// # Example + /// + /// ```rust + /// use std::collections::HashMap; + /// use stygian_graph::ports::graphql_plugin::{GraphQlTargetPlugin, CostThrottleConfig}; + /// use stygian_graph::ports::GraphQlAuth; + /// + /// struct ThrottledPlugin; + /// impl GraphQlTargetPlugin for ThrottledPlugin { + /// fn name(&self) -> &str { "throttled" } + /// fn endpoint(&self) -> &str { "https://api.example.com/graphql" } + /// fn cost_throttle_config(&self) -> Option { + /// Some(CostThrottleConfig::default()) + /// } + /// } + /// ``` + fn cost_throttle_config(&self) -> Option { + None + } } #[cfg(test)] diff --git a/crates/stygian-graph/tests/jobber_integration.rs b/crates/stygian-graph/tests/jobber_integration.rs deleted file mode 100644 index 5c8ffda..0000000 --- a/crates/stygian-graph/tests/jobber_integration.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Integration tests for the Jobber GraphQL plugin. -//! -//! These tests hit the live Jobber API and require `JOBBER_ACCESS_TOKEN` to be -//! set in the environment. They are intentionally `#[ignore]`-gated so they -//! never run in CI unless explicitly invoked: -//! -//! ```bash -//! JOBBER_ACCESS_TOKEN= cargo test --test jobber_integration -- --ignored -//! ``` - -#![allow(clippy::expect_used, clippy::needless_raw_string_hashes)] - -use stygian_graph::adapters::graphql_plugins::jobber::JobberPlugin; -use stygian_graph::ports::graphql_plugin::GraphQlTargetPlugin; - -/// Smoke-test the plugin's static metadata without any network calls. -#[test] -fn jobber_plugin_metadata() { - let plugin = JobberPlugin::new(); - assert_eq!(plugin.name(), "jobber"); - assert_eq!(plugin.endpoint(), "https://api.getjobber.com/api/graphql"); - - let headers = plugin.version_headers(); - assert_eq!( - headers.get("X-JOBBER-GRAPHQL-VERSION").map(String::as_str), - Some("2025-04-16") - ); - assert!(plugin.supports_cursor_pagination()); - assert_eq!(plugin.default_page_size(), 50); - assert!(!plugin.description().is_empty()); -} - -/// Verify that the clients query reaches the live Jobber API and returns data. -/// -/// Run with: -/// ```bash -/// JOBBER_ACCESS_TOKEN= cargo test --test jobber_integration -- --ignored -/// ``` -#[tokio::test] -#[ignore = "requires JOBBER_ACCESS_TOKEN env var"] -async fn test_jobber_clients_returns_data() { - let token = std::env::var("JOBBER_ACCESS_TOKEN") - .expect("JOBBER_ACCESS_TOKEN must be set to run integration tests"); - - let client = reqwest::Client::new(); - - let query = serde_json::json!({ - "operationName": "ListClients", - "query": r#" - query ListClients($first: Int) { - clients(first: $first) { - edges { - node { id name } - } - pageInfo { hasNextPage endCursor } - } - } - "#, - "variables": { "first": 5 } - }); - - let response = client - .post(JobberPlugin::new().endpoint()) - .bearer_auth(&token) - .header("X-JOBBER-GRAPHQL-VERSION", "2025-04-16") - .header("Content-Type", "application/json") - .json(&query) - .send() - .await - .expect("HTTP request should succeed"); - - assert!( - response.status().is_success(), - "Expected 200 OK, got {}", - response.status() - ); - - let body: serde_json::Value = response - .json() - .await - .expect("Response should be valid JSON"); - - // Assert we got a data payload, not just errors - assert!( - body.get("errors").is_none(), - "Unexpected GraphQL errors: {body:#?}" - ); - - let edges = body - .pointer("/data/clients/edges") - .expect("Response must contain data.clients.edges"); - - assert!( - edges.as_array().is_some_and(|a| !a.is_empty()), - "Expected at least one client edge, got: {edges:#?}" - ); -} diff --git a/examples/pipelines/github/README.md b/examples/pipelines/github/README.md new file mode 100644 index 0000000..dda5919 --- /dev/null +++ b/examples/pipelines/github/README.md @@ -0,0 +1,122 @@ +# GitHub GraphQL Pipelines + +Ready-to-run Stygian pipelines for the [GitHub GraphQL API v4](https://docs.github.com/en/graphql). + +## Requirements + +### Environment variables + +| Variable | Required | Purpose | +| --- | --- | --- | +| `GITHUB_TOKEN` | Yes | Personal access token | +| `ANTHROPIC_API_KEY` | Only for `full_sync` | Claude API key for the analysis node | + +### Obtaining a GitHub personal access token + +1. Go to **GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)** +2. Click **Generate new token** +3. Select the following scopes: + - `read:user` — viewer profile queries + - `public_repo` — public repository queries + - `repo` — add this if you also want to read private repositories +4. Copy the generated token and export it: + +```bash +export GITHUB_TOKEN="ghp_..." +``` + +No OAuth app registration or paid subscription required. + +## Running pipelines + +```bash +# Validate a pipeline without executing +stygian check examples/pipelines/github/repositories.toml + +# Fetch all of your own repositories (cursor-paginated) +stygian run examples/pipelines/github/repositories.toml + +# Fetch open issues from rust-lang/rust +stygian run examples/pipelines/github/issues.toml + +# Fetch open pull requests from rust-lang/rust +stygian run examples/pipelines/github/pull_requests.toml + +# Fetch your starred repositories (cursor-paginated) +stygian run examples/pipelines/github/starred.toml + +# Full profile sync with AI analysis (requires ANTHROPIC_API_KEY) +export ANTHROPIC_API_KEY="sk-ant-..." +stygian run examples/pipelines/github/full_sync.toml + +# Introspect the GitHub schema +stygian run examples/pipelines/github/introspect.toml > github_schema.json +``` + +## Targeting a different repository + +The `issues.toml` and `pull_requests.toml` pipelines default to `rust-lang/rust`. +Edit the `[nodes.params.variables]` block to target any public repository: + +```toml +[nodes.params.variables] +owner = "your-org" +name = "your-repo" +``` + +## Pipeline structure + +All pipelines follow this pattern — auth is inline on each node, and the +GitHub endpoint URL is specified directly rather than via a registered plugin: + +```toml +[[services]] +name = "github" +kind = "graphql" + +[[nodes]] +name = "fetch_repositories" +service = "github" +url = "https://api.github.com/graphql" + +[nodes.params] +query = "..." + +[nodes.params.auth] +kind = "bearer" +token = "${env:GITHUB_TOKEN}" # expanded at execution time + +[nodes.params.pagination] +strategy = "cursor" +page_info_path = "data.viewer.repositories.pageInfo" +edges_path = "data.viewer.repositories.edges" +``` + +## DAG pipeline (`full_sync.toml`) + +`full_sync.toml` shows multi-node DAG execution with parallel fetch and a +dependent AI analysis step: + +``` +fetch_viewer ─────┐ + ├──► analyse_profile (Claude) +fetch_repositories┤ + │ +fetch_starred ────┘ +``` + +The three fetch nodes run concurrently. `analyse_profile` only executes once +all upstream nodes have successfully completed, and receives their combined +output as context. + +## Schemas + +The `schemas/` directory contains JSON Schema definitions for the normalised +output of each pipeline: + +| File | Description | +| --- | --- | +| `repository.schema.json` | Owned/starred repository record | +| `issue.schema.json` | GitHub issue record | +| `pull_request.schema.json` | Pull request record | +| `profile_summary.schema.json` | AI-generated developer profile summary | diff --git a/examples/pipelines/github/full_sync.toml b/examples/pipelines/github/full_sync.toml new file mode 100644 index 0000000..84c5a6c --- /dev/null +++ b/examples/pipelines/github/full_sync.toml @@ -0,0 +1,158 @@ +# Full GitHub profile sync with AI analysis. +# +# Executes three data-fetch nodes in parallel (no interdependencies), then +# feeds all results into a Claude analysis node that summarises developer +# activity and open-source contributions. +# +# Required environment variables: +# GITHUB_TOKEN — personal access token (read:user, public_repo) +# ANTHROPIC_API_KEY — Claude API key (for the analyse_profile node) + +[[services]] +name = "github" +kind = "graphql" + +[[services]] +name = "claude" +kind = "claude" +model = "claude-sonnet-4-5" +api_key = "${env:ANTHROPIC_API_KEY}" + +# ─── Parallel fetch layer (no depends_on — all run concurrently) ────────────── + +[[nodes]] +name = "fetch_viewer" +service = "github" +url = "https://api.github.com/graphql" + +[nodes.params] +operation_name = "ViewerProfile" +query = """ +query ViewerProfile { + viewer { + id + login + name + email + bio + company + location + websiteUrl + createdAt + repositories { totalCount } + starredRepositories { totalCount } + followers { totalCount } + following { totalCount } + } +} +""" + +[nodes.params.auth] +kind = "bearer" +token = "${env:GITHUB_TOKEN}" + +# ───────────────────────────────────────────────────────────────────────────── + +[[nodes]] +name = "fetch_repositories" +service = "github" +url = "https://api.github.com/graphql" + +[nodes.params] +operation_name = "ListRepositories" +query = """ +query ListRepositories($first: Int, $after: String) { + viewer { + repositories( + first: $first + after: $after + ownerAffiliations: [OWNER] + orderBy: { field: UPDATED_AT, direction: DESC } + ) { + edges { + node { + id + name + description + url + stargazerCount + forkCount + primaryLanguage { name } + isPrivate + updatedAt + } + } + pageInfo { hasNextPage endCursor } + } + } +} +""" + +[nodes.params.auth] +kind = "bearer" +token = "${env:GITHUB_TOKEN}" + +[nodes.params.pagination] +strategy = "cursor" +page_info_path = "data.viewer.repositories.pageInfo" +edges_path = "data.viewer.repositories.edges" + +# ───────────────────────────────────────────────────────────────────────────── + +[[nodes]] +name = "fetch_starred" +service = "github" +url = "https://api.github.com/graphql" + +[nodes.params] +operation_name = "ListStarred" +query = """ +query ListStarred($first: Int, $after: String) { + viewer { + starredRepositories( + first: $first + after: $after + orderBy: { field: STARRED_AT, direction: DESC } + ) { + edges { + node { + id + nameWithOwner + description + url + stargazerCount + primaryLanguage { name } + } + starredAt + } + pageInfo { hasNextPage endCursor } + } + } +} +""" + +[nodes.params.auth] +kind = "bearer" +token = "${env:GITHUB_TOKEN}" + +[nodes.params.pagination] +strategy = "cursor" +page_info_path = "data.viewer.starredRepositories.pageInfo" +edges_path = "data.viewer.starredRepositories.edges" + +# ─── AI analysis layer (depends on all three fetch nodes) ──────────────────── + +[[nodes]] +name = "analyse_profile" +service = "claude" +depends_on = ["fetch_viewer", "fetch_repositories", "fetch_starred"] + +[nodes.params] +schema_file = "examples/pipelines/github/schemas/profile_summary.schema.json" +prompt = """ +You are a developer-profile analyst. Given the raw GitHub profile data, owned +repositories, and starred repositories below, produce a concise structured +summary of this developer's interests, primary languages, most active projects, +and open-source contribution focus. Emit only valid JSON conforming to the +profile_summary schema. +""" diff --git a/examples/pipelines/github/introspect.toml b/examples/pipelines/github/introspect.toml new file mode 100644 index 0000000..9e533a1 --- /dev/null +++ b/examples/pipelines/github/introspect.toml @@ -0,0 +1,48 @@ +# Introspect the full GitHub GraphQL schema. +# +# The result is written to the output data stream and can be redirected to a +# file for offline exploration: +# +# stygian run examples/pipelines/github/introspect.toml > github_schema.json + +[[services]] +name = "github" +kind = "graphql" + +[[nodes]] +name = "introspect_schema" +service = "github" +url = "https://api.github.com/graphql" + +[nodes.params] +operation_name = "IntrospectSchema" +query = """ +query IntrospectSchema { + __schema { + types { + kind + name + description + fields(includeDeprecated: false) { + name + description + type { + kind + name + ofType { kind name ofType { kind name } } + } + } + inputFields { + name + description + type { kind name ofType { kind name } } + } + enumValues(includeDeprecated: false) { name description } + } + } +} +""" + +[nodes.params.auth] +kind = "bearer" +token = "${env:GITHUB_TOKEN}" diff --git a/examples/pipelines/github/issues.toml b/examples/pipelines/github/issues.toml new file mode 100644 index 0000000..4347925 --- /dev/null +++ b/examples/pipelines/github/issues.toml @@ -0,0 +1,57 @@ +[[services]] +name = "github" +kind = "graphql" + +# Fetch open issues from a public repository. +# +# Change the variables below to target a different repository, or set +# GITHUB_REPO_OWNER / GITHUB_REPO_NAME in your environment and edit the values +# to reference them before running. + +[[nodes]] +name = "fetch_issues" +service = "github" +url = "https://api.github.com/graphql" + +[nodes.params] +operation_name = "ListIssues" +query = """ +query ListIssues($owner: String!, $name: String!, $first: Int, $after: String) { + repository(owner: $owner, name: $name) { + issues( + first: $first + after: $after + states: [OPEN] + orderBy: { field: UPDATED_AT, direction: DESC } + ) { + edges { + node { + id + number + title + url + state + author { login } + labels(first: 10) { nodes { name color } } + createdAt + updatedAt + } + } + pageInfo { hasNextPage endCursor } + } + } +} +""" + +[nodes.params.auth] +kind = "bearer" +token = "${env:GITHUB_TOKEN}" + +[nodes.params.variables] +owner = "rust-lang" +name = "rust" + +[nodes.params.pagination] +strategy = "cursor" +page_info_path = "data.repository.issues.pageInfo" +edges_path = "data.repository.issues.edges" diff --git a/examples/pipelines/github/pull_requests.toml b/examples/pipelines/github/pull_requests.toml new file mode 100644 index 0000000..c89094b --- /dev/null +++ b/examples/pipelines/github/pull_requests.toml @@ -0,0 +1,54 @@ +[[services]] +name = "github" +kind = "graphql" + +[[nodes]] +name = "fetch_pull_requests" +service = "github" +url = "https://api.github.com/graphql" + +[nodes.params] +operation_name = "ListPullRequests" +query = """ +query ListPullRequests($owner: String!, $name: String!, $first: Int, $after: String) { + repository(owner: $owner, name: $name) { + pullRequests( + first: $first + after: $after + states: [OPEN] + orderBy: { field: UPDATED_AT, direction: DESC } + ) { + edges { + node { + id + number + title + url + state + author { login } + additions + deletions + changedFiles + labels(first: 10) { nodes { name color } } + createdAt + updatedAt + } + } + pageInfo { hasNextPage endCursor } + } + } +} +""" + +[nodes.params.auth] +kind = "bearer" +token = "${env:GITHUB_TOKEN}" + +[nodes.params.variables] +owner = "rust-lang" +name = "rust" + +[nodes.params.pagination] +strategy = "cursor" +page_info_path = "data.repository.pullRequests.pageInfo" +edges_path = "data.repository.pullRequests.edges" diff --git a/examples/pipelines/github/repositories.toml b/examples/pipelines/github/repositories.toml new file mode 100644 index 0000000..a0a1a6e --- /dev/null +++ b/examples/pipelines/github/repositories.toml @@ -0,0 +1,48 @@ +[[services]] +name = "github" +kind = "graphql" + +[[nodes]] +name = "fetch_repositories" +service = "github" +url = "https://api.github.com/graphql" + +[nodes.params] +operation_name = "ListRepositories" +query = """ +query ListRepositories($first: Int, $after: String) { + viewer { + repositories( + first: $first + after: $after + ownerAffiliations: [OWNER] + orderBy: { field: UPDATED_AT, direction: DESC } + ) { + edges { + node { + id + name + description + url + stargazerCount + forkCount + primaryLanguage { name } + isPrivate + createdAt + updatedAt + } + } + pageInfo { hasNextPage endCursor } + } + } +} +""" + +[nodes.params.auth] +kind = "bearer" +token = "${env:GITHUB_TOKEN}" + +[nodes.params.pagination] +strategy = "cursor" +page_info_path = "data.viewer.repositories.pageInfo" +edges_path = "data.viewer.repositories.edges" diff --git a/examples/pipelines/github/schemas/issue.schema.json b/examples/pipelines/github/schemas/issue.schema.json new file mode 100644 index 0000000..60c104b --- /dev/null +++ b/examples/pipelines/github/schemas/issue.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stygian/schemas/github/issue.schema.json", + "title": "Issue", + "description": "A GitHub issue record", + "type": "object", + "required": ["id", "number", "title", "url", "state"], + "properties": { + "id": { "type": "string", "description": "GitHub global node ID" }, + "number": { "type": "integer", "minimum": 1 }, + "title": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "state": { "type": "string", "enum": ["OPEN", "CLOSED"] }, + "author": { + "type": ["object", "null"], + "properties": { + "login": { "type": "string" } + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "color": { "type": "string" } + } + } + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/examples/pipelines/github/schemas/profile_summary.schema.json b/examples/pipelines/github/schemas/profile_summary.schema.json new file mode 100644 index 0000000..3966c8c --- /dev/null +++ b/examples/pipelines/github/schemas/profile_summary.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stygian/schemas/github/profile_summary.schema.json", + "title": "ProfileSummary", + "description": "AI-generated summary of a GitHub developer profile", + "type": "object", + "required": ["login", "primary_languages", "interests", "active_projects"], + "properties": { + "login": { "type": "string" }, + "primary_languages": { + "type": "array", + "items": { "type": "string" }, + "description": "Programming languages ranked by usage across owned repositories" + }, + "interests": { + "type": "array", + "items": { "type": "string" }, + "description": "Inferred technical interests from starred repositories" + }, + "active_projects": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "url"], + "properties": { + "name": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "description": { "type": ["string", "null"] } + } + }, + "description": "Most recently updated owned repositories" + }, + "contribution_focus": { + "type": "string", + "description": "One-paragraph characterisation of the developer's open-source focus" + }, + "total_stars_received": { "type": "integer", "minimum": 0 }, + "total_forks": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false +} diff --git a/examples/pipelines/github/schemas/pull_request.schema.json b/examples/pipelines/github/schemas/pull_request.schema.json new file mode 100644 index 0000000..fd0a348 --- /dev/null +++ b/examples/pipelines/github/schemas/pull_request.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stygian/schemas/github/pull_request.schema.json", + "title": "PullRequest", + "description": "A GitHub pull request record", + "type": "object", + "required": ["id", "number", "title", "url", "state"], + "properties": { + "id": { "type": "string", "description": "GitHub global node ID" }, + "number": { "type": "integer", "minimum": 1 }, + "title": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "state": { "type": "string", "enum": ["OPEN", "CLOSED", "MERGED"] }, + "author": { + "type": ["object", "null"], + "properties": { + "login": { "type": "string" } + } + }, + "additions": { "type": "integer", "minimum": 0 }, + "deletions": { "type": "integer", "minimum": 0 }, + "changedFiles": { "type": "integer", "minimum": 0 }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "color": { "type": "string" } + } + } + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/examples/pipelines/github/schemas/repository.schema.json b/examples/pipelines/github/schemas/repository.schema.json new file mode 100644 index 0000000..5a67985 --- /dev/null +++ b/examples/pipelines/github/schemas/repository.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stygian/schemas/github/repository.schema.json", + "title": "Repository", + "description": "A GitHub repository record", + "type": "object", + "required": ["id", "name", "url"], + "properties": { + "id": { "type": "string", "description": "GitHub global node ID" }, + "name": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "url": { "type": "string", "format": "uri" }, + "stargazerCount": { "type": "integer", "minimum": 0 }, + "forkCount": { "type": "integer", "minimum": 0 }, + "primaryLanguage": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" } + } + }, + "isPrivate": { "type": "boolean" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/examples/pipelines/github/starred.toml b/examples/pipelines/github/starred.toml new file mode 100644 index 0000000..c93ee8c --- /dev/null +++ b/examples/pipelines/github/starred.toml @@ -0,0 +1,46 @@ +[[services]] +name = "github" +kind = "graphql" + +[[nodes]] +name = "fetch_starred" +service = "github" +url = "https://api.github.com/graphql" + +[nodes.params] +operation_name = "ListStarred" +query = """ +query ListStarred($first: Int, $after: String) { + viewer { + starredRepositories( + first: $first + after: $after + orderBy: { field: STARRED_AT, direction: DESC } + ) { + edges { + node { + id + name + nameWithOwner + description + url + stargazerCount + primaryLanguage { name } + updatedAt + } + starredAt + } + pageInfo { hasNextPage endCursor } + } + } +} +""" + +[nodes.params.auth] +kind = "bearer" +token = "${env:GITHUB_TOKEN}" + +[nodes.params.pagination] +strategy = "cursor" +page_info_path = "data.viewer.starredRepositories.pageInfo" +edges_path = "data.viewer.starredRepositories.edges" diff --git a/examples/pipelines/jobber/README.md b/examples/pipelines/jobber/README.md deleted file mode 100644 index c90b9dd..0000000 --- a/examples/pipelines/jobber/README.md +++ /dev/null @@ -1,199 +0,0 @@ -# Jobber GraphQL Pipelines - -Ready-to-run Stygian pipelines for the [Jobber](https://d.getjobber.com/docs/api/) field-service management GraphQL API. - -## Requirements - -### Environment variables - -| Variable | Required | Purpose | -| --- | --- | --- | -| `JOBBER_ACCESS_TOKEN` | Yes (simple auth) | Pre-obtained OAuth2 access token | -| `JOBBER_CLIENT_ID` | For full OAuth2 PKCE | App client ID from developer portal | -| `JOBBER_CLIENT_SECRET` | For full OAuth2 PKCE | App client secret | - -### Obtaining a Jobber access token - -1. Sign in to the [Jobber Developer Portal](https://developer.getjobber.com/) -2. Create an application under **Apps** → **Create App** -3. Note your **Client ID** and **Client Secret** -4. Obtain an access token via the OAuth2 PKCE flow (see reference implementation in `reference_materials/oauth/`) - -OAuth2 endpoints: - -```text -Authorization: https://api.getjobber.com/api/oauth/authorize -Token: https://api.getjobber.com/api/oauth/token -``` - -### API version header - -All requests require: - -```text -X-JOBBER-GRAPHQL-VERSION: 2025-04-16 -``` - -This header is **automatically injected** by the `JobberPlugin` — you do not need to add it to your TOML files. - -## Running pipelines - -```bash -# Export your token -export JOBBER_ACCESS_TOKEN="your-token-here" - -# Validate a pipeline without executing -stygian check examples/pipelines/jobber/clients.toml - -# Fetch all clients -stygian run examples/pipelines/jobber/clients.toml - -# Fetch all jobs -stygian run examples/pipelines/jobber/jobs.toml - -# Fetch invoices with line items -stygian run examples/pipelines/jobber/invoices.toml - -# Fetch quotes -stygian run examples/pipelines/jobber/quotes.toml - -# Fetch expenses -stygian run examples/pipelines/jobber/expenses.toml - -# Fetch visits -stygian run examples/pipelines/jobber/visits.toml - -# Full sync with AI normalisation (requires ANTHROPIC_API_KEY) -export ANTHROPIC_API_KEY="sk-ant-..." -stygian run examples/pipelines/jobber/full_sync.toml - -# Introspect the Jobber schema (writes to ~/.stygian/cache/jobber_schema.json) -stygian run examples/pipelines/jobber/introspect.toml -``` - -## Pipeline structure - -All pipelines follow the same pattern: - -```toml -[[services]] -name = "jobber" -kind = "graphql" -plugin = "jobber" # Resolves JobberPlugin: endpoint, auth, version header - -[[nodes]] -name = "fetch_clients" -service = "jobber" - -[nodes.params] -query = "..." - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.clients.pageInfo" -edges_path = "data.clients.edges" -``` - -The `plugin = "jobber"` declaration injects: -- Endpoint: `https://api.getjobber.com/api/graphql` -- Version header: `X-JOBBER-GRAPHQL-VERSION: 2025-04-16` -- Auth: Bearer token from `JOBBER_ACCESS_TOKEN` - -Override any default by adding the field explicitly in the TOML — explicit params always win. - -## Expected output - -Each node emits `ServiceOutput` where: -- `data` — JSON string containing the paginated response body -- `metadata` — execution metadata including: - - `status_code` — HTTP status (should be 200) - - `cost` — Jobber API cost points consumed by this request - - `response_time_ms` — request duration - -Cost metadata example (from Jobber's rate-limit headers): - -```json -{ - "status_code": 200, - "cost": { - "requested": 200, - "actual": 150, - "throttle_status": { "currently_available": 9850, "restore_rate": 500 } - } -} -``` - -## Canonical output schemas - -The `schemas/` directory contains JSON Schema definitions for each domain object. -These are used by the AI normalisation nodes in `full_sync.toml`: - -| Schema file | Root type | -| --- | --- | -| `schemas/client.schema.json` | `Client` | -| `schemas/job.schema.json` | `Job` | -| `schemas/invoice.schema.json` | `Invoice` | -| `schemas/quote.schema.json` | `Quote` | -| `schemas/expense.schema.json` | `Expense` | -| `schemas/visit.schema.json` | `Visit` | - -## Extending pipelines - -### Adding custom fields - -Edit the `query` in the TOML to add or remove GraphQL fields: - -```toml -[nodes.params] -query = """ -query ListClients($first: Int, $after: String) { - clients(first: $first, after: $after) { - edges { - node { - id - name - # Add your custom fields here: - companyName - tags { label } - } - } - pageInfo { hasNextPage endCursor } - } -} -""" -``` - -### Filtering by date range - -Use GraphQL variables in the `params` section: - -```toml -[nodes.params] -variables = { filter = { createdAt = { gte = "2025-01-01", lte = "2025-12-31" } } } -``` - -### Adding a new plugin target - -To add a new GraphQL target (e.g. Shopify, GitHub, Linear): - -1. Create `crates/stygian-graph/src/adapters/graphql_plugins/.rs` implementing `GraphQlTargetPlugin` -2. Add `pub mod ;` to `adapters/graphql_plugins/mod.rs` -3. Register the plugin at startup -4. Reference it with `plugin = ""` in TOML - -No changes to `GraphQlService`, the port, or the registry are required. - -## Rate limits - -Jobber rate limits requests using a cost-based throttle: -- **Max points**: 10,000 per rolling window -- **Restore rate**: 500 points/second - -The `GraphQlService` adapter automatically detects `THROTTLED` errors and retries with exponential back-off. -Monitor cost consumption via the `metadata.cost` field in `ServiceOutput`. - -## Reference materials - -- Go OAuth implementation: `reference_materials/oauth/` -- GraphQL rate-limit config: `reference_materials/graphql/ratelimit_config.go` -- Jobber API docs: diff --git a/examples/pipelines/jobber/clients.toml b/examples/pipelines/jobber/clients.toml deleted file mode 100644 index 924c6be..0000000 --- a/examples/pipelines/jobber/clients.toml +++ /dev/null @@ -1,34 +0,0 @@ -[[services]] -name = "jobber" -kind = "graphql" -plugin = "jobber" - -[[nodes]] -name = "fetch_clients" -service = "jobber" - -[nodes.params] -operation_name = "ListClients" -query = """ -query ListClients($first: Int, $after: String) { - clients(first: $first, after: $after) { - edges { - node { - id - name - email - phone - billingAddress { street city province postalCode country } - balance - createdAt - } - } - pageInfo { hasNextPage endCursor } - } -} -""" - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.clients.pageInfo" -edges_path = "data.clients.edges" diff --git a/examples/pipelines/jobber/expenses.toml b/examples/pipelines/jobber/expenses.toml deleted file mode 100644 index e9c8051..0000000 --- a/examples/pipelines/jobber/expenses.toml +++ /dev/null @@ -1,36 +0,0 @@ -[[services]] -name = "jobber" -kind = "graphql" -plugin = "jobber" - -[[nodes]] -name = "fetch_expenses" -service = "jobber" - -[nodes.params] -operation_name = "ListExpenses" -query = """ -query ListExpenses($first: Int, $after: String) { - expenses(first: $first, after: $after) { - edges { - node { - id - title - total - date - entryType - taxable - reimbursableTo { id name } - job { id title } - createdAt - } - } - pageInfo { hasNextPage endCursor } - } -} -""" - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.expenses.pageInfo" -edges_path = "data.expenses.edges" diff --git a/examples/pipelines/jobber/full_sync.toml b/examples/pipelines/jobber/full_sync.toml deleted file mode 100644 index 7790ebc..0000000 --- a/examples/pipelines/jobber/full_sync.toml +++ /dev/null @@ -1,136 +0,0 @@ -[[services]] -name = "jobber" -kind = "graphql" -plugin = "jobber" - -[[services]] -name = "claude" -kind = "claude" -model = "claude-sonnet-4-5" -api_key = "${env:ANTHROPIC_API_KEY}" - -# ─── Parallel fetch nodes (no dependencies between them) ───────────────────── - -[[nodes]] -name = "fetch_clients" -service = "jobber" - -[nodes.params] -operation_name = "ListClients" -query = """ -query ListClients($first: Int, $after: String) { - clients(first: $first, after: $after) { - edges { node { id name email phone balance createdAt } } - pageInfo { hasNextPage endCursor } - } -} -""" - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.clients.pageInfo" -edges_path = "data.clients.edges" - -# ───────────────────────────────────────────────────────────────────────────── - -[[nodes]] -name = "fetch_jobs" -service = "jobber" - -[nodes.params] -operation_name = "ListJobs" -query = """ -query ListJobs($first: Int, $after: String) { - jobs(first: $first, after: $after) { - edges { node { id title jobStatus total startAt endAt } } - pageInfo { hasNextPage endCursor } - } -} -""" - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.jobs.pageInfo" -edges_path = "data.jobs.edges" - -# ───────────────────────────────────────────────────────────────────────────── - -[[nodes]] -name = "fetch_invoices" -service = "jobber" - -[nodes.params] -operation_name = "ListInvoices" -query = """ -query ListInvoices($first: Int, $after: String) { - invoices(first: $first, after: $after) { - edges { node { id invoiceNumber invoiceStatus total balance issuedDate dueDate } } - pageInfo { hasNextPage endCursor } - } -} -""" - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.invoices.pageInfo" -edges_path = "data.invoices.edges" - -# ───────────────────────────────────────────────────────────────────────────── - -[[nodes]] -name = "fetch_quotes" -service = "jobber" - -[nodes.params] -operation_name = "ListQuotes" -query = """ -query ListQuotes($first: Int, $after: String) { - quotes(first: $first, after: $after) { - edges { node { id title quoteStatus total quoteNumber createdAt } } - pageInfo { hasNextPage endCursor } - } -} -""" - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.quotes.pageInfo" -edges_path = "data.quotes.edges" - -# ─── AI normalisation nodes (each depends on its fetch node) ───────────────── - -[[nodes]] -name = "normalise_clients" -service = "claude" -depends_on = ["fetch_clients"] - -[nodes.params] -schema_file = "examples/pipelines/jobber/schemas/client.schema.json" -prompt = "Extract and normalise the Jobber client records to the canonical Client JSON Schema. Include all fields. Omit null values." - -[[nodes]] -name = "normalise_jobs" -service = "claude" -depends_on = ["fetch_jobs"] - -[nodes.params] -schema_file = "examples/pipelines/jobber/schemas/job.schema.json" -prompt = "Extract and normalise the Jobber job records to the canonical Job JSON Schema. Include all fields. Omit null values." - -[[nodes]] -name = "normalise_invoices" -service = "claude" -depends_on = ["fetch_invoices"] - -[nodes.params] -schema_file = "examples/pipelines/jobber/schemas/invoice.schema.json" -prompt = "Extract and normalise the Jobber invoice records to the canonical Invoice JSON Schema. Include line items." - -[[nodes]] -name = "normalise_quotes" -service = "claude" -depends_on = ["fetch_quotes"] - -[nodes.params] -schema_file = "examples/pipelines/jobber/schemas/quote.schema.json" -prompt = "Extract and normalise the Jobber quote records to the canonical Quote JSON Schema." diff --git a/examples/pipelines/jobber/introspect.toml b/examples/pipelines/jobber/introspect.toml deleted file mode 100644 index e962d13..0000000 --- a/examples/pipelines/jobber/introspect.toml +++ /dev/null @@ -1,30 +0,0 @@ -[[services]] -name = "jobber" -kind = "graphql" -plugin = "jobber" - -[[nodes]] -name = "introspect_jobber" -service = "jobber" - -[nodes.params] -operation_name = "IntrospectionQuery" -output_file = "~/.stygian/cache/jobber_schema.json" -query = """ -query IntrospectionQuery { - __schema { - queryType { name } - mutationType { name } - types { - name - kind - description - fields(includeDeprecated: false) { - name - description - type { name kind ofType { name kind } } - } - } - } -} -""" diff --git a/examples/pipelines/jobber/invoices.toml b/examples/pipelines/jobber/invoices.toml deleted file mode 100644 index 0e33427..0000000 --- a/examples/pipelines/jobber/invoices.toml +++ /dev/null @@ -1,51 +0,0 @@ -[[services]] -name = "jobber" -kind = "graphql" -plugin = "jobber" - -[[nodes]] -name = "fetch_invoices" -service = "jobber" - -[nodes.params] -operation_name = "ListInvoices" -query = """ -query ListInvoices($first: Int, $after: String) { - invoices(first: $first, after: $after) { - edges { - node { - id - invoiceNumber - subject - invoiceStatus - client { id name } - total - balance - depositAmount - issuedDate - dueDate - jobberWebUri - lineItems { - edges { - node { - id - name - description - quantity - unitCost - total - } - } - } - createdAt - } - } - pageInfo { hasNextPage endCursor } - } -} -""" - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.invoices.pageInfo" -edges_path = "data.invoices.edges" diff --git a/examples/pipelines/jobber/jobs.toml b/examples/pipelines/jobber/jobs.toml deleted file mode 100644 index a1d2484..0000000 --- a/examples/pipelines/jobber/jobs.toml +++ /dev/null @@ -1,37 +0,0 @@ -[[services]] -name = "jobber" -kind = "graphql" -plugin = "jobber" - -[[nodes]] -name = "fetch_jobs" -service = "jobber" - -[nodes.params] -operation_name = "ListJobs" -query = """ -query ListJobs($first: Int, $after: String) { - jobs(first: $first, after: $after) { - edges { - node { - id - title - jobStatus - client { id name } - assignedTo { id name } - startAt - endAt - completedAt - total - createdAt - } - } - pageInfo { hasNextPage endCursor } - } -} -""" - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.jobs.pageInfo" -edges_path = "data.jobs.edges" diff --git a/examples/pipelines/jobber/quotes.toml b/examples/pipelines/jobber/quotes.toml deleted file mode 100644 index 05a97bd..0000000 --- a/examples/pipelines/jobber/quotes.toml +++ /dev/null @@ -1,35 +0,0 @@ -[[services]] -name = "jobber" -kind = "graphql" -plugin = "jobber" - -[[nodes]] -name = "fetch_quotes" -service = "jobber" - -[nodes.params] -operation_name = "ListQuotes" -query = """ -query ListQuotes($first: Int, $after: String) { - quotes(first: $first, after: $after) { - edges { - node { - id - title - quoteStatus - client { id name } - total - depositAmount - quoteNumber - createdAt - } - } - pageInfo { hasNextPage endCursor } - } -} -""" - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.quotes.pageInfo" -edges_path = "data.quotes.edges" diff --git a/examples/pipelines/jobber/schemas/client.schema.json b/examples/pipelines/jobber/schemas/client.schema.json deleted file mode 100644 index 16f08e2..0000000 --- a/examples/pipelines/jobber/schemas/client.schema.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://stygian/schemas/jobber/client.schema.json", - "title": "Client", - "description": "A Jobber client record", - "type": "object", - "required": ["id", "name"], - "properties": { - "id": { "type": "string", "description": "Unique Jobber client ID" }, - "name": { "type": "string" }, - "email": { "type": "string", "format": "email" }, - "phone": { "type": "string" }, - "billingAddress": { - "type": "object", - "properties": { - "street": { "type": "string" }, - "city": { "type": "string" }, - "province": { "type": "string" }, - "postalCode": { "type": "string" }, - "country": { "type": "string" } - } - }, - "balance": { "type": "number" }, - "createdAt": { "type": "string", "format": "date-time" } - }, - "additionalProperties": false -} diff --git a/examples/pipelines/jobber/schemas/expense.schema.json b/examples/pipelines/jobber/schemas/expense.schema.json deleted file mode 100644 index b7a1186..0000000 --- a/examples/pipelines/jobber/schemas/expense.schema.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://stygian/schemas/jobber/expense.schema.json", - "title": "Expense", - "description": "A Jobber expense record", - "type": "object", - "required": ["id", "title"], - "properties": { - "id": { "type": "string" }, - "title": { "type": "string" }, - "total": { "type": "number" }, - "date": { "type": "string", "format": "date" }, - "entryType": { "type": "string", "enum": ["MATERIAL", "LABOUR", "OTHER"] }, - "taxable": { "type": "boolean" }, - "reimbursableTo": { - "type": "object", - "properties": { "id": { "type": "string" }, "name": { "type": "string" } } - }, - "job": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "title": { "type": "string" } - } - }, - "createdAt": { "type": "string", "format": "date-time" } - }, - "additionalProperties": false -} diff --git a/examples/pipelines/jobber/schemas/invoice.schema.json b/examples/pipelines/jobber/schemas/invoice.schema.json deleted file mode 100644 index 82d0746..0000000 --- a/examples/pipelines/jobber/schemas/invoice.schema.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://stygian/schemas/jobber/invoice.schema.json", - "title": "Invoice", - "description": "A Jobber invoice record", - "type": "object", - "required": ["id", "invoiceNumber"], - "properties": { - "id": { "type": "string" }, - "invoiceNumber": { "type": "integer" }, - "subject": { "type": "string" }, - "invoiceStatus": { - "type": "string", - "enum": ["DRAFT", "AWAITING_PAYMENT", "PAID", "BAD_DEBT", "VOID"] - }, - "client": { - "type": "object", - "properties": { "id": { "type": "string" }, "name": { "type": "string" } } - }, - "total": { "type": "number" }, - "balance": { "type": "number" }, - "depositAmount": { "type": "number" }, - "issuedDate": { "type": "string", "format": "date" }, - "dueDate": { "type": "string", "format": "date" }, - "jobberWebUri": { "type": "string", "format": "uri" }, - "lineItems": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "description": { "type": "string" }, - "quantity": { "type": "number" }, - "unitCost": { "type": "number" }, - "total": { "type": "number" } - } - } - }, - "createdAt": { "type": "string", "format": "date-time" } - }, - "additionalProperties": false -} diff --git a/examples/pipelines/jobber/schemas/job.schema.json b/examples/pipelines/jobber/schemas/job.schema.json deleted file mode 100644 index dc2d682..0000000 --- a/examples/pipelines/jobber/schemas/job.schema.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://stygian/schemas/jobber/job.schema.json", - "title": "Job", - "description": "A Jobber job record", - "type": "object", - "required": ["id", "title"], - "properties": { - "id": { "type": "string" }, - "title": { "type": "string" }, - "jobStatus": { - "type": "string", - "enum": [ - "DRAFT", - "AWAITING_PAYMENT", - "ACTIVE", - "COMPLETED", - "LATE", - "ARCHIVED", - "REQUIRES_INVOICING" - ] - }, - "client": { - "type": "object", - "properties": { "id": { "type": "string" }, "name": { "type": "string" } } - }, - "assignedTo": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "name": { "type": "string" } - } - } - }, - "startAt": { "type": "string", "format": "date-time" }, - "endAt": { "type": "string", "format": "date-time" }, - "completedAt": { "type": "string", "format": "date-time" }, - "total": { "type": "number" }, - "createdAt": { "type": "string", "format": "date-time" } - }, - "additionalProperties": false -} diff --git a/examples/pipelines/jobber/schemas/quote.schema.json b/examples/pipelines/jobber/schemas/quote.schema.json deleted file mode 100644 index 77d371b..0000000 --- a/examples/pipelines/jobber/schemas/quote.schema.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://stygian/schemas/jobber/quote.schema.json", - "title": "Quote", - "description": "A Jobber quote record", - "type": "object", - "required": ["id", "quoteNumber"], - "properties": { - "id": { "type": "string" }, - "title": { "type": "string" }, - "quoteStatus": { - "type": "string", - "enum": [ - "DRAFT", - "AWAITING_RESPONSE", - "APPROVED", - "ARCHIVED", - "CONVERTED", - "CHANGES_REQUESTED", - "AWAITING_PAYMENT" - ] - }, - "client": { - "type": "object", - "properties": { "id": { "type": "string" }, "name": { "type": "string" } } - }, - "total": { "type": "number" }, - "depositAmount": { "type": "number" }, - "quoteNumber": { "type": "integer" }, - "createdAt": { "type": "string", "format": "date-time" } - }, - "additionalProperties": false -} diff --git a/examples/pipelines/jobber/schemas/visit.schema.json b/examples/pipelines/jobber/schemas/visit.schema.json deleted file mode 100644 index d74dd82..0000000 --- a/examples/pipelines/jobber/schemas/visit.schema.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://stygian/schemas/jobber/visit.schema.json", - "title": "Visit", - "description": "A Jobber visit (scheduled appointment) record", - "type": "object", - "required": ["id"], - "properties": { - "id": { "type": "string" }, - "title": { "type": "string" }, - "startAt": { "type": "string", "format": "date-time" }, - "endAt": { "type": "string", "format": "date-time" }, - "completedAt": { "type": "string", "format": "date-time" }, - "allDay": { "type": "boolean" }, - "isDefaultTitle": { "type": "boolean" }, - "job": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "title": { "type": "string" } - } - }, - "createdAt": { "type": "string", "format": "date-time" } - }, - "additionalProperties": false -} diff --git a/examples/pipelines/jobber/visits.toml b/examples/pipelines/jobber/visits.toml deleted file mode 100644 index f5e454d..0000000 --- a/examples/pipelines/jobber/visits.toml +++ /dev/null @@ -1,36 +0,0 @@ -[[services]] -name = "jobber" -kind = "graphql" -plugin = "jobber" - -[[nodes]] -name = "fetch_visits" -service = "jobber" - -[nodes.params] -operation_name = "ListVisits" -query = """ -query ListVisits($first: Int, $after: String) { - visits(first: $first, after: $after) { - edges { - node { - id - title - startAt - endAt - completedAt - allDay - job { id title } - isDefaultTitle - createdAt - } - } - pageInfo { hasNextPage endCursor } - } -} -""" - -[nodes.params.pagination] -strategy = "cursor" -page_info_path = "data.visits.pageInfo" -edges_path = "data.visits.edges"