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/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/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 new file mode 100644 index 00000000..951992fe --- /dev/null +++ b/lib/workos/application.ex @@ -0,0 +1,56 @@ +defmodule WorkOS.Application do + @moduledoc false + + use Application + + alias WorkOS.Config + + @impl true + def start(_type, _opts) do + http_client = Config.client() + + if http_client == WorkOS.HackneyClient do + unless Code.ensure_loaded?(:hackney) do + raise """ + 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 + + case Application.ensure_all_started(:hackney) do + {:ok, _apps} -> :ok + {:error, reason} -> raise "failed to start the :hackney application: #{inspect(reason)}" + end + end + + validate_json_config!() + 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..756fb51a --- /dev/null +++ b/lib/workos/config.ex @@ -0,0 +1,9 @@ +defmodule WorkOS.Config do + @moduledoc false + + def client, do: Application.get_env(:workos, :client, WorkOS.HackneyClient) + + def hackney_opts, do: Application.get_env(:workos, :hackney_opts, []) + + 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 new file mode 100644 index 00000000..15d02525 --- /dev/null +++ b/lib/workos/hackney_client.ex @@ -0,0 +1,34 @@ +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 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 diff --git a/mix.exs b/mix.exs index 4a9b78a8..1a43b5f5 100755 --- a/mix.exs +++ b/mix.exs @@ -24,6 +24,7 @@ defmodule WorkOS.MixProject do def application do [ + mod: {WorkOS.Application, []}, extra_applications: [:logger], env: env() ] @@ -32,8 +33,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}