GraphApi

Elixir client for the Microsoft Graph API.

Built with Req for modern HTTP handling, featuring automatic token management, OData query building, stream-based pagination, and typed error handling.

Installation

Add keen_microsoft_graphapi to your list of dependencies in mix.exs:

def deps do
  [
    {:keen_microsoft_graphapi, "~> 1.0.0-rc.1"}
  ]
end

Configuration

Single-Tenant Setup

Add to your config/runtime.exs:

config :keen_microsoft_graphapi, :config,
  tenant_id: System.fetch_env!("AZURE_TENANT_ID"),
  client_id: System.fetch_env!("AZURE_CLIENT_ID"),
  client_secret: System.fetch_env!("AZURE_CLIENT_SECRET")

Then call any resource module directly:

{:ok, %{"value" => users}} = GraphApi.Users.list()
{:ok, user} = GraphApi.Users.get("user@contoso.com")

Multi-Tenant Setup

Build an explicit client for each tenant:

config = GraphApi.Config.new!(
  tenant_id: "tenant-aaa",
  client_id: "client-bbb",
  client_secret: "secret-ccc"
)

client = GraphApi.Client.new(config: config)
{:ok, users} = GraphApi.Users.list(client: client)

OData Queries

Use the functional builder to construct query parameters:

alias GraphApi.OData

query = OData.new()
  |> OData.select(["displayName", "mail", "id"])
  |> OData.filter("department eq 'Engineering'")
  |> OData.top(25)
  |> OData.orderby("displayName")

{:ok, response} = GraphApi.Users.list(query: query)

Supported parameters: $select, $filter, $expand, $top, $skip, $orderby, $count, $search.

Schema-Aware Filter Builder

Build type-safe $filter expressions using snake_case field names from schema modules:

alias GraphApi.OData.Filter
alias GraphApi.Schema.User

# Simple keyword syntax for equality conditions
query = OData.new()
  |> OData.filter(User, company_name: "Contoso", account_enabled: true)
# => $filter=companyName eq 'Contoso' and accountEnabled eq true

# Full builder for complex filters
filter =
  Filter.new(User)
  |> Filter.where(:display_name, :starts_with, "A")
  |> Filter.where(:account_enabled, :eq, true)
  |> Filter.or_where(:company_name, :eq, "Fabrikam")

OData.new() |> OData.filter(filter)
# => $filter=startsWith(displayName,'A') and accountEnabled eq true or companyName eq 'Fabrikam'

Supported operators: :eq, :ne, :gt, :lt, :ge, :le, :starts_with, :ends_with, :contains, :in, :is_nil. Raw string filters still work as a fallback.

Schema Casting

All resource functions accept an :as option to cast responses into typed Elixir structs:

alias GraphApi.Schema.User

# Single item — returns a struct
{:ok, user} = GraphApi.Users.get("user-id", as: User)
# => %User{id: "abc", display_name: "Alice", mail: "alice@contoso.com", ...}

# List — casts each item in "value"
{:ok, %{"value" => users}} = GraphApi.Users.list(as: User)
# => [%User{}, %User{}, ...]

# Combine with $select — only fetch the fields you need
query = OData.new() |> OData.select(["id", "displayName", "mail"])
{:ok, %{"value" => users}} = GraphApi.Users.list(query: query, as: User)
# => [%User{id: "abc", display_name: "Alice", mail: "alice@...", job_title: nil, ...}]

Nested objects are recursively cast — e.g., password_profile becomes %PasswordProfile{}, lists of assigned_licenses become [%AssignedLicense{}].

For field projections, define a View module to auto-inject $select:

defmodule MyApp.UserSummary do
  use GraphApi.View,
    schema: GraphApi.Schema.User,
    fields: [:id, :display_name, :mail]
end

# Automatically adds $select=id,displayName,mail
{:ok, %{"value" => users}} = GraphApi.Users.list(as: MyApp.UserSummary)
# => [%MyApp.UserSummary{id: "abc", display_name: "Alice", mail: "alice@..."}, ...]

Pagination

Stream-based pagination that lazily follows @odata.nextLink:

{:ok, first_page} = GraphApi.Users.list(client: client)

# Lazy stream
all_users = GraphApi.Pagination.stream(first_page, client: client)
  |> Enum.to_list()

# Or collect all at once
{:ok, all_users} = GraphApi.Pagination.collect_all(first_page, client: client)

Batch Requests

Send up to 20 requests in a single HTTP call using JSON batching. Every resource function has a _query variant that returns a %Batch.Request{} instead of executing immediately:

