linear-api 0.1.0

Unofficial async Rust client for the Linear GraphQL API (API-key auth)
Documentation
//! Cross-module stream contracts enforced by the integration review:
//!
//! 1. every `list_stream` can be created from a *temporary* service handle
//!    (`client.issues().list_stream(..)`) and must only borrow the client;
//! 2. a caller-seeded `after` cursor on the request is honored by the first
//!    page fetch instead of being silently discarded.

use futures::TryStreamExt;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, Request, ResponseTemplate};

mod support;

/// Compile-time contract: streams from temporary service handles outlive
/// them, borrowing only the client.
#[allow(dead_code)]
async fn streams_outlive_temporary_service_handles(
    client: &linear_api::LinearClient,
) -> linear_api::Result<()> {
    let issues = client
        .issues()
        .list_stream(linear_api::issues::ListIssuesRequest::default());
    let projects = client
        .projects()
        .list_stream(linear_api::projects::ListProjectsRequest::default());
    let teams = client.teams().list_stream(Default::default());
    let users = client.users().list_stream(Default::default());
    let labels = client
        .labels()
        .list_stream(linear_api::labels::ListLabelsRequest::default());
    let comments = client.comments().list_for_issue_stream(
        linear_api::IssueRef::identifier("ENG-1"),
        Default::default(),
    );

    let _: Vec<_> = std::pin::pin!(issues).try_collect().await?;
    let _: Vec<_> = std::pin::pin!(projects).try_collect().await?;
    let _: Vec<_> = std::pin::pin!(teams).try_collect().await?;
    let _: Vec<_> = std::pin::pin!(users).try_collect().await?;
    let _: Vec<_> = std::pin::pin!(labels).try_collect().await?;
    let _: Vec<_> = std::pin::pin!(comments).try_collect().await?;
    Ok(())
}

/// Matches a request whose GraphQL `variables.after` equals the given value.
struct AfterEquals(&'static str);

impl wiremock::Match for AfterEquals {
    fn matches(&self, request: &Request) -> bool {
        serde_json::from_slice::<serde_json::Value>(&request.body)
            .ok()
            .and_then(|body| body["variables"]["after"].as_str().map(str::to_owned))
            .as_deref()
            == Some(self.0)
    }
}

#[tokio::test]
async fn list_stream_honors_a_seeded_after_cursor() {
    let server = MockServer::start().await;
    // Only the request carrying the seeded cursor gets a page; anything else
    // (e.g. a first fetch that dropped the cursor) falls through to a 404
    // and fails the collect.
    Mock::given(method("POST"))
        .and(support::Operation("LabelList"))
        .and(AfterEquals("seed-cursor"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "data": { "issueLabels": {
                "nodes": [{
                    "id": "lbl-1",
                    "name": "bug",
                    "color": "#BB87FC",
                    "description": null,
                    "isGroup": false,
                    "parent": null,
                    "team": null
                }],
                "pageInfo": {
                    "hasNextPage": false,
                    "hasPreviousPage": true,
                    "startCursor": "seed-cursor",
                    "endCursor": "end-1"
                }
            } }
        })))
        .expect(1)
        .mount(&server)
        .await;

    let client = support::client_for(&server);
    let labels: Vec<_> = client
        .labels()
        .list_stream(
            linear_api::labels::ListLabelsRequest::builder()
                .after("seed-cursor")
                .build(),
        )
        .try_collect()
        .await
        .expect("stream with seeded cursor succeeds");
    assert_eq!(labels.len(), 1);
    assert_eq!(labels[0].name, "bug");
}