ex_asana

Elixir client library for the Asana API with cursor pagination exposed as lazy streams.

Quick start

client = Asana.client(token: System.fetch_env!("ASANA_TOKEN"))

client
|> Asana.Tasks.list("120000000000001", opt_fields: ["gid", "name", "completed"])
|> Enum.take(10)

Asana.Tasks.list/3 returns a stream and automatically follows next_page.offset as needed.

You can also use the generated operation modules through the stream wrapper:

Asana.Tasks.list_openapi("120000000000001",
  token: System.fetch_env!("ASANA_TOKEN"),
  opt_fields: ["gid", "name", "completed"]
)
|> Enum.take(10)

Other generated stream wrappers follow the same shape:

Asana.Projects.list_openapi("workspace_gid", token: token) |> Enum.take(10)
Asana.Users.list_openapi("workspace_gid", token: token) |> Enum.take(10)
Asana.Workspaces.list_openapi(token: token) |> Enum.take(10)
Asana.Attachments.list_openapi("task_gid", token: token) |> Enum.take(10)

API Surface (v0.1)

Module Stream Wrappers Convenience Wrappers Return Type
Asana.Taskslist/3, list_openapi/2get/create/update/complete/reopen/delete, duplicate/duplicate_and_wait%Asana.Task{} (except duplicate ops: job map)
Asana.Projectslist_openapi/2get/create/update/archive/unarchive/delete, duplicate/duplicate_and_wait, add/remove_members, add/remove_followers%Asana.Project{} (except duplicate ops: job map)
Asana.Userslist_openapi/2get/update, get_for_workspace/update_for_workspace%Asana.User{}
Asana.Workspaceslist_openapi/1get/update, add_user/remove_user%Asana.Workspace{} for get/update, %Asana.User{} for add_user, %{} for remove_user
Asana.Attachmentslist_openapi/2upload/get/delete attachment map (upload/get) and %{} for delete
Asana.Jobs n/a get, wait_until_complete, wait_for_duplicate job map

Convenience wrappers for single-resource operations return unwrapped data:

{:ok, task} = Asana.Tasks.get_openapi("task_gid", token: token)
{:ok, task} = Asana.Tasks.create_openapi(%{name: "Write docs", workspace: "workspace_gid"}, token: token)
{:ok, task} = Asana.Tasks.complete_openapi("task_gid", token: token)
{:ok, task} = Asana.Tasks.reopen_openapi("task_gid", token: token)
{:ok, _} = Asana.Tasks.delete_openapi("task_gid", token: token)
{:ok, job} = Asana.Tasks.duplicate_openapi("task_gid", %{name: "Copy"}, token: token)
{:ok, job} = Asana.Tasks.duplicate_and_wait_openapi("task_gid", %{name: "Copy"}, token: token)

{:ok, project} = Asana.Projects.get_openapi("project_gid", token: token)
{:ok, project} = Asana.Projects.update_openapi("project_gid", %{name: "Renamed"}, token: token)
{:ok, project} = Asana.Projects.archive_openapi("project_gid", token: token)
{:ok, project} = Asana.Projects.unarchive_openapi("project_gid", token: token)
{:ok, _} = Asana.Projects.delete_openapi("project_gid", token: token)
{:ok, job} = Asana.Projects.duplicate_openapi("project_gid", %{name: "Copy"}, token: token)
{:ok, job} = Asana.Projects.duplicate_and_wait_openapi("project_gid", %{name: "Copy"}, token: token)

{:ok, user} = Asana.Users.get_openapi("user_gid", token: token)
{:ok, user} = Asana.Users.update_openapi("user_gid", %{name: "Renamed User"}, token: token)
{:ok, user} = Asana.Users.get_for_workspace_openapi("workspace_gid", "user_gid", token: token)
{:ok, user} = Asana.Users.update_for_workspace_openapi("workspace_gid", "user_gid", %{name: "Renamed User"}, token: token)
{:ok, workspace} = Asana.Workspaces.get_openapi("workspace_gid", token: token)
{:ok, workspace} = Asana.Workspaces.update_openapi("workspace_gid", %{name: "Renamed Workspace"}, token: token)
{:ok, user} = Asana.Workspaces.add_user_openapi("workspace_gid", %{user: "user_gid"}, token: token)
{:ok, _} = Asana.Workspaces.remove_user_openapi("workspace_gid", %{user: "user_gid"}, token: token)

{:ok, attachment} =
  Asana.Attachments.upload_openapi(
    %{parent: "task_gid", file: {File.stream!("spec.pdf"), filename: "spec.pdf"}},
    token: token
  )
{:ok, attachment} = Asana.Attachments.get_openapi("attachment_gid", token: token)
{:ok, _} = Asana.Attachments.delete_openapi("attachment_gid", token: token)

Task wrappers (get/create/update/complete/reopen) return %Asana.Task{}. Project wrappers (get/create/update/archive/unarchive) return %Asana.Project{}. User wrappers (get/update/get_for_workspace/update_for_workspace) return %Asana.User{}. Workspace wrappers (get/update) return %Asana.Workspace{}. Asana.Workspaces.add_user_openapi/3 returns %Asana.User{}. Asana.Workspaces.remove_user_openapi/3 returns %{} data. Asana.Attachments.upload_openapi/2 uses multipart upload (form-data) and returns attachment data map.

Duplicate operations return jobs. Poll to completion with:

{:ok, job} = Asana.Tasks.duplicate_openapi("task_gid", %{name: "Copy"}, token: token)
{:ok, done_job} = Asana.Jobs.wait_until_complete(job["gid"], token: token)
# or directly from duplicate result tuple:
{:ok, done_job} = Asana.Jobs.wait_for_duplicate({:ok, job}, token: token)

Webhook signature helper

If you run your own webhook endpoint, Asana.Webhook.Signature can validate the x-hook-signature header:

valid? = Asana.Webhook.Signature.valid?(signature_header, webhook_secret, raw_body)

Development status

Guides

OpenAPI generation

Place the Asana OpenAPI spec at priv/openapi/asana.yaml, then run:

mix api.gen.asana

To pull the latest upstream spec and regenerate in one step:

mix api.refresh.asana

mix api.gen.asana first flattens schema allOf inheritance into priv/openapi/asana_flat.json and then runs oapi_generatordefault.

Generated modules are written under lib/asana/generated in the Asana.Generated namespace.

Live smoke test

The suite excludes :live tests by default.

Run the real API smoke test with:

ASANA_TOKEN=your_token mix test --include live test/asana/live_smoke_test.exs

Local quality commands

# matches CI jobs (includes dialyzer)
mix ci

# faster local loop (no dialyzer)
mix ci.fast