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"}
]
endConfiguration
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 syncsLazy 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")
endDelegated 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 URLStep 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 expiryStep 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"
endResource 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"){: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, "")
endLicense
MIT - see LICENSE for details.