README.md

# GraphApi

Elixir client for the [Microsoft Graph API](https://learn.microsoft.com/en-us/graph/overview).

Built with [Req](https://hexdocs.pm/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`:

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

## Configuration

### Single-Tenant Setup

Add to your `config/runtime.exs`:

```elixir
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:

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

### Multi-Tenant Setup

Build an explicit client for each tenant:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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`:

```elixir
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`:

```elixir
{: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:

```elixir
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:

```elixir
%{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:

```elixir
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

```elixir
# 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

```elixir
# 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:

```elixir
{: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:

```elixir
{: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:

```elixir
{: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}`:

```elixir
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

```elixir
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

```elixir
# 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

```elixir
{: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:

```elixir
{: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](https://hexdocs.pm/req/Req.Test.html) for stubbing HTTP calls in tests. Pass a pre-configured Req client via the `client:` option:

```elixir
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

```elixir
{: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

```elixir
{: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

```elixir
{: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

```elixir
{: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)

```elixir
{: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

```elixir
# 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](LICENSE) for details.