sema-asana

0.1.0

Asana REST API (v1) client: tasks, projects, workspaces, comments, and pagination

$ sema pkg install sema-asana
Readme Versions 1 Dependencies 0

sema-asana

Asana REST API (v1) client: tasks, projects, workspaces, comments, and pagination.

Install

sema pkg add sema-asana

Authentication

The client uses an Asana personal access token (PAT):

  1. Create one at https://app.asana.com/0/my-apps (My apps → Personal access tokens).
  2. Export it — asana/client reads ASANA_ACCESS_TOKEN, then ASANA_TOKEN:
export ASANA_ACCESS_TOKEN="1/1234567890:abcdef..."

Or pass it explicitly: (asana/client {:token (env "ASANA_ACCESS_TOKEN")}). Never put a literal token in source. If no token is found the constructor raises an error naming both env vars.

Quick start

(import "sema-asana")

(define client (asana/client))

;; Who am I, and where?
(define me (asana/me client))
(define ws (first (asana/workspaces client)))
(println (:name me) "in" (:name ws))

;; Create a task, comment on it, complete it.
(define task
  (asana/create-task! client
    {:name      "Ship sema-asana"
     :notes     "Created from Sema"
     :workspace (:gid ws)}))

(asana/comment! client (:gid task) "On it — tests are green.")
(asana/complete-task! client (:gid task))
; => {:gid "12007..." :completed #t ...}

Data unwrapping

Asana wraps every payload in a {"data": ...} envelope. This client unwraps it for you: functions return the payload itself (a keyword-keyed map, or a list of them), not the envelope. Request bodies go the other way — pass plain field maps ({:name "..."}) and the client wraps them as {:data {...}} automatically (see asana/wrap-body). Use :raw #t on asana/request when you need the full response envelope (e.g. for pagination cursors).

API

Function Description
(asana/client opts?) Build a client map. :token, :base-url, :timeout
(asana/request client method path opts?) Raw API call — the escape hatch. :query, :body, :raw
(asana/paginate client path opts?) Follow next_page offsets, return all items. :query, :limit, :max-pages
(asana/query-string params) Build an encoded query string from a map
(asana/wrap-body body) Wrap a body as {:data body} — what asana/request sends
(asana/me client) The authenticated user
(asana/workspaces client opts?) List workspaces
(asana/projects client workspace-gid opts?) List projects in a workspace
(asana/project client project-gid opts?) Fetch one project
(asana/sections client project-gid opts?) List a project's sections
(asana/tasks client filters opts?) List tasks by :project, or :assignee + :workspace
(asana/task client task-gid opts?) Fetch one task
(asana/create-task! client fields) Create a task
(asana/update-task! client task-gid fields) Update task fields
(asana/complete-task! client task-gid) Mark a task complete
(asana/delete-task! client task-gid) Delete a task
(asana/comment! client task-gid text) Comment on a task
(asana/stories client task-gid opts?) List a task's stories (comments/activity)
(asana/typeahead client workspace-gid resource-type query opts?) Search a workspace

asana/client

(asana/client)
(asana/client {:token "..." :base-url "https://app.asana.com/api/1.0" :timeout 30000})

Returns a plain client map {:token ... :base-url ... :timeout ...} passed as the first argument to every other function. Token resolution: :token option → ASANA_ACCESS_TOKENASANA_TOKEN → error. Defaults: base URL https://app.asana.com/api/1.0, timeout 30000 ms.

asana/request

(asana/request client "GET" "/users/me")
(asana/request client "POST" "/tasks" {:body {:name "Task" :workspace "123"}})
(asana/request client "GET" "/tasks" {:query {:project "456" :limit 5} :raw #t})

The escape hatch for any endpoint the wrappers don't cover. Options:

  • :query — map of query params (see asana/query-string)
  • :body — map, automatically wrapped as {:data body} and JSON-encoded
  • :raw#t to return the full decoded response (:data and siblings like :next_page) instead of just the unwrapped :data

On a non-2xx status it raises an error containing the method, path, status, and Asana's first error message:

(try
  (asana/task client "999999999")
  (catch e (println (:message e))))
; asana: GET /tasks/999999999 → 404: task: Unknown object: 999999999

asana/paginate

(asana/paginate client "/tasks" {:query {:project "456"} :limit 100 :max-pages 5})

Asana paginates with offset cursors: a page is {"data": [...], "next_page": {"offset": "...", ...}}. asana/paginate requests pages with ?limit=N, follows next_page.offset until it runs out, and returns the concatenated :data items as one list. Defaults: :limit 50, :max-pages 10 (a safety cap — raise it for big result sets). All list wrappers (asana/workspaces, asana/projects, asana/tasks, asana/sections, asana/stories) accept the same options and paginate for you — callers never touch offsets.

asana/query-string

(asana/query-string {:workspace "123" :limit 50})
; => "limit=50&workspace=123"

(asana/query-string {:opt_fields (list "name" "completed" "assignee.name")})
; => "opt_fields=name%2Ccompleted%2Cassignee.name"

Keys are keywords or strings; values are percent-encoded. Lists join with commas (handy for opt_fields), booleans become true/false, keywords use their name. Returns "" for an empty map — no leading ?.

Tip: every GET accepts an opt_fields param controlling which fields Asana returns — e.g. (asana/task client gid {:query {:opt_fields "name,completed,due_on"}}).

asana/me

(asana/me client)
; => {:gid "1200..." :name "Helge Sverre" :resource_type "user" ...}

asana/workspaces

(asana/workspaces client)
; => ({:gid "1200..." :name "My Workspace" :resource_type "workspace"} ...)

asana/projects / asana/project / asana/sections

(asana/projects client workspace-gid)
(asana/projects client workspace-gid {:query {:archived #f}})
(asana/project  client project-gid {:query {:opt_fields "name,notes"}})
(asana/sections client project-gid)

asana/tasks / asana/task

(asana/tasks client {:project "456"})
(asana/tasks client {:assignee "me" :workspace "123" :completed_since "now"})
(asana/task  client task-gid)

Asana requires either :project, or :assignee together with :workspaceasana/tasks validates this up front and raises a helpful error otherwise. Extra filter keys (:completed_since, :modified_since, :section, :opt_fields, ...) pass straight through as query params.

asana/create-task! / asana/update-task! / asana/complete-task! / asana/delete-task!

(asana/create-task! client {:name "Task" :workspace "123" :notes "..."})
(asana/create-task! client {:name "Task" :projects (list "456")})
(asana/update-task! client task-gid {:due_on "2026-08-01"})
(asana/complete-task! client task-gid)
(asana/delete-task! client task-gid)

asana/create-task! requires a non-empty :name plus a location — one of :workspace, :projects (list of project gids), or :parent. Mutating functions return the created/updated resource.

asana/comment! / asana/stories

(asana/comment! client task-gid "Looks good — shipping it.")
(asana/stories  client task-gid)

Comments are Asana stories; asana/stories returns all of a task's stories, including system activity (:type is "comment" for comments).

asana/typeahead

(asana/typeahead client workspace-gid "task" "quarterly report")
(asana/typeahead client workspace-gid "project" "website" {:query {:count 5}})

Workspace search. resource-type is one of "task", "project", "user", "tag", "team", "portfolio", "goal", "custom_field".

Error handling

Non-2xx responses raise (error ...) with the pattern asana: <METHOD> <path> → <status>: <Asana's first error message>. Argument mistakes (bad gid, missing filters, tokenless client) are validated locally and raise before any request is made. Catch with try/catch; the message is at (:message e).

Testing

sema pkg add sema-test   # once
sema tests.sema

Offline tests (client construction, query strings, body wrapping, validation) always run. Live tests against the real API run only when ASANA_ACCESS_TOKEN or ASANA_TOKEN is set.

License

MIT

VersionSizePublished
0.1.0 8 KB 2026-07-07 21:27:52
No dependencies