From 68518a062485870101a6b239caca839ec6929422 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sat, 17 May 2025 06:36:56 -0600 Subject: [PATCH 01/10] Creates initial Elixir project --- .formatter.exs | 4 ++++ README.md | 21 +++++++++++++++++++++ lib/msg.ex | 18 ++++++++++++++++++ mix.exs | 36 ++++++++++++++++++++++++++++++++++++ mix.lock | 21 +++++++++++++++++++++ test/msg_test.exs | 8 ++++++++ test/test_helper.exs | 1 + 7 files changed, 109 insertions(+) create mode 100644 .formatter.exs create mode 100644 README.md create mode 100644 lib/msg.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 test/msg_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..79ddd73 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Msg + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `msg` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:msg, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/lib/msg.ex b/lib/msg.ex new file mode 100644 index 0000000..689ae35 --- /dev/null +++ b/lib/msg.ex @@ -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 diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..053e2cd --- /dev/null +++ b/mix.exs @@ -0,0 +1,36 @@ +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: "Microsoft Graph API client for Elixir", + package: [ + licenses: ["MIT"], + links: %{"GitHub" => "https://github.com/riddler/msg"} + ] + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:req, "~> 0.4"}, + {:oauth2, "~> 2.0"}, + {:jason, "~> 1.4"}, + {:mox, "~> 1.1", only: :test}, + {:ex_doc, "~> 0.31", only: :dev, runtime: false} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..e5774a6 --- /dev/null +++ b/mix.lock @@ -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"}, +} diff --git a/test/msg_test.exs b/test/msg_test.exs new file mode 100644 index 0000000..86c2b6e --- /dev/null +++ b/test/msg_test.exs @@ -0,0 +1,8 @@ +defmodule MsgTest do + use ExUnit.Case + doctest Msg + + test "greets the world" do + assert Msg.hello() == :world + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() From 6b3306eddfd336ec8e42c6c1b538b9c6b809afc2 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sat, 17 May 2025 06:37:14 -0600 Subject: [PATCH 02/10] Adds initial Client and tests --- lib/msg/client.ex | 60 ++++++++++++++++++++++++++++++++++++++++ test/msg/client_test.exs | 23 +++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 lib/msg/client.ex create mode 100644 test/msg/client_test.exs diff --git a/lib/msg/client.ex b/lib/msg/client.ex new file mode 100644 index 0000000..02f0587 --- /dev/null +++ b/lib/msg/client.ex @@ -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(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: id, client_secret: secret, tenant_id: tenant}) do + OAuth2.Client.new([ + client_id: id, + client_secret: secret, + site: "https://login.microsoftonline.com/#{tenant}", + authorize_url: "/oauth2/v2.0/authorize", + token_url: "/oauth2/v2.0/token", + params: [scope: "https://graph.microsoft.com/.default"] + ]) + |> OAuth2.Client.get_token!() + |> Map.get(:token) + |> Map.get(:access_token) + end +end diff --git a/test/msg/client_test.exs b/test/msg/client_test.exs new file mode 100644 index 0000000..16e084a --- /dev/null +++ b/test/msg/client_test.exs @@ -0,0 +1,23 @@ +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) + + require IEx; IEx.pry + + assert client.url == URI.parse("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 From 3ec553a3e57f3d088efac6a8df00234310fadcc0 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sat, 17 May 2025 06:38:34 -0600 Subject: [PATCH 03/10] Adds local environment variables setup --- .envrc | 1 + .gitignore | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 .envrc diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..40439a9 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +source_env_if_exists .env diff --git a/.gitignore b/.gitignore index 8fc0e79..cbebfba 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ msg-*.tar # Temporary files, for example, from tests. /tmp/ + +# Don't commit environment variables used for local testing +.env From a6a70faeb2d2507282eb22f56e75ca7988f29a71 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sat, 17 May 2025 07:03:00 -0600 Subject: [PATCH 04/10] Updates Client to work with acutal credentials --- lib/msg/client.ex | 20 ++++++++++---------- test/msg/client_test.exs | 2 -- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/msg/client.ex b/lib/msg/client.ex index 02f0587..fafc1af 100644 --- a/lib/msg/client.ex +++ b/lib/msg/client.ex @@ -44,16 +44,16 @@ defmodule Msg.Client do end @spec fetch_token!(credentials()) :: String.t() - def fetch_token!(%{client_id: id, client_secret: secret, tenant_id: tenant}) do - OAuth2.Client.new([ - client_id: id, - client_secret: secret, - site: "https://login.microsoftonline.com/#{tenant}", - authorize_url: "/oauth2/v2.0/authorize", - token_url: "/oauth2/v2.0/token", - params: [scope: "https://graph.microsoft.com/.default"] - ]) - |> OAuth2.Client.get_token!() + def fetch_token!(%{client_id: client_id, client_secret: client_secret, tenant_id: tenant_id}) do + OAuth2.Client.new( + strategy: OAuth2.Strategy.ClientCredentials, + site: "https://graph.microsoft.com", + client_id: client_id, + client_secret: client_secret, + 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 diff --git a/test/msg/client_test.exs b/test/msg/client_test.exs index 16e084a..53a22e3 100644 --- a/test/msg/client_test.exs +++ b/test/msg/client_test.exs @@ -13,8 +13,6 @@ defmodule Msg.ClientTest do client = Msg.Client.new(@creds, token_provider) headers = Req.get_headers_list(client) - require IEx; IEx.pry - assert client.url == URI.parse("https://graph.microsoft.com/v1.0") assert {"authorization", "Bearer stub-token-123"} in headers assert {"content-type", "application/json"} in headers From 6f2f05e00a2355bf84ce4603d843be7be3adfb10 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sat, 17 May 2025 11:15:53 -0600 Subject: [PATCH 05/10] Adds integration test for fetching token --- lib/msg/client.ex | 2 +- mix.exs | 11 +++++++---- test/msg/client_test.exs | 2 +- test/msg/integration/client_test.exs | 19 +++++++++++++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 test/msg/integration/client_test.exs diff --git a/lib/msg/client.ex b/lib/msg/client.ex index fafc1af..d479f25 100644 --- a/lib/msg/client.ex +++ b/lib/msg/client.ex @@ -35,7 +35,7 @@ defmodule Msg.Client do def new(creds, token_provider \\ &fetch_token!/1) do access_token = token_provider.(creds) - Req.new(url: "https://graph.microsoft.com/v1.0") + Req.new(base_url: "https://graph.microsoft.com/v1.0") |> Req.Request.put_headers([ {"authorization", "Bearer #{access_token}"}, {"content-type", "application/json"}, diff --git a/mix.exs b/mix.exs index 053e2cd..1416e2e 100644 --- a/mix.exs +++ b/mix.exs @@ -26,11 +26,14 @@ defmodule Msg.MixProject do defp deps do [ - {:req, "~> 0.4"}, - {:oauth2, "~> 2.0"}, - {:jason, "~> 1.4"}, + # Testing and development + {:ex_doc, "~> 0.31", only: :dev, runtime: false}, {:mox, "~> 1.1", only: :test}, - {:ex_doc, "~> 0.31", only: :dev, runtime: false} + + # Actual dependencies + {:jason, "~> 1.4"}, + {:oauth2, "~> 2.0"}, + {:req, "~> 0.4"} ] end end diff --git a/test/msg/client_test.exs b/test/msg/client_test.exs index 53a22e3..c7c4abf 100644 --- a/test/msg/client_test.exs +++ b/test/msg/client_test.exs @@ -13,7 +13,7 @@ defmodule Msg.ClientTest do client = Msg.Client.new(@creds, token_provider) headers = Req.get_headers_list(client) - assert client.url == URI.parse("https://graph.microsoft.com/v1.0") + 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 diff --git a/test/msg/integration/client_test.exs b/test/msg/integration/client_test.exs new file mode 100644 index 0000000..c555beb --- /dev/null +++ b/test/msg/integration/client_test.exs @@ -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 From 7c24872b6c36f9b9710eb2d2cfbc8aa6531115db Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sun, 18 May 2025 04:53:26 -0600 Subject: [PATCH 06/10] Adds Request and Users --- lib/msg/client.ex | 4 ++-- lib/msg/request.ex | 34 ++++++++++++++++++++++++++++++++++ lib/msg/users.ex | 23 +++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 lib/msg/request.ex create mode 100644 lib/msg/users.ex diff --git a/lib/msg/client.ex b/lib/msg/client.ex index d479f25..da43953 100644 --- a/lib/msg/client.ex +++ b/lib/msg/client.ex @@ -46,10 +46,10 @@ defmodule Msg.Client do @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( - strategy: OAuth2.Strategy.ClientCredentials, - site: "https://graph.microsoft.com", 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) diff --git a/lib/msg/request.ex b/lib/msg/request.ex new file mode 100644 index 0000000..d0d6b52 --- /dev/null +++ b/lib/msg/request.ex @@ -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 diff --git a/lib/msg/users.ex b/lib/msg/users.ex new file mode 100644 index 0000000..0dfe256 --- /dev/null +++ b/lib/msg/users.ex @@ -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 From 34ec1dec7866f9024ce7c39bcc6053805e41b2d6 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sun, 18 May 2025 07:18:38 -0600 Subject: [PATCH 07/10] Updates description --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 1416e2e..4ed741d 100644 --- a/mix.exs +++ b/mix.exs @@ -10,7 +10,7 @@ defmodule Msg.MixProject do elixir: "~> 1.16", start_permanent: Mix.env() == :prod, deps: deps(), - description: "Microsoft Graph API client for Elixir", + description: "Micorosft Graph for Elixir", package: [ licenses: ["MIT"], links: %{"GitHub" => "https://github.com/riddler/msg"} From 058b4ff4204f32662eb8c714ef6493f542401a0e Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Mon, 19 May 2025 06:06:04 -0600 Subject: [PATCH 08/10] Updates README --- README.md | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 79ddd73..b468305 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,16 @@ -# Msg +# Microsoft Graph for Elixir -**TODO: Add description** +`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 -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `msg` to your list of dependencies in `mix.exs`: +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 @@ -15,7 +20,24 @@ def deps do end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at . +## 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 for details. From 648201760827f3ea249fdcad03132efcd39b1af8 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Mon, 19 May 2025 06:16:44 -0600 Subject: [PATCH 09/10] Adds CI workflow --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b0aa1f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 From eaf87f3d81fda52015cdb8dc0c80ac6409a8aa8c Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Mon, 19 May 2025 06:28:50 -0600 Subject: [PATCH 10/10] Adds LICENSE --- LICENSE | 22 ++++++++++++++++++++++ README.md | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4d6d394 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index b468305..2ded169 100644 --- a/README.md +++ b/README.md @@ -40,4 +40,4 @@ client = Msg.Client.new(creds) ## License -MIT License. See LICENSE for details. +MIT License. See [LICENSE](/LICENSE) for details.