linear-api 0.1.0

Unofficial async Rust client for the Linear GraphQL API (API-key auth)
Documentation
//! Shared wiremock helpers for integration tests. Not every test file uses
//! every helper, hence the module-wide `dead_code` allowance.
#![allow(dead_code)]

use std::time::Duration;

/// A client pointed at the mock server, with a fast retry config.
pub fn client_for(server: &wiremock::MockServer) -> linear_api::LinearClient {
    linear_api::LinearClient::builder()
        .api_key("test-key")
        .endpoint(server.uri())
        .retry(linear_api::RetryConfig {
            max_attempts: 3,
            base_backoff: Duration::from_millis(5),
            max_rate_limit_wait: Duration::from_secs(2),
            retry_mutations_on_transient: false,
        })
        .build()
        .expect("test client builds")
}

/// 200 response with body `{"data": <data>}`.
pub fn graphql_ok(data: serde_json::Value) -> wiremock::ResponseTemplate {
    wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": data }))
}

/// Response with the given status and body `{"errors": <errors>}`.
pub fn graphql_errors(status: u16, errors: serde_json::Value) -> wiremock::ResponseTemplate {
    wiremock::ResponseTemplate::new(status).set_body_json(serde_json::json!({ "errors": errors }))
}

/// Linear's rate-limit rejection: HTTP 400, `RATELIMITED` extension code,
/// and the requests-reset header set to `reset_epoch_ms` (UTC epoch ms).
pub fn rate_limited(reset_epoch_ms: i128) -> wiremock::ResponseTemplate {
    wiremock::ResponseTemplate::new(400)
        .set_body_json(serde_json::json!({
            "errors": [{
                "message": "rate limited",
                "extensions": { "code": "RATELIMITED" }
            }]
        }))
        .insert_header("X-RateLimit-Requests-Remaining", "0")
        .insert_header(
            "X-RateLimit-Requests-Reset",
            reset_epoch_ms.to_string().as_str(),
        )
}

/// Matches requests whose GraphQL document contains the named operation,
/// e.g. `Operation("IssueList")` matches `query IssueList` or
/// `mutation IssueList`.
pub struct Operation(pub &'static str);

impl wiremock::Match for Operation {
    fn matches(&self, request: &wiremock::Request) -> bool {
        let Ok(body) = serde_json::from_slice::<serde_json::Value>(&request.body) else {
            return false;
        };
        body.get("query")
            .and_then(serde_json::Value::as_str)
            .is_some_and(|query| {
                query.contains(&format!("query {}", self.0))
                    || query.contains(&format!("mutation {}", self.0))
            })
    }
}