Skip to main content

onetaskgraph_linear/
lib.rs

1//! A read-only source over Linear's published GraphQL API.
2//!
3//! Linear `Issue` maps to [`Task`], `Project` to [`Project`], `IssueLabel` and
4//! `ProjectLabel` to [`Label`], and `WorkflowState.name` is preserved while its
5//! `type` (`backlog`, `unstarted`, `started`, `completed`, or `canceled`) maps to
6//! the normalized status category. Issue `relations`/`inverseRelations` and
7//! project relations provide native dependency traversal in both directions.
8//!
9//! Label, workflow-state, project, and orphan filters are sent in the
10//! `issues(filter:)`/`projects(filter:)` variables. Linear's separate search
11//! connection cannot restrict matching to title versus description, so all
12//! three text predicates are deliberately unsupported and ignored here for
13//! sound engine compensation. Pagination uses Relay `first` and `after`.
14//!
15//! Fixture provenance is recorded in `tests/fixtures/README.md`. Live tests are
16//! non-destructive by construction: [`TaskSource`] exposes reads only, and no
17//! test uses GraphQL mutations for setup or teardown.
18#![deny(missing_docs)]
19
20use chrono::{DateTime, Utc};
21use onetaskgraph_plugin_api::{
22    Capabilities, Cursor, DependencyEdge, DependencyKind, DependencySupport, Direction, Health,
23    Label, NativeId, Page, PageRequest, Project, ProjectFilter, ProjectQuery, SecretResolver,
24    SourceError, SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery,
25    TaskSource,
26};
27use schemars::{Schema, schema_for};
28use secrecy::{ExposeSecret, SecretString};
29use serde::Deserialize;
30use serde_json::{Value, json};
31
32/// The plugin kind a `linear` source's `plugin:` field names.
33pub const KIND: &str = "linear";
34const DEFAULT_ENDPOINT: &str = "https://api.linear.app/graphql";
35
36/// Exact GraphQL query documents issued by this plugin.
37///
38/// Fixture servers consume these constants so their recognized contract cannot drift
39/// from the production requests.
40pub mod graphql {
41    /// Check the authenticated viewer.
42    pub const VIEWER: &str = "query { viewer { id } }";
43    /// Fetch one issue.
44    pub const ISSUE: &str = "query($id:String!){ issue(id:$id){ id title description url createdAt updatedAt state{name type} labels{nodes{id name color}} project{id} } }";
45    /// Fetch one project.
46    pub const PROJECT: &str = "query($id:String!){ project(id:$id){ id name description url createdAt updatedAt status{name type} labels{nodes{id name color}} } }";
47    /// List issues.
48    pub const ISSUES: &str = "query($first:Int!,$after:String,$filter:IssueFilter){ issues(first:$first,after:$after,filter:$filter){ nodes{id title description url createdAt updatedAt state{name type} labels{nodes{id name color}} project{id}} pageInfo{hasNextPage endCursor} } }";
49    /// List projects.
50    pub const PROJECTS: &str = "query($first:Int!,$after:String,$filter:ProjectFilter){ projects(first:$first,after:$after,filter:$filter){ nodes{id name description url createdAt updatedAt status{name type} labels{nodes{id name color}}} pageInfo{hasNextPage endCursor} } }";
51    /// List issue labels.
52    pub const LABELS: &str = "query($first:Int!,$after:String){ issueLabels(first:$first,after:$after){ nodes{id name color} pageInfo{hasNextPage endCursor} } }";
53    /// Fetch issue dependency relations.
54    pub const ISSUE_RELATIONS: &str = "query($id:String!,$first:Int!,$after:String){ issue(id:$id){ relations(first:$first,after:$after){nodes{type relatedIssue{id}} pageInfo{hasNextPage endCursor}} inverseRelations(first:$first,after:$after){nodes{type issue{id}} pageInfo{hasNextPage endCursor}} } }";
55    /// Fetch project dependency relations.
56    pub const PROJECT_RELATIONS: &str = "query($id:String!,$first:Int!,$after:String){ project(id:$id){ relations(first:$first,after:$after){nodes{type relatedProject{id}} pageInfo{hasNextPage endCursor}} inverseRelations(first:$first,after:$after){nodes{type project{id}} pageInfo{hasNextPage endCursor}} } }";
57}
58
59use graphql::{
60    ISSUE, ISSUE_RELATIONS, ISSUES, LABELS, PROJECT, PROJECT_RELATIONS, PROJECTS, VIEWER,
61};
62
63/// Configuration contains only the credential variable's name, never its value.
64#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
65#[serde(default, deny_unknown_fields)]
66pub struct LinearConfig {
67    /// Environment variable resolved by the host.
68    #[schemars(with = "String")]
69    api_key_env: EnvName,
70    /// Optional Linear team key/id narrowing task and project reads.
71    #[schemars(with = "Option<String>")]
72    team: Option<Team>,
73    /// GraphQL endpoint override, primarily for fixture servers.
74    #[schemars(with = "String")]
75    endpoint: Endpoint,
76}
77
78#[derive(Debug, Clone, Deserialize)]
79#[serde(try_from = "String")]
80struct EnvName(String);
81impl TryFrom<String> for EnvName {
82    type Error = String;
83    fn try_from(value: String) -> Result<Self, Self::Error> {
84        let mut bytes = value.bytes();
85        if bytes
86            .next()
87            .is_some_and(|byte| byte == b'_' || byte.is_ascii_uppercase())
88            && bytes.all(|byte| byte == b'_' || byte.is_ascii_uppercase() || byte.is_ascii_digit())
89        {
90            Ok(Self(value))
91        } else {
92            Err("must be an uppercase environment-variable name".into())
93        }
94    }
95}
96#[derive(Debug, Clone, Deserialize)]
97#[serde(try_from = "String")]
98struct Team(String);
99impl TryFrom<String> for Team {
100    type Error = String;
101    fn try_from(value: String) -> Result<Self, Self::Error> {
102        if value.trim().is_empty() {
103            Err("must not be empty".into())
104        } else {
105            Ok(Self(value))
106        }
107    }
108}
109#[derive(Debug, Clone, Deserialize)]
110#[serde(try_from = "String")]
111struct Endpoint(String);
112impl TryFrom<String> for Endpoint {
113    type Error = String;
114    fn try_from(value: String) -> Result<Self, Self::Error> {
115        let url = reqwest::Url::parse(&value).map_err(|e| e.to_string())?;
116        if matches!(url.scheme(), "http" | "https") {
117            Ok(Self(value))
118        } else {
119            Err("must use http or https".into())
120        }
121    }
122}
123
124impl Default for LinearConfig {
125    fn default() -> Self {
126        Self {
127            api_key_env: EnvName("LINEAR_API_KEY".into()),
128            team: None,
129            endpoint: Endpoint(DEFAULT_ENDPOINT.into()),
130        }
131    }
132}
133
134/// The Linear plugin factory.
135#[derive(Debug, Clone, Copy, Default)]
136pub struct Plugin;
137
138impl SourcePlugin for Plugin {
139    fn kind(&self) -> &'static str {
140        KIND
141    }
142    fn config_schema(&self) -> Schema {
143        schema_for!(LinearConfig)
144    }
145    fn build(
146        &self,
147        name: &SourceName,
148        config: &Value,
149        secrets: &dyn SecretResolver,
150    ) -> Result<Box<dyn TaskSource>, SourceError> {
151        let config: LinearConfig =
152            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
153                message: format!("source {name}: {e}"),
154            })?;
155        let key = secrets
156            .get(&config.api_key_env.0)
157            .filter(|v| !v.expose_secret().trim().is_empty())
158            .ok_or_else(|| SourceError::Auth {
159                message: format!("set environment variable {}", config.api_key_env.0),
160            })?;
161        Ok(Box::new(LinearSource {
162            client: reqwest::Client::new(),
163            endpoint: config.endpoint,
164            key,
165            team: config.team,
166        }))
167    }
168}
169
170struct LinearSource {
171    client: reqwest::Client,
172    endpoint: Endpoint,
173    key: SecretString,
174    team: Option<Team>,
175}
176
177#[derive(Deserialize)]
178struct Envelope {
179    // llmlint: ignore[invalid_states_unrepresentable] One transport envelope carries eight distinct GraphQL data shapes; each operation immediately validates its own complete mapper into typed plugin-api values, so malformed external data cannot cross the plugin boundary and a union here would duplicate every query response solely inside transport code.
180    data: Option<Value>,
181    #[serde(default)]
182    errors: Vec<GqlError>,
183}
184#[derive(Deserialize)]
185struct GqlError {
186    message: String,
187    extensions: Option<GqlExtensions>,
188}
189#[derive(Deserialize)]
190#[serde(rename_all = "camelCase")]
191struct GqlExtensions {
192    code: GqlErrorCode,
193    retry_after: Option<u64>,
194}
195#[derive(Deserialize)]
196enum GqlErrorCode {
197    #[serde(rename = "RATELIMITED", alias = "RATE_LIMITED")]
198    RateLimited,
199    #[serde(other)]
200    Other,
201}
202
203impl LinearSource {
204    // llmlint: ignore[invalid_states_unrepresentable] This private generic transport accepts only variables constructed immediately at typed TaskSource call sites, never untrusted input; per-operation response mappers validate every external field before returning public values.
205    async fn send(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
206        let response = self
207            .client
208            .post(&self.endpoint.0)
209            .header("Authorization", self.key.expose_secret())
210            .json(&json!({"query": query, "variables": variables}))
211            .send()
212            .await
213            .map_err(|e| SourceError::Unavailable {
214                message: e.to_string(),
215            })?;
216        let status = response.status();
217        let retry = response
218            .headers()
219            .get("retry-after")
220            .and_then(|v| v.to_str().ok())
221            .and_then(|v| v.parse().ok());
222        if status.as_u16() == 429 {
223            return Err(SourceError::RateLimited {
224                retry_after_seconds: retry,
225            });
226        }
227        if status.as_u16() == 401 || status.as_u16() == 403 {
228            return Err(SourceError::Auth {
229                message: "Linear rejected the configured credential".into(),
230            });
231        }
232        if !status.is_success() {
233            return Err(SourceError::Unavailable {
234                message: format!("Linear returned HTTP {status}"),
235            });
236        }
237        let body: Envelope = response.json().await.map_err(|e| SourceError::Malformed {
238            message: e.to_string(),
239        })?;
240        if let Some(error) = body.errors.first() {
241            if error
242                .extensions
243                .as_ref()
244                .is_some_and(|extensions| matches!(extensions.code, GqlErrorCode::RateLimited))
245            {
246                let hint = error
247                    .extensions
248                    .as_ref()
249                    .and_then(|value| value.retry_after);
250                return Err(SourceError::RateLimited {
251                    retry_after_seconds: hint.or(retry),
252                });
253            }
254            return Err(SourceError::Refused {
255                message: error.message.clone(),
256            });
257        }
258        body.data.ok_or_else(|| SourceError::Malformed {
259            message: "GraphQL response has no data".into(),
260        })
261    }
262
263    // llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] These operators follow the accepted 2026-08-24 Linear contract, but Linear exposes their authoritative definitions only through an authenticated unversioned explorer; the real-HTTP tests assert every serialized operator and the shared CLI journeys assert resulting rows without making credentials required.
264    fn filter(
265        &self,
266        labels: &onetaskgraph_plugin_api::LabelFilter,
267        statuses: &[StatusCategory],
268        project: Option<&ProjectFilter>,
269    ) -> Value {
270        let mut parts = Vec::new();
271        if let Some(team) = &self.team {
272            parts.push(json!({"team": {"key": {"eqIgnoreCase": team.0}}}));
273        }
274        if !labels.any_of.is_empty() {
275            parts.push(json!({"labels": {"some": {"name": {"inIgnoreCase": labels.any_of}}}}));
276        }
277        for name in &labels.all_of {
278            parts.push(json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}));
279        }
280        for name in &labels.none_of {
281            parts.push(json!({"labels": {"every": {"name": {"neqIgnoreCase": name}}}}));
282        }
283        if !statuses.is_empty() {
284            parts.push(json!({"state": {"type": {"in": statuses.iter().flat_map(linear_statuses).collect::<Vec<_>>()}}}));
285        }
286        match project {
287            Some(ProjectFilter::Orphans) => parts.push(json!({"project": {"null": true}})),
288            Some(ProjectFilter::Is(id)) => parts.push(json!({"project": {"id": {"eq": id.0}}})),
289            _ => {}
290        }
291        if parts.len() == 1 {
292            parts.pop().unwrap()
293        } else {
294            json!({"and": parts})
295        }
296    }
297    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
298}
299
300#[async_trait::async_trait]
301impl TaskSource for LinearSource {
302    fn kind(&self) -> &'static str {
303        KIND
304    }
305    fn capabilities(&self) -> Capabilities {
306        Capabilities {
307            projects: Support::Native,
308            orphan_tasks: Support::Native,
309            filter_by_label: Support::Native,
310            filter_by_status: Support::Native,
311            search_title: Support::Unsupported,
312            search_content: Support::Unsupported,
313            task_dependencies: DependencySupport::BothDirections,
314            project_dependencies: DependencySupport::BothDirections,
315            max_page_size: 250,
316        }
317    }
318    async fn health(&self) -> Result<Health, SourceError> {
319        let data = self.send(VIEWER, json!({})).await?;
320        str_at(
321            data.get("viewer").ok_or_else(|| SourceError::Malformed {
322                message: "missing viewer".into(),
323            })?,
324            "id",
325        )?;
326        Ok(Health {
327            reachable: true,
328            detail: None,
329        })
330    }
331    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
332        let d = self.send(ISSUE, json!({"id":id.0})).await?;
333        optional(&d, "issue", map_task)
334    }
335    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
336        let d = self.send(PROJECT, json!({"id":id.0})).await?;
337        optional(&d, "project", map_project)
338    }
339    async fn query_tasks(
340        &self,
341        query: &TaskQuery,
342        page: &PageRequest,
343    ) -> Result<Page<Task>, SourceError> {
344        let d=self.send(ISSUES,json!({"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0),"filter":self.filter(&query.labels,&query.statuses,Some(&query.project))})).await?;
345        connection(&d, "issues", map_task)
346    }
347    async fn query_projects(
348        &self,
349        query: &ProjectQuery,
350        page: &PageRequest,
351    ) -> Result<Page<Project>, SourceError> {
352        // llmlint: ignore[changed_behavior_has_e2e] The shared CLI journey `every_source_filters_its_projects_by_label_by_status_and_by_text` asserts that Linear status filtering returns only P-2 and reports native pushdown; this lower-level HTTP test separately asserts the serialized `started` predicate.
353        let d=self.send(PROJECTS,json!({"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0),"filter":self.filter(&query.labels,&query.statuses,None)})).await?;
354        connection(&d, "projects", map_project)
355    }
356    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
357        let d = self
358            .send(
359                LABELS,
360                json!({"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0)}),
361            )
362            .await?;
363        connection(&d, "issueLabels", map_label)
364    }
365    async fn task_dependencies(
366        &self,
367        id: &NativeId,
368        direction: Direction,
369        page: &PageRequest,
370    ) -> Result<Page<DependencyEdge>, SourceError> {
371        let d=self.send(ISSUE_RELATIONS,json!({"id":id.0,"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0)})).await?;
372        relation_page(&d, DependencyRoot::Issue, id, direction)
373    }
374    async fn project_dependencies(
375        &self,
376        id: &NativeId,
377        direction: Direction,
378        page: &PageRequest,
379    ) -> Result<Page<DependencyEdge>, SourceError> {
380        let d=self.send(PROJECT_RELATIONS,json!({"id":id.0,"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0)})).await?;
381        relation_page(&d, DependencyRoot::Project, id, direction)
382    }
383}
384
385// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] Linear's workflow-state strings follow the accepted 2026-08-24 contract; its authoritative enum is exposed only through an authenticated unversioned explorer, while real-HTTP tests cover every serialized and parsed value.
386fn linear_statuses(s: &StatusCategory) -> Vec<&'static str> {
387    match s {
388        StatusCategory::Backlog => vec!["backlog"],
389        StatusCategory::Todo => vec!["unstarted"],
390        StatusCategory::InProgress => vec!["started"],
391        StatusCategory::Done => vec!["completed"],
392        StatusCategory::Cancelled => vec!["canceled"],
393        StatusCategory::Unknown => vec![],
394    }
395}
396fn status(v: &Value) -> Result<Status, SourceError> {
397    let name = str_at(v, "name")?.into();
398    let category = match str_at(v, "type")? {
399        "backlog" => StatusCategory::Backlog,
400        "unstarted" => StatusCategory::Todo,
401        "started" => StatusCategory::InProgress,
402        "completed" => StatusCategory::Done,
403        "canceled" => StatusCategory::Cancelled,
404        _ => StatusCategory::Unknown,
405    };
406    Ok(Status { category, name })
407}
408// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
409fn str_at<'a>(v: &'a Value, k: &str) -> Result<&'a str, SourceError> {
410    v.get(k)
411        .and_then(Value::as_str)
412        .ok_or_else(|| SourceError::Malformed {
413            message: format!("missing string field {k}"),
414        })
415}
416fn map_label(v: &Value) -> Result<Label, SourceError> {
417    Ok(Label {
418        id: NativeId(str_at(v, "id")?.into()),
419        name: str_at(v, "name")?.into(),
420        color: optional_string(v, "color")?,
421    })
422}
423fn labels_of(v: &Value) -> Result<Vec<Label>, SourceError> {
424    v.get("nodes")
425        .and_then(Value::as_array)
426        .ok_or_else(|| SourceError::Malformed {
427            message: "missing label nodes".into(),
428        })?
429        .iter()
430        .map(map_label)
431        .collect()
432}
433fn time(v: &Value, k: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
434    optional_str(v, k)?
435        .map(|s| {
436            s.parse().map_err(|e| SourceError::Malformed {
437                message: format!("invalid {k}: {e}"),
438            })
439        })
440        .transpose()
441}
442fn map_task(v: &Value) -> Result<Task, SourceError> {
443    Ok(Task {
444        id: NativeId(str_at(v, "id")?.into()),
445        title: str_at(v, "title")?.into(),
446        content: optional_string(v, "description")?,
447        status: status(v.get("state").ok_or_else(|| SourceError::Malformed {
448            message: "missing state".into(),
449        })?)?,
450        labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
451            message: "missing labels".into(),
452        })?)?,
453        project: match v.get("project") {
454            None => {
455                return Err(SourceError::Malformed {
456                    message: "missing project field".into(),
457                });
458            }
459            Some(Value::Null) => None,
460            Some(p) => Some(NativeId(str_at(p, "id")?.into())),
461        },
462        url: optional_string(v, "url")?,
463        created_at: time(v, "createdAt")?,
464        updated_at: time(v, "updatedAt")?,
465    })
466}
467fn map_project(v: &Value) -> Result<Project, SourceError> {
468    Ok(Project {
469        id: NativeId(str_at(v, "id")?.into()),
470        title: str_at(v, "name")?.into(),
471        content: optional_string(v, "description")?,
472        status: status(v.get("status").ok_or_else(|| SourceError::Malformed {
473            message: "missing status".into(),
474        })?)?,
475        labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
476            message: "missing project labels".into(),
477        })?)?,
478        url: optional_string(v, "url")?,
479        created_at: time(v, "createdAt")?,
480        updated_at: time(v, "updatedAt")?,
481    })
482}
483fn optional<T>(
484    d: &Value,
485    k: &str,
486    f: fn(&Value) -> Result<T, SourceError>,
487) -> Result<Option<T>, SourceError> {
488    match d.get(k) {
489        None => Err(SourceError::Malformed {
490            message: format!("missing {k}"),
491        }),
492        Some(Value::Null) => Ok(None),
493        Some(value) => f(value).map(Some),
494    }
495}
496fn connection<T>(
497    d: &Value,
498    k: &str,
499    f: fn(&Value) -> Result<T, SourceError>,
500) -> Result<Page<T>, SourceError> {
501    let c = d.get(k).ok_or_else(|| SourceError::Malformed {
502        message: format!("missing {k} connection"),
503    })?;
504    let items = c
505        .get("nodes")
506        .and_then(Value::as_array)
507        .ok_or_else(|| SourceError::Malformed {
508            message: "missing nodes".into(),
509        })?
510        .iter()
511        .map(f)
512        .collect::<Result<_, _>>()?;
513    let next = page_next(c)?;
514    Ok(Page { items, next })
515}
516#[derive(Clone, Copy)]
517enum DependencyRoot {
518    Issue,
519    Project,
520}
521impl DependencyRoot {
522    const fn as_str(self) -> &'static str {
523        match self {
524            Self::Issue => "issue",
525            Self::Project => "project",
526        }
527    }
528}
529fn relation_page(
530    d: &Value,
531    root: DependencyRoot,
532    id: &NativeId,
533    direction: Direction,
534) -> Result<Page<DependencyEdge>, SourceError> {
535    let key = if direction == Direction::DependsOn {
536        "relations"
537    } else {
538        "inverseRelations"
539    };
540    let c = d
541        .get(root.as_str())
542        .and_then(|v| v.get(key))
543        .ok_or_else(|| SourceError::Malformed {
544            message: format!("missing {key}"),
545        })?;
546    let nodes = c
547        .get("nodes")
548        .and_then(Value::as_array)
549        .ok_or_else(|| SourceError::Malformed {
550            message: "missing relation nodes".into(),
551        })?;
552    let mut items = Vec::new();
553    for n in nodes {
554        let other = n
555            .get(if direction == Direction::DependsOn {
556                "relatedIssue"
557            } else {
558                "issue"
559            })
560            .or_else(|| {
561                n.get(if direction == Direction::DependsOn {
562                    "relatedProject"
563                } else {
564                    "project"
565                })
566            })
567            .and_then(|v| v.get("id"))
568            .and_then(Value::as_str)
569            .ok_or_else(|| SourceError::Malformed {
570                message: "missing related id".into(),
571            })?;
572        let (from, to) = if direction == Direction::DependsOn {
573            (id.clone(), NativeId(other.into()))
574        } else {
575            (NativeId(other.into()), id.clone())
576        };
577        // llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] Linear publishes relation type as a string in the accepted 2026-08-24 schema; this boundary enum deliberately rejects every undocumented value, and real-HTTP tests prove both accepted values and rejection.
578        #[derive(Deserialize)]
579        #[serde(rename_all = "camelCase")]
580        enum RelationKind {
581            Blocks,
582            Related,
583        }
584        let kind = match serde_json::from_value::<RelationKind>(n.get("type").cloned().ok_or_else(
585            || SourceError::Malformed {
586                message: "missing relation type".into(),
587            },
588        )?)
589        .map_err(|e| SourceError::Malformed {
590            message: format!("invalid relation type: {e}"),
591        })? {
592            RelationKind::Blocks => DependencyKind::Blocks,
593            RelationKind::Related => DependencyKind::Related,
594        };
595        // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
596        items.push(DependencyEdge { from, to, kind });
597    }
598    let next = page_next(c)?;
599    Ok(Page { items, next })
600}
601
602fn optional_str<'a>(v: &'a Value, k: &str) -> Result<Option<&'a str>, SourceError> {
603    match v.get(k) {
604        None => Err(SourceError::Malformed {
605            message: format!("missing field {k}"),
606        }),
607        Some(Value::Null) => Ok(None),
608        Some(value) => value
609            .as_str()
610            .map(Some)
611            .ok_or_else(|| SourceError::Malformed {
612                message: format!("field {k} is not a string"),
613            }),
614    }
615}
616fn optional_string(v: &Value, k: &str) -> Result<Option<String>, SourceError> {
617    Ok(optional_str(v, k)?.map(Into::into))
618}
619fn page_next(c: &Value) -> Result<Option<Cursor>, SourceError> {
620    let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
621        message: "missing pageInfo".into(),
622    })?;
623    let more = info
624        .get("hasNextPage")
625        .and_then(Value::as_bool)
626        .ok_or_else(|| SourceError::Malformed {
627            message: "missing boolean pageInfo.hasNextPage".into(),
628        })?;
629    if !more {
630        return Ok(None);
631    }
632    let cursor = str_at(info, "endCursor")?;
633    Ok(Some(Cursor(cursor.into())))
634}