diff --git a/config/config.ex b/config/config.ex new file mode 100644 index 00000000..871a3d1e --- /dev/null +++ b/config/config.ex @@ -0,0 +1,3 @@ +import Config + +import_config "#{Mix.env()}.exs" diff --git a/config/dev.ex b/config/dev.ex new file mode 100644 index 00000000..32741e70 --- /dev/null +++ b/config/dev.ex @@ -0,0 +1,5 @@ +import Config + +config :workos, WorkOS.Client, + client_id: System.get_env("WORKOS_CLIENT_ID"), + api_key: System.get_env("WORKOS_API_KEY") diff --git a/config/prod.ex b/config/prod.ex new file mode 100644 index 00000000..d932aaf7 --- /dev/null +++ b/config/prod.ex @@ -0,0 +1 @@ +# This empty module is for configuration purposes diff --git a/config/test.ex b/config/test.ex new file mode 100644 index 00000000..72490659 --- /dev/null +++ b/config/test.ex @@ -0,0 +1,11 @@ +import Config + +workos_api_key = System.get_env("WORKOS_API_KEY") +workos_client_id = System.get_env("WORKOS_CLIENT_ID") + +if workos_api_key and workos_client_id do + config :workos, WorkOS.Client, api_key: workos_api_key, client_id: workos_client_id +else + config :tesla, adapter: Tesla.Mock + config :workos, WorkOS.Client, api_key: "test_123", client_id: "test_123" +end diff --git a/lib/workos.ex b/lib/workos.ex index a8e90d7b..badb4e80 100755 --- a/lib/workos.ex +++ b/lib/workos.ex @@ -1,8 +1,81 @@ defmodule WorkOS do @moduledoc """ - Use the WorkOS module to authenticate your requests to the WorkOS API + Documentation for `WorkOS`. """ + @config_module WorkOS.Client + + @type config() :: + list( + {:api_key, String.t()} + | {:client_id, String.t()} + | {:base_url, String.t()} + | {:client, atom()} + ) + + @doc """ + Returns a WorkOS client. + + Accepts a keyword list of config opts, though if omitted then it will attempt to load + them from the application environment. + """ + @spec client() :: WorkOS.Client.t() + @spec client(config()) :: WorkOS.Client.t() + def client(config \\ config()) do + WorkOS.Client.new(config) + end + + @doc """ + Loads config values from the application environment. + + Config options are as follows: + + ```ex + config :workos, WorkOS.Client + api_key: "test_123", + base_url: "https://api.workos.com", + client: WorkOs.Client.TeslaClient + ``` + + The only required config option is `:api_key` and `:client_id`. If you would like to replace the + HTTP client used by WorkOS, configure the `:client` option. By default, this library + uses [Tesla](https://github.com/elixir-tesla/tesla), but changing it is as easy as + defining your own client module. See the `WorkOS.Client` module docs for more info. + """ + @spec config() :: config() + def config do + config = + Application.get_env(:workos, @config_module) || + raise """ + Missing client configuration for WorkOS. + + Configure your WorkOS API key in one of your config files, for example: + + config :workos, #{inspect(@config_module)}, api_key: "example_123", client_id: "example_123" + """ + + validate_config!(config) + end + + @spec validate_config!(WorkOS.config()) :: WorkOS.config() | no_return() + defp validate_config!(config) do + api_key = + Keyword.get(config, :api_key) || + raise "Missing required config key for #{@config_module}: :api_key" + + String.starts_with?(api_key, "sk_") || + raise "WorkOS API key should start with 'sk_', please check your configuration" + + client_id = + Keyword.get(config, :client_id) || + raise "Missing required config key for #{@config_module}: :client_id" + + String.starts_with?(client_id, "project_") || + raise "WorkOS Client ID should start with 'project_', please check your configuration" + + config + end + def host, do: Application.get_env(:workos, :host) def base_url, do: "https://" <> Application.get_env(:workos, :host) def adapter, do: Application.get_env(:workos, :adapter) || Tesla.Adapter.Hackney diff --git a/lib/workos/api.ex b/lib/workos/api.ex index 21f8af87..c203db4d 100644 --- a/lib/workos/api.ex +++ b/lib/workos/api.ex @@ -1,4 +1,6 @@ defmodule WorkOS.API do + @deprecated "This module is deprecated. Please use WorkOS.Client instead." + @moduledoc """ Provides core API communication and data processing functionality. """ diff --git a/lib/workos/castable.ex b/lib/workos/castable.ex new file mode 100644 index 00000000..65a596c3 --- /dev/null +++ b/lib/workos/castable.ex @@ -0,0 +1,34 @@ +defmodule WorkOS.Castable do + @moduledoc false + + @type impl :: module() | {module(), module()} | :raw + @type generic_map :: %{String.t() => any()} + + @callback cast(generic_map() | {module(), generic_map()} | nil) :: struct() | nil + + @spec cast(impl(), generic_map() | nil) :: struct() | generic_map() | nil + def cast(_implementation, nil) do + nil + end + + def cast(:raw, generic_map) do + generic_map + end + + def cast({implementation, inner}, generic_map) when is_map(generic_map) do + implementation.cast({inner, generic_map}) + end + + def cast(implementation, generic_map) when is_map(generic_map) do + implementation.cast(generic_map) + end + + @spec cast_list(module(), [generic_map()] | nil) :: [struct()] | nil + def cast_list(_implementation, nil) do + nil + end + + def cast_list(implementation, list_of_generic_maps) when is_list(list_of_generic_maps) do + Enum.map(list_of_generic_maps, &cast(implementation, &1)) + end +end diff --git a/lib/workos/client.ex b/lib/workos/client.ex new file mode 100644 index 00000000..46c7a653 --- /dev/null +++ b/lib/workos/client.ex @@ -0,0 +1,121 @@ +defmodule WorkOS.Client do + @moduledoc """ + WorkOS API client. + """ + + require Logger + + alias WorkOS.Castable + + @callback request(t(), Keyword.t()) :: + {:ok, %{body: map(), status: pos_integer()}} | {:error, any()} + + @type response(type) :: {:ok, type} | {:error, WorkOS.Error.t() | :client_error} + + @type t() :: %__MODULE__{ + api_key: String.t(), + base_url: String.t() | nil, + client: module() | nil + } + + @enforce_keys [:api_key, :base_url, :client] + defstruct [:api_key, :base_url, :client] + + @default_opts [ + base_url: "https://api.workos.com", + client: __MODULE__.TeslaClient + ] + + @doc """ + Creates a new WorkOS client struct given a keyword list of config opts. + """ + @spec new(WorkOS.config()) :: t() + def new(config) do + config = Keyword.take(config, [:api_key, :base_url, :client]) + struct!(__MODULE__, Keyword.merge(@default_opts, config)) + end + + @spec get(t(), Castable.impl(), String.t()) :: response(any()) + @spec get(t(), Castable.impl(), String.t(), Keyword.t()) :: response(any()) + def get(client, castable_module, path, opts \\ []) do + client_module = client.client || WorkOS.Client.TeslaClient + + opts = + opts + |> Keyword.put(:method, :get) + |> Keyword.put(:url, path) + + client_module.request(client, opts) + |> handle_response(path, castable_module) + end + + @spec post(t(), Castable.impl(), String.t()) :: response(any()) + @spec post(t(), Castable.impl(), String.t(), map()) :: response(any()) + @spec post(t(), Castable.impl(), String.t(), map(), Keyword.t()) :: response(any()) + def post(client, castable_module, path, body \\ %{}, opts \\ []) do + client_module = client.client || WorkOS.Client.TeslaClient + + opts = + opts + |> Keyword.put(:method, :post) + |> Keyword.put(:url, path) + |> Keyword.put(:body, body) + + client_module.request(client, opts) + |> handle_response(path, castable_module) + end + + @spec put(t(), Castable.impl(), String.t()) :: response(any()) + @spec put(t(), Castable.impl(), String.t(), map()) :: response(any()) + @spec put(t(), Castable.impl(), String.t(), map(), Keyword.t()) :: response(any()) + def put(client, castable_module, path, body \\ %{}, opts \\ []) do + client_module = client.client || WorkOS.Client.TeslaClient + + opts = + opts + |> Keyword.put(:method, :put) + |> Keyword.put(:url, path) + |> Keyword.put(:body, body) + + client_module.request(client, opts) + |> handle_response(path, castable_module) + end + + @spec delete(t(), Castable.impl(), String.t()) :: response(any()) + @spec delete(t(), Castable.impl(), String.t(), map()) :: response(any()) + @spec delete(t(), Castable.impl(), String.t(), map(), Keyword.t()) :: response(any()) + def delete(client, castable_module, path, body \\ %{}, opts \\ []) do + client_module = client.client || WorkOS.Client.TeslaClient + + opts = + opts + |> Keyword.put(:method, :delete) + |> Keyword.put(:url, path) + |> Keyword.put(:body, body) + + client_module.request(client, opts) + |> handle_response(path, castable_module) + end + + defp handle_response(response, path, castable_module) do + case response do + {:ok, %{body: "", status: status}} when status in 200..299 -> + {:ok, Castable.cast(castable_module, %{})} + + {:ok, %{body: body, status: status}} when status in 200..299 -> + {:ok, Castable.cast(castable_module, body)} + + {:ok, %{body: body}} when is_map(body) -> + Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{inspect(body)}") + {:error, Castable.cast(WorkOS.Error, body)} + + {:ok, %{body: body}} when is_binary(body) -> + Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{body}") + {:error, body} + + {:error, reason} -> + Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{inspect(reason)}") + {:error, :client_error} + end + end +end diff --git a/lib/workos/client/tesla_client.ex b/lib/workos/client/tesla_client.ex new file mode 100644 index 00000000..cca946c4 --- /dev/null +++ b/lib/workos/client/tesla_client.ex @@ -0,0 +1,30 @@ +defmodule WorkOS.Client.TeslaClient do + @moduledoc """ + Tesla client for WorkOS. This is the default HTTP client used. + """ + @behaviour WorkOS.Client + + @doc """ + Sends a request to a WorkOS API endpoint, given list of request opts. + """ + @spec request(WorkOS.Client.t(), Keyword.t()) :: + {:ok, %{body: map(), status: pos_integer()}} | {:error, any()} + def request(client, opts) do + opts = Keyword.take(opts, [:method, :url, :query, :headers, :body, :opts]) + Tesla.request(new(client), opts) + end + + @doc """ + Returns a new `Tesla.Client`, configured for calling the WorkOs API. + """ + @spec new(WorkOS.Client.t()) :: Tesla.Client.t() + def new(client) do + Tesla.client([ + Tesla.Middleware.Logger, + {Tesla.Middleware.BaseUrl, client.base_url}, + Tesla.Middleware.PathParams, + Tesla.Middleware.JSON, + {Tesla.Middleware.Headers, [{"Authorization", "Bearer #{client.api_key}"}]} + ]) + end +end diff --git a/lib/workos/error.ex b/lib/workos/error.ex new file mode 100644 index 00000000..c6aad658 --- /dev/null +++ b/lib/workos/error.ex @@ -0,0 +1,39 @@ +defmodule WorkOS.Error do + @moduledoc """ + Castable module for returning structured errors from the WorkOS API. + """ + + @behaviour WorkOS.Castable + + @type unprocessable_entity_error() :: %{ + field: String.t(), + code: String.t() + } + + @type t() :: %__MODULE__{ + code: String.t() | nil, + error: String.t() | nil, + errors: [unprocessable_entity_error()] | nil, + message: String.t(), + error_description: String.t() | nil + } + + defstruct [ + :code, + :error, + :errors, + :message, + :error_description + ] + + @impl true + def cast(error) when is_map(error) do + %__MODULE__{ + code: error["code"], + error: error["error"], + errors: error["errors"], + message: error["message"], + error_description: error["error_description"] + } + end +end