alias GraphApi.{Batch, OData, Users, Groups, Calendar}
alias GraphApi.Schema.{User, Group, Event}

query = OData.new() |> OData.select(["id", "displayName"]) |> OData.top(5)

{:ok, responses} =
  Batch.new()
  |> Batch.add("1", Users.list_query(query: query, as: User))
  |> Batch.add("2", Groups.get_query("group-id", as: Group))
  |> Batch.add("3", Calendar.list_events_query("user-id", as: Event))
  |> Batch.execute(client: client)

Each response is individually accessible and auto-cast to its schema:

%{status: 200, body: %{"value" => users}} = Batch.get(responses, "1")
# users => [%User{id: "...", display_name: "Alice"}, ...]

%{status: 200, body: group} = Batch.get(responses, "2")
# group => %Group{id: "...", display_name: "Engineering"}

Sequential Dependencies

Use depends_on to control execution order within a batch:

Batch.new()
|> Batch.add("1", Users.create_query(%{"displayName" => "New User"}, as: User))
|> Batch.add("2", Groups.add_member_query("group-id", "new-user-id"), depends_on: ["1"])
|> Batch.execute(client: client)

Delta Queries

Delta queries let you track incremental changes to resources. Instead of fetching the full dataset every time, you get only what changed since your last sync.

Initial Sync

# Fetch all current users + get a delta_link for future syncs
{:ok, page} = GraphApi.Delta.query("/users/delta", client: client)
# page.items => [all current users]
# page.delta_link => "https://graph...?$deltatoken=..."

# Store delta_link somewhere persistent (database, ETS, etc.)

Incremental Sync

# Later, fetch only changes since last sync
{:ok, changes} = GraphApi.Delta.query(stored_delta_link, client: client)

for item <- changes.items do
  case item do
    %{"@removed" => %{"reason" => reason}} ->
      # Item was deleted
      delete_from_local_store(item["id"])

    user ->
      # Item was created or updated
      upsert_local_store(user)
  end
end

# Store the new delta_link for next sync
save_delta_link(changes.delta_link)

Collect All Pages

For initial syncs that span multiple pages, collect_all/2 follows all @odata.nextLink pages automatically:

{:ok, result} = GraphApi.Delta.collect_all("/users/delta", client: client)
# result.items => all items across all pages
# result.delta_link => final delta link for future syncs

Lazy Streaming

Stream items across pages without loading everything into memory:

{:ok, first_page} = GraphApi.Delta.query("/users/delta", client: client)

first_page
|> GraphApi.Delta.stream(client: client)
|> Stream.filter(fn item -> item["@removed"] == nil end)
|> Enum.each(&process_user/1)

Schema Casting with Delta

Delta queries support :as for schema casting. Deleted items (with @removed) are kept as raw maps:

{:ok, changes} = GraphApi.Delta.query(delta_link,
  client: client,
  as: GraphApi.Schema.User
)

Enum.each(changes.items, fn
  %GraphApi.Schema.User{} = user ->
    IO.puts("Updated: #{user.display_name}")

  %{"@removed" => _} = removed ->
    IO.puts("Deleted: #{removed["id"]}")
end)

Error Handling

All operations return {:ok, result}, :ok, or {:error, error}:

case GraphApi.Users.get("user-id") do
  {:ok, user} ->
    IO.puts("Found: #{user["displayName"]}")

  {:error, %GraphApi.Error.ApiError{status: 404}} ->
    IO.puts("User not found")

  {:error, %GraphApi.Error.AuthError{}} ->
    IO.puts("Authentication failed")

  {:error, %GraphApi.Error.RateLimitError{retry_after: seconds}} ->
    IO.puts("Rate limited, retry after #{seconds}s")
end

Delegated Auth (OAuth Authorization Code Flow)

For accessing resources on behalf of a signed-in user (delegated permissions), use the authorization code flow:

Step 1: Redirect to Microsoft Login

alias GraphApi.Auth.Delegated

url = Delegated.authorize_url(
  tenant_id: "your-tenant-id",
  client_id: "your-client-id",
  redirect_uri: "http://localhost:4000/auth/callback",
  scope: "User.Read Mail.Read offline_access",
  state: generate_csrf_token()
)

# Redirect the user to this URL

Step 2: Exchange Code for Tokens

# In your callback handler
{:ok, tokens} = Delegated.exchange_code(
  tenant_id: "your-tenant-id",
  client_id: "your-client-id",
  client_secret: "your-client-secret",
  code: params["code"],
  redirect_uri: "http://localhost:4000/auth/callback"
)

