Skip to main content

gonzalo_ticket_asana/
source.rs

1//! A read-only [`TicketSource`] backed by the Asana REST API.
2//!
3//! Authenticates with a personal access token (bearer). Phase 1 imports a
4//! single project's tasks via `fetch_changed` (offset-paginated) and `get`. An
5//! optional [`StateMapping`] selects the status signal (completed / section /
6//! custom field; see [`crate::mapping`]). `capabilities` reports
7//! `single_assignee` and `custom_fields`. HTTP-level behavior is exercised by
8//! the conformance suite's fixtures (#20).
9
10use crate::mapping::{AsanaTask, task_to_ticket};
11use async_trait::async_trait;
12use gonzalo_domain::{StateCategory, Ticket};
13use gonzalo_ticket::{
14    Capabilities, Cursor, Page, Result, SourceError, StateMapping, StateSignal, TicketSource,
15    provider_error,
16};
17use serde::Deserialize;
18
19const DEFAULT_BASE: &str = "https://app.asana.com/api/1.0";
20
21/// Fields requested explicitly — Asana returns a minimal object otherwise.
22const OPT_FIELDS: &str = "name,notes,html_notes,completed,assignee.name,created_by.name,\
23    memberships.project.name,memberships.project.gid,memberships.section.name,\
24    memberships.section.gid,custom_fields.gid,custom_fields.enum_value.name,\
25    custom_fields.display_value,tags.name,permalink_url";
26
27#[derive(Debug, Deserialize)]
28struct ListResponse {
29    #[serde(default)]
30    data: Vec<AsanaTask>,
31    #[serde(default)]
32    next_page: Option<NextPage>,
33}
34
35#[derive(Debug, Deserialize)]
36struct NextPage {
37    #[serde(default)]
38    offset: Option<String>,
39}
40
41#[derive(Debug, Deserialize)]
42struct OneResponse {
43    data: AsanaTask,
44}
45
46/// Imports tasks from a single Asana project.
47pub struct AsanaSource {
48    client: reqwest::Client,
49    base: reqwest::Url,
50    project: String,
51    token: String,
52    mapping: Option<StateMapping>,
53}
54
55impl AsanaSource {
56    /// Import tasks from the Asana `project` gid, authenticating with a PAT.
57    pub fn new(project: impl Into<String>, token: impl Into<String>) -> Result<Self> {
58        Self::with_base(DEFAULT_BASE, project, token)
59    }
60
61    /// As [`AsanaSource::new`] but against a custom API base (e.g. a test
62    /// server).
63    pub fn with_base(
64        base: &str,
65        project: impl Into<String>,
66        token: impl Into<String>,
67    ) -> Result<Self> {
68        let client = reqwest::Client::builder()
69            .user_agent("gonzalo-ticket-asana")
70            .build()
71            .map_err(be)?;
72        Ok(Self {
73            client,
74            base: reqwest::Url::parse(base).map_err(be)?,
75            project: project.into(),
76            token: token.into(),
77            mapping: None,
78        })
79    }
80
81    /// Apply a per-connection [`StateMapping`] selecting the status signal
82    /// (completed / section / custom field).
83    #[must_use]
84    pub fn with_mapping(mut self, mapping: StateMapping) -> Self {
85        self.mapping = Some(mapping);
86        self
87    }
88
89    fn url(&self, segments: &[&str]) -> Result<reqwest::Url> {
90        let mut url = self.base.clone();
91        url.path_segments_mut()
92            .map_err(|_| SourceError::Backend("base URL cannot be a base".into()))?
93            .extend(segments);
94        Ok(url)
95    }
96
97    fn auth(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
98        rb.bearer_auth(&self.token)
99    }
100}
101
102#[async_trait]
103impl TicketSource for AsanaSource {
104    fn capabilities(&self) -> Capabilities {
105        Capabilities {
106            single_assignee: true,
107            custom_fields: true,
108            push: true,
109            comments: true,
110            ..Capabilities::default()
111        }
112    }
113
114    async fn fetch_changed(&self, cursor: &Cursor) -> Result<Page> {
115        let mut url = self.url(&["tasks"])?;
116        {
117            let mut q = url.query_pairs_mut();
118            q.append_pair("project", &self.project);
119            q.append_pair("opt_fields", OPT_FIELDS);
120            q.append_pair("limit", "100");
121            if let Some(offset) = &cursor.0 {
122                q.append_pair("offset", offset);
123            }
124        }
125        let resp = self
126            .auth(self.client.get(url))
127            .send()
128            .await
129            .map_err(be)?
130            .check_status()
131            .await?;
132        let list: ListResponse = resp.json().await.map_err(be)?;
133        let tickets = list
134            .data
135            .iter()
136            .map(|t| task_to_ticket(t, self.mapping.as_ref()))
137            .collect();
138        Ok(Page {
139            tickets,
140            next: Cursor(list.next_page.and_then(|p| p.offset)),
141        })
142    }
143
144    async fn get(&self, uid: &str) -> Result<Ticket> {
145        let mut url = self.url(&["tasks", uid])?;
146        url.query_pairs_mut().append_pair("opt_fields", OPT_FIELDS);
147        let resp = self
148            .auth(self.client.get(url))
149            .send()
150            .await
151            .map_err(be)?
152            .check_status()
153            .await?;
154        let one: OneResponse = resp.json().await.map_err(be)?;
155        Ok(task_to_ticket(&one.data, self.mapping.as_ref()))
156    }
157
158    async fn set_state(&self, uid: &str, target: StateCategory) -> Result<()> {
159        // The portable Asana write is the `completed` flag: terminal categories
160        // complete the task, others reopen it.
161        let terminal = matches!(target, StateCategory::Done | StateCategory::Canceled);
162        // When a section / custom-field mapping drives the read path, the
163        // category comes from that field, not the `completed` bool. Only a
164        // terminal move is expressible via the completed flag; a non-terminal
165        // move would toggle `completed` without touching the section / custom
166        // field, so the read path would still report the field's category.
167        // Reject it rather than reporting a false success (#143). (Section /
168        // custom-field write-back is out of scope here.)
169        if !terminal
170            && matches!(
171                self.mapping.as_ref().map(|m| &m.signal),
172                Some(StateSignal::Section | StateSignal::CustomField { .. })
173            )
174        {
175            return Err(SourceError::Unsupported(
176                "non-terminal set_state under a section/custom-field mapping (section/custom-field write-back not implemented)",
177            ));
178        }
179        let completed = terminal;
180        let url = self.url(&["tasks", uid])?;
181        self.auth(
182            self.client
183                .put(url)
184                .json(&serde_json::json!({ "data": { "completed": completed } })),
185        )
186        .send()
187        .await
188        .map_err(be)?
189        .check_status()
190        .await?;
191        Ok(())
192    }
193
194    async fn comment(&self, uid: &str, body: &str) -> Result<()> {
195        let url = self.url(&["tasks", uid, "stories"])?;
196        self.auth(
197            self.client
198                .post(url)
199                .json(&serde_json::json!({ "data": { "text": body } })),
200        )
201        .send()
202        .await
203        .map_err(be)?
204        .check_status()
205        .await?;
206        Ok(())
207    }
208}
209
210fn be<E: std::fmt::Display>(e: E) -> SourceError {
211    SourceError::Backend(e.to_string())
212}
213
214/// Fail with the provider's own message instead of reqwest's generic one.
215///
216/// reqwest's `error_for_status()` discards the response body, which is exactly
217/// where a ticket provider puts the reason — expired token, missing scope,
218/// malformed query, rate-limit window. Swapping it for `check_status()` keeps
219/// the reason (#240). The message format is shared via
220/// [`provider_error`](gonzalo_ticket::provider_error) so the connectors cannot
221/// drift apart.
222trait CheckStatus: Sized {
223    async fn check_status(self) -> Result<Self>;
224}
225
226impl CheckStatus for reqwest::Response {
227    async fn check_status(self) -> Result<Self> {
228        let status = self.status();
229        if status.is_success() {
230            return Ok(self);
231        }
232        // Read the body only on the failure path; the caller decodes success.
233        let body = self.text().await.unwrap_or_default();
234        Err(provider_error(status.as_u16(), &body))
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn builds_task_urls() {
244        let src = AsanaSource::new("1201", "tok").unwrap();
245        assert_eq!(
246            src.url(&["tasks"]).unwrap().as_str(),
247            "https://app.asana.com/api/1.0/tasks"
248        );
249        assert_eq!(
250            src.url(&["tasks", "1201"]).unwrap().as_str(),
251            "https://app.asana.com/api/1.0/tasks/1201"
252        );
253    }
254
255    #[test]
256    fn capabilities_reflect_asana_shape() {
257        let caps = AsanaSource::new("1201", "tok").unwrap().capabilities();
258        assert!(caps.single_assignee);
259        assert!(caps.custom_fields);
260        assert!(caps.push);
261        assert!(caps.comments);
262    }
263}