Asana
Asana's API without an SDK: tasks, projects and comments in Asana's data envelope, offset pages walked with an expired one named, opt_fields, Retry-After waited out, and webhooks from their handshake on: X-Hook-Secret answered and kept, refused once one is held, and each delivery's X-Hook-Signature checked.
Version 1.0.0 · 16 functions · 5 crates
Functions
What use asana.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use asana_me, asana_projects from asana (Quillo 0.6).
asana_me() → anyor an errorWho the token belongs to, with their workspaces.
asana_projects(workspace: str, filter: any, max_items: int) → listor an errorProjects of a workspace through every page: filter adds archived, team, opt_fields; max_items caps them (0: all).
asana_project(gid: str) → anyor an errorOne project by gid.
asana_project_create(fields: any) → anyor an errorMakes a project: name and workspace or team.
asana_project_tasks(project: str, filter: any, max_items: int) → listor an errorA project's tasks through every page: filter adds completed_since ("now" for the unfinished), opt_fields; max_items caps them (0: all).
asana_task(gid: str, opt_fields: any) → anyor an errorOne task by gid; opt_fields is a list of fields, or null for the default set.
asana_task_create(fields: any) → anyor an errorMakes a task: name, and one of workspace, projects or parent, with notes, assignee, due_on...
asana_task_update(gid: str, fields: any) → anyor an errorChanges a task.
asana_task_complete(gid: str) → anyor an errorMarks a task done.
asana_task_delete(gid: str) → boolor an errorDeletes a task.
asana_comment(gid: str, text: str) → anyor an errorWrites a comment on a task.
asana_comments(gid: str) → listor an errorA task's comments, without the system's own stories.
asana_webhook_create(name: str, resource: str, target: str, filters: any) → anyor an errorAsks Asana for a webhook on resource, calling target back. Asana calls target with the handshake before this returns, so target's route must use asana_webhook_receive with the same name, which is how the secret gets kept. filters is Asana's list, or null.
asana_webhook_delete(gid: str) → boolor an errorDeletes a webhook.
asana_webhook_receive(name: str, headers: map, body: str) → anyor an errorTakes what comes to a webhook's route, for a name of yours. The handshake carries X-Hook-Secret: this keeps it and gives { handshake: true, secret }, and the route must answer 200 with that secret in the same header. It is refused when a secret is already kept and no asana_webhook_create is under way, or anyone could replace it. A delivery has X-Hook-Signature: this checks it and gives { handshake: false, events }.
asana_signature_valid(body: str, signature: str, secret: str) → boolChecks a body against a secret you keep yourself: signature the X-Hook-Signature header.
Get it
$ quillo install asana # asana.qwer into this folder$ quillo install asana --example # and its example, asana.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/asana.qwer$ curl -O https://quillolang.org/plugins/files/asana.qweThen, in your program, all of it or only what you call:
use asana.qweruse asana_me, asana_projects from asana // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
ureq = "2"hmac = "0.12"sha2 = "0.10"hex = "0.4"serde_json = { version = "1", features = ["preserve_order"] }Example
// Work tracked in Asana: tasks made, changed, commented on and finished, lists past one page, and a// webhook from its handshake on.//// ASANA_ACCESS_TOKEN=... ASANA_WORKSPACE=... quillo run asana.qwe// then open /tasks, /lists, /webhook, /hook-check and /failures//// ASANA_API_URL reaches a test server. A webhook's target must be public; Asana calls it while the// webhook is being made, and the route must echo X-Hook-Secret back.use "asana.qwer"let workspace = env("ASANA_WORKSPACE", "")route GET /tasks { let me = asana_me()? let p = asana_project_create({ name: "Launch", workspace: workspace })? let t = asana_task_create({ name: "Write the guide", notes: "With examples", projects: [p.gid], due_on: "2026-12-01" })? asana_task_update(t.gid, { name: "Write the guide & the changelog" })? asana_comment(t.gid, "Half done")? let done = asana_task_complete(t.gid)? let few = asana_task(t.gid, ["name", "completed"])? { me: me.name, name: few.name, completed: done.completed, only_asked: contains(few, "notes"), comments: map(asana_comments(t.gid)?, (c) => c.text), in_project: map(asana_project_tasks(p.gid, {}, 0)?, (x) => x.gid == t.gid), no_home: asana_task_create({ name: "Lost" }).error, no_text: asana_comment(t.gid, " ").error, deleted: asana_task_delete(t.gid)?, gone: asana_task(t.gid, null).error, }}route GET /lists = { projects: len(asana_projects(workspace, { archived: false }, 0)?), capped: len(asana_projects(workspace, {}, 130)?), fields: asana_projects(workspace, { opt_fields: ["name", "color"] }, 1)?[0],}// Asana's handshake and its deliveries both land here.route POST /asana/webhook { let got = asana_webhook_receive("tasks", request.headers, request.body) if is_err(got) { reply 401 got.error } if got.handshake { respond(200, "", { "x-hook-secret": got.secret }) } else { { seen: len(got.events) } }}// The webhook made for real: the stand-in calls this very server back while it is made, and then// delivers an event signed with the secret that handshake gave.route GET /webhook { let target = `http://${request.headers.host}/asana/webhook` let w = asana_webhook_create("tasks", "1200000000000001", target, [{ resource_type: "task", action: "changed" }])? { active: w.active, delivered: w.last_delivery, deleted: asana_webhook_delete(w.gid)? }}// A signature made outside this plugin, by node's crypto, and an imposter's second handshake.route GET /hook-check { let body = "{\"events\":[{\"action\":\"changed\",\"resource\":{\"gid\":\"1\",\"resource_type\":\"task\"}}]}" let sig = "0f9bdbf886af2ccf15fb0e9db624e96fc013ac6fc954a86bc05f68174cc71efa" let first = asana_webhook_receive("check", { "x-hook-secret": "secret-one" }, "")? { handshake: first, good: asana_signature_valid(body, sig, "secret-one"), events: len(asana_webhook_receive("check", { "x-hook-signature": sig }, body)?.events), tampered: asana_webhook_receive("check", { "x-hook-signature": sig }, body + " ").error, imposter: asana_webhook_receive("check", { "x-hook-secret": "mine-now" }, "").error, unsigned: asana_webhook_receive("check", {}, body).error, unknown: asana_webhook_receive("other", { "x-hook-signature": sig }, body).error, }}route GET /failures = { missing: asana_project("999").error, expired: asana_projects("expired", {}, 0).error, waited: asana_project("busy")?.name, premium: asana_project("premium").error,}$ quillo run asana.qwe$ curl 'localhost:8080/tasks'$ curl 'localhost:8080/lists'$ curl 'localhost:8080/webhook'$ curl 'localhost:8080/hook-check'$ curl 'localhost:8080/failures'It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.
Source
//! Asana's API without an SDK: tasks, projects and comments in Asana's data envelope, offset pages walked with their limit and an expired offset named, opt_fields, Retry-After waited out, and webhooks: the handshake's X-Hook-Secret answered and kept, refused once one is held, and each delivery's X-Hook-Signature checked.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! hmac = "0.12"//! sha2 = "0.10"//! hex = "0.4"//! serde_json = { version = "1", features = ["preserve_order"] }use hmac::{Hmac, Mac};use serde_json::{json, Value};use std::collections::HashMap;use std::sync::atomic::{AtomicI64, Ordering};use std::sync::Mutex;use std::time::Duration;// ASANA_ACCESS_TOKEN is a personal access token; ASANA_API_URL the API// (https://app.asana.com/api/1.0 when not set). ASANA_HOOK_SECRETS_FILE keeps webhook secrets across// restarts, as one JSON map of names to secrets.fn setting(k: &str) -> Option<String> { std::env::var(k).ok().filter(|v| !v.is_empty())}fn api() -> String { setting("ASANA_API_URL").unwrap_or_else(|| "https://app.asana.com/api/1.0".into()).trim_end_matches('/').to_string()}fn enc(s: &str) -> String { s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{:02X}", b) }).collect()}fn error_text(status: u16, text: &str) -> String { let v: Value = serde_json::from_str(text).unwrap_or(Value::Null); let list: Vec<&str> = v["errors"].as_array().into_iter().flatten().filter_map(|e| e["message"].as_str()).collect(); if list.is_empty() { return format!("Asana {}: {}", status, text.trim().chars().take(300).collect::<String>()); } let hint = match status { 401 => " (check ASANA_ACCESS_TOKEN)", 402 => " (this needs a paid Asana plan)", _ => "", }; format!("Asana {}: {}{}", status, list.join("; "), hint)}fn call(method: &str, path: &str, body: Option<&Value>) -> Result<Value, String> { let token = setting("ASANA_ACCESS_TOKEN").ok_or("ASANA_ACCESS_TOKEN is not set")?; let url = format!("{}{}", api(), path); let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(60)).build(); for attempt in 0..5u32 { let req = agent.request(method, &url).set("Authorization", &format!("Bearer {}", token)).set("Accept", "application/json"); let res = match body { Some(b) => req.set("Content-Type", "application/json").send_string(&b.to_string()), None => req.call(), }; match res { Ok(r) => { let t = r.into_string().map_err(|e| e.to_string())?; return serde_json::from_str(&t).map_err(|_| format!("Asana's answer is not JSON: {}", t.chars().take(200).collect::<String>())); } Err(ureq::Error::Status(429, r)) if attempt < 4 => { let wait = r.header("Retry-After").and_then(|s| s.parse::<u64>().ok()).unwrap_or(1); std::thread::sleep(Duration::from_secs(wait.min(60))); } Err(ureq::Error::Status(c, r)) => { let t = r.into_string().unwrap_or_default(); if c == 400 && t.contains("pagination token") { return Err("Asana's offset has expired: an offset lasts minutes, so start the list again".into()); } return Err(error_text(c, &t)); } Err(e) => return Err(format!("cannot reach Asana at {}: {}", api(), e)), } } Err("Asana stayed over its limit through five tries".into())}fn data(method: &str, path: &str, body: Option<&Value>) -> Result<Value, String> { let b = body.map(|b| json!({ "data": b })); Ok(call(method, path, b.as_ref())?["data"].clone())}fn opt(fields: &Value) -> String { match fields { Value::Array(a) => a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(","), Value::String(s) => s.clone(), _ => String::new(), }}fn query(filter: &Value) -> Vec<String> { filter.as_object().map_or(vec![], |m| m.iter().map(|(k, v)| { let t = if k == "opt_fields" { opt(v) } else { v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()) }; format!("{}={}", enc(k), enc(&t)) }).collect())}// A list without `limit` is cut off or refused when long, so a limit is always sent.fn walk(path: &str, filter: &Value, max_items: i64) -> Result<Vec<Value>, String> { let mut out = vec![]; let mut offset: Option<String> = None; loop { let mut parts = query(filter); parts.push("limit=100".into()); if let Some(o) = &offset { parts.push(format!("offset={}", enc(o))); } let v = call("GET", &format!("{}?{}", path, parts.join("&")), None)?; out.extend(v["data"].as_array().cloned().unwrap_or_default()); if max_items > 0 && out.len() as i64 >= max_items { out.truncate(max_items as usize); return Ok(out); } match v["next_page"]["offset"].as_str() { Some(o) => offset = Some(o.to_string()), None => return Ok(out), } }}/// Who the token belongs to, with their `workspaces`.pub fn asana_me() -> Result<serde_json::Value, String> { data("GET", "/users/me", None)}/// Projects of a workspace through every page: `filter` adds `archived`, `team`, `opt_fields`; `max_items` caps them (0: all).pub fn asana_projects(workspace: &str, filter: serde_json::Value, max_items: i64) -> Result<Vec<serde_json::Value>, String> { let mut f = if filter.is_object() { filter } else { json!({}) }; f["workspace"] = json!(workspace); walk("/projects", &f, max_items)}/// One project by gid.pub fn asana_project(gid: &str) -> Result<serde_json::Value, String> { data("GET", &format!("/projects/{}", enc(gid)), None)}/// Makes a project: `name` and `workspace` or `team`.pub fn asana_project_create(fields: serde_json::Value) -> Result<serde_json::Value, String> { data("POST", "/projects", Some(&fields))}/// A project's tasks through every page: `filter` adds `completed_since` ("now" for the unfinished), `opt_fields`; `max_items` caps them (0: all).pub fn asana_project_tasks(project: &str, filter: serde_json::Value, max_items: i64) -> Result<Vec<serde_json::Value>, String> { walk(&format!("/projects/{}/tasks", enc(project)), &filter, max_items)}/// One task by gid; `opt_fields` is a list of fields, or null for the default set.pub fn asana_task(gid: &str, opt_fields: serde_json::Value) -> Result<serde_json::Value, String> { let f = opt(&opt_fields); let q = if f.is_empty() { String::new() } else { format!("?opt_fields={}", enc(&f)) }; data("GET", &format!("/tasks/{}{}", enc(gid), q), None)}/// Makes a task: `name`, and one of `workspace`, `projects` or `parent`, with `notes`, `assignee`, `due_on`...pub fn asana_task_create(fields: serde_json::Value) -> Result<serde_json::Value, String> { if fields.get("workspace").is_none() && fields.get("projects").is_none() && fields.get("parent").is_none() { return Err("a task needs a workspace, projects or a parent".into()); } data("POST", "/tasks", Some(&fields))}/// Changes a task.pub fn asana_task_update(gid: &str, fields: serde_json::Value) -> Result<serde_json::Value, String> { data("PUT", &format!("/tasks/{}", enc(gid)), Some(&fields))}/// Marks a task done.pub fn asana_task_complete(gid: &str) -> Result<serde_json::Value, String> { asana_task_update(gid, json!({ "completed": true }))}/// Deletes a task.pub fn asana_task_delete(gid: &str) -> Result<bool, String> { data("DELETE", &format!("/tasks/{}", enc(gid)), None)?; Ok(true)}/// Writes a comment on a task.pub fn asana_comment(gid: &str, text: &str) -> Result<serde_json::Value, String> { if text.trim().is_empty() { return Err("a comment needs text".into()); } data("POST", &format!("/tasks/{}/stories", enc(gid)), Some(&json!({ "text": text })))}/// A task's comments, without the system's own stories.pub fn asana_comments(gid: &str) -> Result<Vec<serde_json::Value>, String> { Ok(walk(&format!("/tasks/{}/stories", enc(gid)), &Value::Null, 0)?.into_iter().filter(|s| s["resource_subtype"] == "comment_added").collect())}static SECRETS: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);static MAKING: AtomicI64 = AtomicI64::new(0);fn secrets<T>(f: impl FnOnce(&mut HashMap<String, String>) -> T) -> T { let mut g = SECRETS.lock().unwrap_or_else(|e| e.into_inner()); let m = g.get_or_insert_with(|| { setting("ASANA_HOOK_SECRETS_FILE").and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() }); f(m)}/// Asks Asana for a webhook on `resource`, calling `target` back. Asana calls `target` with the handshake before this returns, so `target`'s route must use asana_webhook_receive with the same `name`, which is how the secret gets kept. `filters` is Asana's list, or null.pub fn asana_webhook_create(name: &str, resource: &str, target: &str, filters: serde_json::Value) -> Result<serde_json::Value, String> { let mut b = json!({ "resource": resource, "target": target }); if filters.is_array() { b["filters"] = filters; } secrets(|m| m.remove(name)); MAKING.fetch_add(1, Ordering::SeqCst); let r = data("POST", "/webhooks", Some(&b)); MAKING.fetch_sub(1, Ordering::SeqCst); r}/// Deletes a webhook.pub fn asana_webhook_delete(gid: &str) -> Result<bool, String> { data("DELETE", &format!("/webhooks/{}", enc(gid)), None)?; Ok(true)}fn hex_sig(secret: &str, body: &str) -> Hmac<sha2::Sha256> { let mut m = Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC takes any key"); m.update(body.as_bytes()); m}/// Takes what comes to a webhook's route, for a `name` of yours. The handshake carries X-Hook-Secret: this keeps it and gives { handshake: true, secret }, and the route must answer 200 with that secret in the same header. It is refused when a secret is already kept and no asana_webhook_create is under way, or anyone could replace it. A delivery has X-Hook-Signature: this checks it and gives { handshake: false, events }.pub fn asana_webhook_receive(name: &str, headers: std::collections::HashMap<String, serde_json::Value>, body: &str) -> Result<serde_json::Value, String> { let h = |k: &str| headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(k)).and_then(|(_, v)| v.as_str()).map(str::to_string); if let Some(s) = h("x-hook-secret") { let accepted = secrets(|m| { if m.contains_key(name) && MAKING.load(Ordering::SeqCst) == 0 { return false; } m.insert(name.to_string(), s.clone()); if let Some(p) = setting("ASANA_HOOK_SECRETS_FILE") { let _ = std::fs::write(p, json!(m).to_string()); } true }); if !accepted { return Err("a handshake came for a webhook that already has a secret, with no webhook being made".into()); } return Ok(json!({ "handshake": true, "secret": s })); } let sig = h("x-hook-signature").ok_or("no X-Hook-Signature header: this did not come from Asana")?; let secret = secrets(|m| m.get(name).cloned()).ok_or(format!("no secret is kept for webhook `{}`: was its handshake answered here?", name))?; let raw = hex::decode(sig.trim()).map_err(|_| "the X-Hook-Signature header is not hex".to_string())?; if hex_sig(&secret, body).verify_slice(&raw).is_err() { return Err("the webhook's signature does not match its secret, or the body was changed (use the raw body)".into()); } let v: Value = serde_json::from_str(body).map_err(|e| format!("the webhook's body is not JSON: {}", e))?; Ok(json!({ "handshake": false, "events": v["events"] }))}/// Checks a body against a secret you keep yourself: `signature` the X-Hook-Signature header.pub fn asana_signature_valid(body: &str, signature: &str, secret: &str) -> bool { hex::decode(signature.trim()).map_or(false, |raw| hex_sig(secret, body).verify_slice(&raw).is_ok())}Related
calendly.qwer · 1.0.0Calendly's API v2 without an SDK: event types, scheduled events and their invitees through page tokens, cancels, webhook subscriptions with a signing key insisted on, error details read, resource addresses shortened to uuids, and the Calendly-Webhook-Signature checked with its timestamp tolerance.3 views · 9 downloadsGitHub API github_api.qwer · 1.0.0Work with GitHub from a program: issues, comments, pull requests and check runs, every list followed through its Link header, primary and secondary rate limits waited out or named, webhook signatures checked, and a GitHub App's RS256 JWT and installation tokens made and kept.2 views · 7 downloadsGitLab API gitlab_api.qwer · 1.0.0Work with GitLab from a program: issues, merge requests, notes and pipelines, projects named by their path, lists followed by X-Next-Page or keyset links, errors read whether they are texts or maps of fields, 429s waited out, and the webhook's token compared in constant time.4 views · 6 downloadsZendesk zendesk.qwer · 1.0.0Run a help desk through Zendesk's API: tickets opened, changed and solved, public replies and internal notes, search and its export past 1000 results, every list walked by cursor, validation errors named by field, 429s waited out, and webhook signatures checked.2 views · 11 downloadsCloudinary cloudinary.qwer · 1.0.0Cloudinary without an SDK: signed uploads from a URL or a file, the signature a browser needs for a direct upload, delivery URLs with transformations and URL signatures, webhook and upload signatures checked, and assets listed by cursor.3 views · 8 downloadsDiscord discord.qwer · 1.0.0Messages to Discord channels through webhooks: embeds with color, fields, images and timestamps built and checked against Discord's limits, mentions off unless asked for, threads, and the rate limit's wait reported.7 views · 8 downloadsThread 0 posts
On Asana: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.