From 7d91006f41180e371d12c9ba2c0c6ad27400e23d Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Sun, 24 Sep 2023 13:01:25 -0300 Subject: [PATCH 1/5] Add config for HTTP client --- README.md | 16 ++++-- lib/workos/application.ex | 66 ++++++++++++++++++++++ lib/workos/config.ex | 104 +++++++++++++++++++++++++++++++++++ lib/workos/hackney_client.ex | 43 +++++++++++++++ lib/workos/http_client.ex | 92 +++++++++++++++++++++++++++++++ 5 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 lib/workos/application.ex create mode 100644 lib/workos/config.ex create mode 100644 lib/workos/hackney_client.ex create mode 100644 lib/workos/http_client.ex diff --git a/README.md b/README.md index 048a3a72..32813609 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # WorkOS Elixir Library -> **Note:** this an experimental SDK and breaking changes may occur. We don't recommend using this in production since we can't guarantee its stability. - The WorkOS library for Elixir provides convenient access to the WorkOS API from applications written in Elixir. ## Documentation @@ -10,14 +8,20 @@ See the [API Reference](https://workos.com/docs/reference/client-libraries) for ## Installation -Add this package to the list of dependencies in your `mix.exs` file: +The hex package can be found here: https://hex.pm/packages/workos + +To use WorkOS SDK with your projects, edit your mix.exs file and add it as a dependency. WorkOS SDK does not install a JSON library nor HTTP client by itself. It will default to trying to use Jason for JSON operations and Hackney for HTTP requests, but can be configured to use other ones. To use the default ones, do: ```ex -def deps do - [{:workos, "~> 0.2.0"}] +defp deps do + [ + # ... + {:workos, "~> 1.0.0"}, + {:jason, "~> 1.1"}, + {:hackney, "~> 1.8"}, + ] end ``` -The hex package can be found here: https://hex.pm/packages/workos ## Configuration diff --git a/lib/workos/application.ex b/lib/workos/application.ex new file mode 100644 index 00000000..93b1df0b --- /dev/null +++ b/lib/workos/application.ex @@ -0,0 +1,66 @@ +defmodule WorkOS.Application do + @moduledoc false + + use Application + + alias WorkOS.Config + + @impl true + def start(_type, _opts) do + http_client = Config.client() + + maybe_http_client_spec = + if Code.ensure_loaded?(http_client) and function_exported?(http_client, :child_spec, 0) do + [http_client.child_spec()] + else + [] + end + + if http_client == Sentry.HackneyClient do + unless Code.ensure_loaded?(:hackney) do + raise """ + cannot start the :sentry application because the HTTP client is set to \ + Sentry.HackneyClient (which is the default), but the Hackney library is not loaded. \ + Add :hackney to your dependencies to fix this. + """ + end + + case Application.ensure_all_started(:hackney) do + {:ok, _apps} -> :ok + {:error, reason} -> raise "failed to start the :hackney application: #{inspect(reason)}" + end + end + + Config.warn_for_deprecated_env_vars!() + validate_json_config!() + Config.validate_included_environments!() + Config.validate_environment_name!() + end + + defp validate_json_config!() do + case Config.json_library() do + nil -> + raise ArgumentError.exception("nil is not a valid :json_library configuration") + + library -> + try do + with {:ok, %{}} <- library.decode("{}"), + {:ok, "{}"} <- library.encode(%{}) do + :ok + else + _ -> + raise ArgumentError.exception( + "configured :json_library #{inspect(library)} does not implement decode/1 and encode/1" + ) + end + rescue + UndefinedFunctionError -> + reraise ArgumentError.exception(""" + configured :json_library #{inspect(library)} is not available or does not implement decode/1 and encode/1. + Do you need to add #{inspect(library)} to your mix.exs? + """), + __STACKTRACE__ + end + end + end +end diff --git a/lib/workos/config.ex b/lib/workos/config.ex new file mode 100644 index 00000000..6d1d8e5a --- /dev/null +++ b/lib/workos/config.ex @@ -0,0 +1,104 @@ +defmodule WorkOS.Config do + @moduledoc false + + @default_max_hackney_connections 50 + @default_hackney_timeout 5000 + + @spec validate_environment_name!() :: :ok + def validate_environment_name! do + # This already raises if missing. + environment_name() + :ok + end + + @spec validate_included_environments!() :: :ok + def validate_included_environments! do + normalized_environments = + case included_environments() do + list when is_list(list) -> + Enum.map(list, fn + env when is_atom(env) or is_binary(env) -> + to_string(env) + + other -> + raise ArgumentError, """ + expected environments in :included_environments to be atoms or strings, \ + got: #{inspect(other)}\ + """ + end) + + :all -> + :all + end + + :ok = Application.put_env(:workos, :included_environments, normalized_environments) + end + + def included_environments do + case Application.fetch_env(:workos, :included_environments) do + {:ok, :all} -> + :all + + {:ok, envs} when is_binary(envs) -> + raise ArgumentError, """ + setting :included_environments to a comma-separated string is not supported anymore. \ + Set :included_environments to a list of atoms instead.\ + """ + + {:ok, envs} when is_list(envs) -> + string_envs = Enum.map(envs, &to_string/1) + Application.put_env(:workos, :included_environments, string_envs) + string_envs + + :error -> + _default = ["prod"] + end + end + + def max_hackney_connections do + Application.get_env(:workos, :hackney_pool_max_connections, @default_max_hackney_connections) + end + + def hackney_timeout do + Application.get_env(:workos, :hackney_pool_timeout, @default_hackney_timeout) + end + + def client do + Application.get_env(:workos, :client, WorkOS.HackneyClient) + end + + def hackney_opts do + Application.get_env(:workos, :hackney_opts, []) + end + + def json_library do + Application.get_env(:workos, :json_library, Jason) + end + + defp get_config_from_app_or_system_env(app_key, system_env_key) do + case Application.get_env(:workos, app_key, nil) do + {:system, env_key} -> + raise ArgumentError, """ + using {:system, env} as a configuration value is not supported since v9.0.0 of this \ + library. Move the configuration for #{inspect(app_key)} to config/runtime.exs, \ + and read the #{inspect(env_key)} environment variable from there: + + config :workos, + # ..., + #{app_key}: System.fetch_env!(#{inspect(env_key)}) + + """ + + nil -> + if value = System.get_env(system_env_key) do + Application.put_env(:workos, app_key, value) + value + else + nil + end + + value -> + value + end + end +end diff --git a/lib/workos/hackney_client.ex b/lib/workos/hackney_client.ex new file mode 100644 index 00000000..c49ec77c --- /dev/null +++ b/lib/workos/hackney_client.ex @@ -0,0 +1,43 @@ +defmodule WorkOS.HackneyClient do + @behaviour WorkOS.HTTPClient + + @moduledoc """ + The built-in HTTP client. + + This client implements the `WorkOS.HTTPClient` behaviour. + + It's based on the [hackney](https://github.com/benoitc/hackney) Erlang HTTP client, + which is an *optional dependency* of this library. If you wish to use another + HTTP client, you'll have to implement your own `WorkOS.HTTPClient`. See the + documentation for `WorkOS.HTTPClient` for more information. + + WorkOS SDK starts its own hackney pool called `:workos_pool`. If you need to set other + [hackney configuration options](https://github.com/benoitc/hackney/blob/master/doc/hackney.md#request5) + for things such as proxies, using your own pool, or response timeouts, the `:hackney_opts` + configuration is passed directly to hackney for each request. See the configuration + documentation in the `WorkOS` module. + """ + + @hackney_pool_name :workos_pool + + @impl true + def child_spec do + :hackney_pool.child_spec( + @hackney_pool_name, + timeout: WorkOS.Config.hackney_timeout(), + max_connections: WorkOS.Config.max_hackney_connections() + ) + end + + @impl true + def post(url, headers, body) do + hackney_opts = + WorkOS.Config.hackney_opts() + |> Keyword.put_new(:pool, @hackney_pool_name) + + case :hackney.request(:post, url, headers, body, [:with_body] ++ hackney_opts) do + {:ok, _status, _headers, _body} = result -> result + {:error, _reason} = error -> error + end + end +end diff --git a/lib/workos/http_client.ex b/lib/workos/http_client.ex new file mode 100644 index 00000000..866f0972 --- /dev/null +++ b/lib/workos/http_client.ex @@ -0,0 +1,92 @@ +defmodule WorkOS.HttpClient do + @moduledoc """ + A behaviour for HTTP clients that WorkOS can use. + + The default HTTP client is `WorkOS.HackneyClient`. + + To configure a different HTTP client, implement the `WorkOS.HTTPClient` behaviour and + change the `:client` configuration: + + config :workos, + client: MyHTTPClient + + ## Alternative Clients + + Let's look at an example of using an alternative HTTP client. In this example, we'll + use [Finch](https://github.com/sneako/finch), a lightweight HTTP client for Elixir. + + First, we need to add Finch to our dependencies: + + # In mix.exs + defp deps do + [ + # ... + {:finch, "~> 0.16"} + ] + end + + Then, we need to define a module that implements the `WorkOS.HTTPClient` behaviour: + + defmodule MyApp.WorkOSFinchHTTPClient do + @behaviour WorkOS.HTTPClient + + @impl true + def child_spec do + Supervisor.child_spec({Finch, name: __MODULE__}, id: __MODULE__) + end + + @impl true + def post(url, headers, body) do + request = Finch.build(:post, url, headers, body) + + case Finch.request(request, __MODULE__) do + {:ok, %Finch.Response{status: status, headers: headers, body: body}} -> + {:ok, status, headers, body} + + {:error, error} -> + {:error, error} + end + end + end + + Last, we need to configure WorkOS to use our new HTTP client: + + config :workos, + client: MyApp.WorkOSFinchHTTPClient + """ + + @typedoc """ + The response status for an HTTP request. + """ + @typedoc since: "1.0.0" + @type status :: 100..599 + + @typedoc """ + HTTP request or response headers. + """ + @type headers :: [{String.t(), String.t()}] + + @typedoc """ + HTTP request or response body. + """ + @typedoc since: "1.0.0" + @type body :: binary() + + @doc """ + Should return a **child specification** to start the HTTP client. + + For example, this can start a pool of HTTP connections dedicated to WorkOS SDK. + If not provided, WorkOS SDK won't do anything to start your HTTP client. See + [the module documentation](#module-child-spec) for more info. + """ + @callback child_spec() :: :supervisor.child_spec() + + @doc """ + Should make an HTTP `POST` request to `url` with the given `headers` and `body`. + """ + @callback post(url :: String.t(), request_headers :: headers(), request_body :: body()) :: + {:ok, status(), response_headers :: headers(), response_body :: body()} + | {:error, term()} + + @optional_callbacks [child_spec: 0] +end From a529c37baea0fb8c445eb5b95bcaae6c0042b960 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Sun, 24 Sep 2023 13:26:04 -0300 Subject: [PATCH 2/5] Fix config module --- .gitignore | 3 +++ lib/workos/application.ex | 9 +++---- lib/workos/config.ex | 51 --------------------------------------- 3 files changed, 6 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index dfb8fbea..a6307c70 100755 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ workos-*.tar # Temporary files for e.g. tests /tmp + +# Editor +.DS_Store diff --git a/lib/workos/application.ex b/lib/workos/application.ex index 93b1df0b..eb779fb7 100644 --- a/lib/workos/application.ex +++ b/lib/workos/application.ex @@ -16,11 +16,11 @@ defmodule WorkOS.Application do [] end - if http_client == Sentry.HackneyClient do + if http_client == WorkOS.HackneyClient do unless Code.ensure_loaded?(:hackney) do raise """ - cannot start the :sentry application because the HTTP client is set to \ - Sentry.HackneyClient (which is the default), but the Hackney library is not loaded. \ + cannot start the :workos application because the HTTP client is set to \ + WorkOS.HackneyClient (which is the default), but the Hackney library is not loaded. \ Add :hackney to your dependencies to fix this. """ end @@ -31,10 +31,7 @@ defmodule WorkOS.Application do end end - Config.warn_for_deprecated_env_vars!() validate_json_config!() - Config.validate_included_environments!() - Config.validate_environment_name!() end defp validate_json_config!() do diff --git a/lib/workos/config.ex b/lib/workos/config.ex index 6d1d8e5a..60c0be06 100644 --- a/lib/workos/config.ex +++ b/lib/workos/config.ex @@ -4,57 +4,6 @@ defmodule WorkOS.Config do @default_max_hackney_connections 50 @default_hackney_timeout 5000 - @spec validate_environment_name!() :: :ok - def validate_environment_name! do - # This already raises if missing. - environment_name() - :ok - end - - @spec validate_included_environments!() :: :ok - def validate_included_environments! do - normalized_environments = - case included_environments() do - list when is_list(list) -> - Enum.map(list, fn - env when is_atom(env) or is_binary(env) -> - to_string(env) - - other -> - raise ArgumentError, """ - expected environments in :included_environments to be atoms or strings, \ - got: #{inspect(other)}\ - """ - end) - - :all -> - :all - end - - :ok = Application.put_env(:workos, :included_environments, normalized_environments) - end - - def included_environments do - case Application.fetch_env(:workos, :included_environments) do - {:ok, :all} -> - :all - - {:ok, envs} when is_binary(envs) -> - raise ArgumentError, """ - setting :included_environments to a comma-separated string is not supported anymore. \ - Set :included_environments to a list of atoms instead.\ - """ - - {:ok, envs} when is_list(envs) -> - string_envs = Enum.map(envs, &to_string/1) - Application.put_env(:workos, :included_environments, string_envs) - string_envs - - :error -> - _default = ["prod"] - end - end - def max_hackney_connections do Application.get_env(:workos, :hackney_pool_max_connections, @default_max_hackney_connections) end From 41bbe1db1a0392c6c515bd20652555625a0433a9 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Sun, 24 Sep 2023 13:28:24 -0300 Subject: [PATCH 3/5] Make dependencies optional --- mix.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 4a9b78a8..a2547f38 100755 --- a/mix.exs +++ b/mix.exs @@ -32,8 +32,8 @@ defmodule WorkOS.MixProject do defp deps do [ {:tesla, "~> 1.4"}, - {:hackney, "~> 1.18.0"}, - {:jason, ">= 1.0.0"}, + {:hackney, "~> 1.18.0", optional: true}, + {:jason, ">= 1.0.0", optional: true}, {:plug_crypto, "~> 1.0"}, {:ex_doc, "~> 0.23", only: :dev, runtime: false}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false} From 602567284b6266a3f7f9d9540d77e9b06a000e4e Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Sun, 24 Sep 2023 13:34:57 -0300 Subject: [PATCH 4/5] Specifies application main module --- mix.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/mix.exs b/mix.exs index a2547f38..fe7a5b30 100755 --- a/mix.exs +++ b/mix.exs @@ -24,6 +24,7 @@ defmodule WorkOS.MixProject do def application do [ + mod: {Sentry.Application, []}, extra_applications: [:logger], env: env() ] From 71c1c911b3b668c845713dea77f79887adfee8a7 Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Sun, 24 Sep 2023 14:07:44 -0300 Subject: [PATCH 5/5] Integrate custom client with API module --- lib/workos/api.ex | 43 +++++++++++-------------------- lib/workos/application.ex | 7 ----- lib/workos/config.ex | 50 +++--------------------------------- lib/workos/hackney_client.ex | 11 +------- mix.exs | 2 +- 5 files changed, 20 insertions(+), 93 deletions(-) diff --git a/lib/workos/api.ex b/lib/workos/api.ex index 21f8af87..020b881e 100644 --- a/lib/workos/api.ex +++ b/lib/workos/api.ex @@ -4,57 +4,44 @@ defmodule WorkOS.API do """ @doc """ - Generates the Tesla client used to make requests to WorkOS + Generates the HTTP client used to make requests to WorkOS """ - def client(opts \\ []) do + def client(http_client, opts \\ []) do auth = opts |> Keyword.get(:access_token, WorkOS.api_key(opts)) - middleware = [ - {Tesla.Middleware.BaseUrl, WorkOS.base_url()}, - Tesla.Middleware.JSON, - {Tesla.Middleware.Headers, - [ - {"Authorization", "Bearer " <> auth} - ]} - ] - - Tesla.client(middleware, WorkOS.adapter()) + http_client.new(opts: opts, auth: auth) end @doc """ Performs a GET request """ - def get(path, query \\ [], opts \\ []) do - client(opts) - |> Tesla.get(path, query: query) - |> handle_response + def get(http_client, url, query \\ [], opts \\ []) do + http_client.get(url, query, opts) + |> handle_response() end @doc """ Performs a POST request """ - def post(path, params \\ "", opts \\ []) do - client(opts) - |> Tesla.post(path, params) - |> handle_response + def post(http_client, url, body, opts \\ []) do + http_client.post(url, body, opts) + |> handle_response() end @doc """ Performs a DELETE request """ - def delete(path, params \\ "", opts \\ []) do - client(opts) - |> Tesla.delete(path, query: params) - |> handle_response + def delete(http_client, url, query \\ [], opts \\ []) do + http_client.delete(url, query, opts) + |> handle_response() end @doc """ Performs a PUT request """ - def put(path, params \\ "", opts \\ []) do - client(opts) - |> Tesla.put(path, params) - |> handle_response + def put(http_client, url, body, opts \\ []) do + http_client.put(url, body, opts) + |> handle_response() end @doc """ diff --git a/lib/workos/application.ex b/lib/workos/application.ex index eb779fb7..951992fe 100644 --- a/lib/workos/application.ex +++ b/lib/workos/application.ex @@ -9,13 +9,6 @@ defmodule WorkOS.Application do def start(_type, _opts) do http_client = Config.client() - maybe_http_client_spec = - if Code.ensure_loaded?(http_client) and function_exported?(http_client, :child_spec, 0) do - [http_client.child_spec()] - else - [] - end - if http_client == WorkOS.HackneyClient do unless Code.ensure_loaded?(:hackney) do raise """ diff --git a/lib/workos/config.ex b/lib/workos/config.ex index 60c0be06..756fb51a 100644 --- a/lib/workos/config.ex +++ b/lib/workos/config.ex @@ -1,53 +1,9 @@ defmodule WorkOS.Config do @moduledoc false - @default_max_hackney_connections 50 - @default_hackney_timeout 5000 + def client, do: Application.get_env(:workos, :client, WorkOS.HackneyClient) - def max_hackney_connections do - Application.get_env(:workos, :hackney_pool_max_connections, @default_max_hackney_connections) - end + def hackney_opts, do: Application.get_env(:workos, :hackney_opts, []) - def hackney_timeout do - Application.get_env(:workos, :hackney_pool_timeout, @default_hackney_timeout) - end - - def client do - Application.get_env(:workos, :client, WorkOS.HackneyClient) - end - - def hackney_opts do - Application.get_env(:workos, :hackney_opts, []) - end - - def json_library do - Application.get_env(:workos, :json_library, Jason) - end - - defp get_config_from_app_or_system_env(app_key, system_env_key) do - case Application.get_env(:workos, app_key, nil) do - {:system, env_key} -> - raise ArgumentError, """ - using {:system, env} as a configuration value is not supported since v9.0.0 of this \ - library. Move the configuration for #{inspect(app_key)} to config/runtime.exs, \ - and read the #{inspect(env_key)} environment variable from there: - - config :workos, - # ..., - #{app_key}: System.fetch_env!(#{inspect(env_key)}) - - """ - - nil -> - if value = System.get_env(system_env_key) do - Application.put_env(:workos, app_key, value) - value - else - nil - end - - value -> - value - end - end + def json_library, do: Application.get_env(:workos, :json_library, Jason) end diff --git a/lib/workos/hackney_client.ex b/lib/workos/hackney_client.ex index c49ec77c..15d02525 100644 --- a/lib/workos/hackney_client.ex +++ b/lib/workos/hackney_client.ex @@ -1,5 +1,5 @@ defmodule WorkOS.HackneyClient do - @behaviour WorkOS.HTTPClient + @behaviour WorkOS.HttpClient @moduledoc """ The built-in HTTP client. @@ -20,15 +20,6 @@ defmodule WorkOS.HackneyClient do @hackney_pool_name :workos_pool - @impl true - def child_spec do - :hackney_pool.child_spec( - @hackney_pool_name, - timeout: WorkOS.Config.hackney_timeout(), - max_connections: WorkOS.Config.max_hackney_connections() - ) - end - @impl true def post(url, headers, body) do hackney_opts = diff --git a/mix.exs b/mix.exs index fe7a5b30..1a43b5f5 100755 --- a/mix.exs +++ b/mix.exs @@ -24,7 +24,7 @@ defmodule WorkOS.MixProject do def application do [ - mod: {Sentry.Application, []}, + mod: {WorkOS.Application, []}, extra_applications: [:logger], env: env() ]