Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .envrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
source_env_if_exists .env
4 changes: 4 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
name: Build & Test
runs-on: ubuntu-latest

strategy:
matrix:
elixir: [1.17.1, 1.18.3]
otp: [25.3, 26.2, 27.0]

steps:
- uses: actions/checkout@v4

- uses: erlef/setup-beam@v1
with:
elixir-version: ${{ matrix.elixir }}
otp-version: ${{ matrix.otp }}

- name: Restore deps cache
uses: actions/cache@v4
with:
path: deps
key: deps-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }}
restore-keys: deps-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}

- name: Restore build cache
uses: actions/cache@v4
with:
path: _build
key: build-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }}
restore-keys: build-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}

- name: Install dependencies
run: mix deps.get

- name: Compile project
run: mix compile --warnings-as-errors

- name: Run tests
run: mix test --exclude integration
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ msg-*.tar

# Temporary files, for example, from tests.
/tmp/

# Don't commit environment variables used for local testing
.env
22 changes: 22 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
MIT License

Copyright (c) 2025 John Thornton

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Microsoft Graph for Elixir

`msg` is an Elixir library for accessing Microsoft 365 data using the [Microsoft Graph API](https://learn.microsoft.com/en-us/graph/api/overview).

This library is designed for applications that use client credentials (application-only).

Documentation can be found at [https://hexdocs.com/msg](https://hexdocs.com/msg).

---

## Installation

This package is [available in Hex](https://hex.pm/packages/msg), and can be installed by adding `msg` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:msg, "~> 0.1.0"}
]
end
```

## Example Usage

```elixir
creds = %{
client_id: System.get_env("MICROSOFT_CLIENT_ID"),
client_secret: System.get_env("MICROSOFT_CLIENT_SECRET"),
tenant_id: System.get_env("MICROSOFT_TENANT_ID")
}

client = Msg.Client.new(creds)
{:ok, %{"value" => users}} = Msg.Users.list(client)
```

## Features

* Built on top of Req for HTTP requests
* OAuth2 client credentials flow via oauth2

## License

MIT License. See [LICENSE](/LICENSE) for details.
18 changes: 18 additions & 0 deletions lib/msg.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
defmodule Msg do
@moduledoc """
Documentation for `Msg`.
"""

@doc """
Hello world.

## Examples

iex> Msg.hello()
:world

"""
def hello do
:world
end
end
60 changes: 60 additions & 0 deletions lib/msg/client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule Msg.Client do
@moduledoc """
Responsible for handling authentication and request setup for
interacting with the Microsoft Graph API using the `req` and `oauth2` libraries.

## Example

creds = %{
client_id: "your-client-id",
client_secret: "your-client-secret",
tenant_id: "your-tenant-id"
}

client = Msg.Client.new(creds)
Req.get!(client, "/me")

# With custom token provider for testability
token_provider = fn creds -> "stub-token" end
client = Msg.Client.new(creds, token_provider)

## References
- Microsoft Graph REST API: https://learn.microsoft.com/en-us/graph/api/overview
- OAuth2 client credentials: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow
"""

@type credentials :: %{
required(:client_id) => String.t(),
required(:client_secret) => String.t(),
required(:tenant_id) => String.t()
}

@type token_provider :: (credentials() -> String.t())

@spec new(credentials(), token_provider()) :: Req.Request.t()
def new(creds, token_provider \\ &fetch_token!/1) do
access_token = token_provider.(creds)

Req.new(base_url: "https://graph.microsoft.com/v1.0")
|> Req.Request.put_headers([
{"authorization", "Bearer #{access_token}"},
{"content-type", "application/json"},
{"accept", "application/json"}
])
end

@spec fetch_token!(credentials()) :: String.t()
def fetch_token!(%{client_id: client_id, client_secret: client_secret, tenant_id: tenant_id}) do
OAuth2.Client.new(
client_id: client_id,
client_secret: client_secret,
site: "https://graph.microsoft.com",
strategy: OAuth2.Strategy.ClientCredentials,
token_url: "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token"
)
|> OAuth2.Client.put_serializer("application/json", Jason)
|> OAuth2.Client.get_token!(scope: "https://graph.microsoft.com/.default")
|> Map.get(:token)
|> Map.get(:access_token)
end
end
34 changes: 34 additions & 0 deletions lib/msg/request.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
defmodule Msg.Request do
@moduledoc """
Provides helpers for performing Microsoft Graph API requests using Req.

Handles common behaviors like parsing JSON, extracting errors, and optionally
paginating across `@odata.nextLink`.
"""

@type client :: Req.Request.t()

@doc """
Performs a simple GET request to the given Graph API path.

## Example

Msg.Request.get(client, "/me")
"""
@spec get(client(), String.t()) :: {:ok, map()} | {:error, any()}
def get(%Req.Request{} = client, path) do
client
|> Req.get(url: path)
|> handle_response()
end

defp handle_response({:ok, %{status: status, body: body}}) when status in 200..299 do
{:ok, body}
end

defp handle_response({:ok, %{status: status, body: body}}) do
{:error, %{status: status, body: body}}
end

defp handle_response({:error, reason}), do: {:error, reason}
end
23 changes: 23 additions & 0 deletions lib/msg/users.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
defmodule Msg.Users do
@moduledoc """
Provides functions for interacting with the Microsoft Graph `/users` endpoint.

## Example

client = Msg.Client.new(creds)
{:ok, users} = Msg.Users.list(client)
"""

alias Msg.Request

@doc """
Lists all users in the organization.

Corresponds to: [GET /users]
https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0&tabs=http
"""
@spec list(Req.Request.t()) :: {:ok, map()} | {:error, any()}
def list(client) do
Request.get(client, "/users")
end
end
39 changes: 39 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
defmodule Msg.MixProject do
use Mix.Project

@version "0.1.0"

def project do
[
app: :msg,
version: @version,
elixir: "~> 1.16",
start_permanent: Mix.env() == :prod,
deps: deps(),
description: "Micorosft Graph for Elixir",
package: [
licenses: ["MIT"],
links: %{"GitHub" => "https://github.com/riddler/msg"}
]
]
end

def application do
[
extra_applications: [:logger]
]
end

defp deps do
[
# Testing and development
{:ex_doc, "~> 0.31", only: :dev, runtime: false},
{:mox, "~> 1.1", only: :test},

# Actual dependencies
{:jason, "~> 1.4"},
{:oauth2, "~> 2.0"},
{:req, "~> 0.4"}
]
end
end
21 changes: 21 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
%{
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ex_doc": {:hex, :ex_doc, "0.38.1", "bae0a0bd5b5925b1caef4987e3470902d072d03347114ffe03a55dbe206dd4c2", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "754636236d191b895e1e4de2ebb504c057fe1995fdfdd92e9d75c4b05633008b"},
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"oauth2": {:hex, :oauth2, "2.1.0", "beb657f393814a3a7a8a15bd5e5776ecae341fd344df425342a3b6f1904c2989", [:mix], [{:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "8ac07f85b3307dd1acfeb0ec852f64161b22f57d0ce0c15e616a1dfc8ebe2b41"},
"req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"tesla": {:hex, :tesla, "1.14.1", "71c5b031b4e089c0fbfb2b362e24b4478465773ae4ef569760a8c2899ad1e73c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "c1dde8140a49a3bef5bb622356e77ac5a24ad0c8091f12c3b7fc1077ce797155"},
}
21 changes: 21 additions & 0 deletions test/msg/client_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule Msg.ClientTest do
use ExUnit.Case, async: true

@creds %{
client_id: "fake-client-id",
client_secret: "fake-client-secret",
tenant_id: "fake-tenant-id"
}

test "new/2 builds a Req client with expected headers" do
token_provider = fn _ -> "stub-token-123" end

client = Msg.Client.new(@creds, token_provider)
headers = Req.get_headers_list(client)

assert client.options.base_url == "https://graph.microsoft.com/v1.0"
assert {"authorization", "Bearer stub-token-123"} in headers
assert {"content-type", "application/json"} in headers
assert {"accept", "application/json"} in headers
end
end
19 changes: 19 additions & 0 deletions test/msg/integration/client_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule Msg.Integration.ClientTest do
use ExUnit.Case, async: false

alias Msg.Client

@tag :integration
test "creates a new client and fetches an access token" do
creds = %{
client_id: System.fetch_env!("MICROSOFT_CLIENT_ID"),
client_secret: System.fetch_env!("MICROSOFT_CLIENT_SECRET"),
tenant_id: System.fetch_env!("MICROSOFT_TENANT_ID")
}

# Ensure no errors are raised and token is returned
token = Client.fetch_token!(creds)
assert is_binary(token)
assert String.length(token) > 20
end
end
8 changes: 8 additions & 0 deletions test/msg_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
defmodule MsgTest do
use ExUnit.Case
doctest Msg

test "greets the world" do
assert Msg.hello() == :world
end
end
1 change: 1 addition & 0 deletions test/test_helper.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ExUnit.start()