# tokens.access_token — use for API calls
# tokens.refresh_token — store for refreshing later
# tokens.expires_in — seconds until expiry

Step 3: Refresh When Expired

{:ok, new_tokens} = Delegated.refresh_token(
  tenant_id: "your-tenant-id",
  client_id: "your-client-id",
  client_secret: "your-client-secret",
  refresh_token: stored_refresh_token
)

Using the Token

Pass the delegated access token via the :access_token option:

{:ok, me} = GraphApi.Users.get("me", access_token: tokens.access_token)
{:ok, messages} = GraphApi.Mail.list_messages("me", access_token: tokens.access_token)

Testing

The library uses Req.Test for stubbing HTTP calls in tests. Pass a pre-configured Req client via the client: option:

test "lists users" do
  Req.Test.stub(:my_stub, fn conn ->
    Req.Test.json(conn, %{"value" => [%{"id" => "1", "displayName" => "Alice"}]})
  end)

  client = Req.new(plug: {Req.Test, :my_stub})
  assert {:ok, %{"value" => [user]}} = GraphApi.Users.list(client: client)
  assert user["displayName"] == "Alice"
end

Resource Modules

Users

{:ok, %{"value" => users}} = GraphApi.Users.list()
{:ok, user} = GraphApi.Users.get("user-id")
{:ok, user} = GraphApi.Users.create(%{"displayName" => "Alice", ...})
{:ok, user} = GraphApi.Users.update("user-id", %{"jobTitle" => "Engineer"})
:ok = GraphApi.Users.delete("user-id")
{:ok, %{"value" => reports}} = GraphApi.Users.list_direct_reports("user-id")
{:ok, %{"value" => groups}} = GraphApi.Users.list_member_of("user-id")

Groups

{:ok, %{"value" => groups}} = GraphApi.Groups.list()
{:ok, group} = GraphApi.Groups.get("group-id")
{:ok, %{"value" => members}} = GraphApi.Groups.list_members("group-id")
:ok = GraphApi.Groups.add_member("group-id", "user-id")
:ok = GraphApi.Groups.remove_member("group-id", "user-id")

Mail

{:ok, %{"value" => messages}} = GraphApi.Mail.list_messages("user-id")
{:ok, msg} = GraphApi.Mail.get_message("user-id", "message-id")

:ok = GraphApi.Mail.send_mail("user-id", %{
  subject: "Hello",
  body: %{contentType: "Text", content: "Hi there"},
  toRecipients: [%{emailAddress: %{address: "bob@contoso.com"}}]
})

{:ok, %{"value" => folders}} = GraphApi.Mail.list_mail_folders("user-id")

Calendar

{:ok, %{"value" => events}} = GraphApi.Calendar.list_events("user-id")
{:ok, event} = GraphApi.Calendar.create_event("user-id", %{"subject" => "Meeting"})

{:ok, %{"value" => view}} = GraphApi.Calendar.calendar_view("user-id",
  start_date_time: "2024-01-01T00:00:00",
  end_date_time: "2024-01-31T23:59:59"
)

{:ok, %{"value" => calendars}} = GraphApi.Calendar.list_calendars("user-id")

Files (OneDrive/SharePoint)

{:ok, drive} = GraphApi.Files.get_drive("user-id")
{:ok, %{"value" => items}} = GraphApi.Files.list_root_children("drive-id")
{:ok, item} = GraphApi.Files.get_item_by_path("drive-id", "Documents/report.docx")
{:ok, content} = GraphApi.Files.download_content("drive-id", "item-id")
{:ok, item} = GraphApi.Files.upload_small("drive-id", "path/file.txt", content)
{:ok, session} = GraphApi.Files.create_upload_session("drive-id", "path/large.zip")

Subscriptions & Webhooks

# Create a subscription
{:ok, sub} = GraphApi.Subscriptions.create(%{
  "changeType" => "created,updated,deleted",
  "notificationUrl" => "https://example.com/webhook",
  "resource" => "users",
  "expirationDateTime" => "2025-04-01T00:00:00Z",
  "clientState" => "my-secret-state"
})

# Handle webhook notifications
case GraphApi.Webhook.classify(conn) do
  {:validate, token} -> send_resp(conn, 200, token)
  :notification ->
    notifications = GraphApi.Webhook.parse_notifications(conn.body_params)
    Enum.each(notifications, &MyApp.NotificationWorker.enqueue/1)
    send_resp(conn, 202, "")
end

License

MIT - see LICENSE for details.