defmodule Tesla.Error do defexception env: nil, stack: [], reason: nil def message(%Tesla.Error{env: %{url: url, method: method}, reason: reason}) do "#{inspect(reason)} (#{method |> to_string |> String.upcase()} #{url})" end end defmodule Tesla.Env do @moduledoc """ This module defines a `t:Tesla.Env.t/0` struct that stores all data related to request/response. ## Fields - `:method` - method of request. Example: `:get` - `:url` - request url. Example: `"https://www.google.com"` - `:query` - list of query params. Example: `[{"param", "value"}]` will be translated to `?params=value`. Note: query params passed in url (e.g. `"/get?param=value"`) are not parsed to `query` field. - `:headers` - list of request/response headers. Example: `[{"content-type", "application/json"}]`. Note: request headers are overriden by response headers when adapter is called. - `:body` - request/response body. Note: request body is overriden by response body when adapter is called. - `:status` - response status. Example: `200` - `:opts` - list of options. Example: `[adapter: [recv_timeout: 30_000]]` """ @type client :: Tesla.Client.t() @type method :: :head | :get | :delete | :trace | :options | :post | :put | :patch @type url :: binary @type param :: binary | [{binary | atom, param}] @type query :: [{binary | atom, param}] @type headers :: [{binary, binary}] @type body :: any @type status :: integer | nil @type opts :: keyword @type stack :: [{atom, atom, any} | {atom, atom} | {:fn, (t -> t)} | {:fn, (t, stack -> t)}] @type result :: {:ok, t()} | {:error, any} @type t :: %__MODULE__{ method: method, query: query, url: url, headers: headers, body: body, status: status, opts: opts, __module__: atom, __client__: client } defstruct method: nil, url: "", query: [], headers: [], body: nil, status: nil, opts: [], __module__: nil, __client__: nil end defmodule Tesla.Client do @type adapter :: module | {module, any} | (Tesla.Env.t() -> Tesla.Env.result()) @type middleware :: module | {module, any} @type t :: %__MODULE__{ pre: Tesla.Env.stack(), post: Tesla.Env.stack(), adapter: adapter | nil } defstruct fun: nil, pre: [], post: [], adapter: nil end defmodule Tesla.Middleware do @moduledoc """ The middleware specification Middleware is an extension of basic `Tesla` functionality. It is a module that must implement `c:Tesla.Middleware.call/3`. ## Middleware options Options can be passed to middleware in second param of `Tesla.Builder.plug/2` macro: plug Tesla.Middleware.BaseUrl, "https://example.com" or inside tuple in case of dynamic middleware (`Tesla.client/1`) Tesla.client([{Tesla.Middleware.BaseUrl, "https://example.com"}]) ## Writing custom middleware Writing custom middleware is as simple as creating a module implementing `c:Tesla.Middleware.call/3`. See `c:Tesla.Middleware.call/3` for details. ### Example defmodule MyProject.InspectHeadersMiddleware do @behaviour Tesla.Middleware @impl Tesla.Middleware def call(env, next, options) do env |> inspect_headers(options) |> Tesla.run(next) |> inspect_headers(options) end defp inspect_headers(env, options) do IO.inspect(env.headers, options) end end """ @doc """ Invoked when a requset runs. - (optionally) read and/or writes request data - calls `Tesla.run/2` - (optionally) read and/or writes response data ## Arguments - `env` - `Tesla.Env` struct that stores request/response data - `next` - middlewares that should be called after current one - `options` - middleware options provided by user """ @callback call(env :: Tesla.Env.t(), next :: Tesla.Env.stack(), options :: any) :: Tesla.Env.result() end defmodule Tesla.Adapter do @moduledoc """ The adapter specification. Adapter is a module that denormalize request data stored in `Tesla.Env` in order to make request with lower level http client (e.g. `:httpc` or `:hackney`) and normalize response data in order to store it back to `Tesla.Env`. It has to implement `c:Tesla.Adapter.call/2`. ## Writing custom adapter Create a module implementing `c:Tesla.Adapter.call/2`. See `c:Tesla.Adapter.call/2` for details. ### Example defmodule MyProject.CustomAdapter do alias Tesla.Multipart @behaviour Tesla.Adapter @override_defaults [follow_redirect: false] @impl Tesla.Adapter def call(env, opts) do opts = Tesla.Adapter.opts(@override_defaults, env, opts) with {:ok, {status, headers, body}} <- request(env.method, env.body, env.headers, opts) do {:ok, normalize_response(env, status, headers, body)} end end defp request(_method, %Stream{}, _headers, _opts) do {:error, "stream not supported by adapter"} end defp request(_method, %Multipart{}, _headers, _opts) do {:error, "multipart not supported by adapter"} end defp request(method, body, headers, opts) do :lower_level_http.request(method, body, denormalize_headers(headers), opts) end defp denormalize_headers(headers), do: ... defp normalize_response(env, status, headers, body), do: %Tesla.Env{env | ...} end """ @doc """ Invoked when a request runs. ## Arguments - `env` - `Tesla.Env` struct that stores request/response data - `options` - middleware options provided by user """ @callback call(env :: Tesla.Env.t(), options :: any) :: Tesla.Env.result() @doc """ Helper function that merges all adapter options. ## Arguments - `defaults` (optional) - useful to override lower level http client default configuration - `env` - `Tesla.Env` struct - `opts` - options provided to `Tesla.Builder.adapter/2` macro ## Precedence rules - config from `opts` overrides config from `defaults` when same key is encountered - config from `env` overrides config from both `defaults` and `opts` when same key is encountered """ @spec opts(Keyword.t(), Tesla.Env.t(), Keyword.t()) :: Keyword.t() def opts(defaults \\ [], env, opts) do defaults |> Keyword.merge(opts || []) |> Keyword.merge(env.opts[:adapter] || []) end end defmodule Tesla do use Tesla.Builder alias Tesla.Env require Tesla.Adapter.Httpc @default_adapter Tesla.Adapter.Httpc @moduledoc """ A HTTP toolkit for building API clients using middlewares ## Building API client `use Tesla` macro will generate basic http functions (e.g. get, post) inside your module. It supports following options: - `:only` - builder will generate only functions included in list given in this option - `:except` - builder won't generate functions included in list given in this option - `:docs` - when set to false builder will won't add documentation to generated functions ### Example defmodule ExampleApi do use Tesla, only: [:get], docs: false plug Tesla.Middleware.BaseUrl, "http://api.example.com" plug Tesla.Middleware.JSON def fetch_data do get("/data") end end In example above `ExampleApi.fetch_data/0` is equivalent of `ExampleApi.get("/data")`. ## Direct usage It is also possible to do request directly with `Tesla` module. Tesla.get("https://example.com") ### Common pitfalls Direct usage won't include any middlewares. In following example: defmodule ExampleApi do use Tesla, only: [:get], docs: false plug Tesla.Middleware.BaseUrl, "http://api.example.com" plug Tesla.Middleware.JSON def fetch_data do Tesla.get("/data") end end call to `ExampleApi.fetch_data/0` will fail, because request will be missing base url. ## Default adapter By default `Tesla` is using `Tesla.Adapter.Httpc`, because `:httpc` is included in Erlang/OTP and doen not require installation of any additional dependency. It can be changed globally with config config :tesla, :adapter, Tesla.Adapter.Hackney or by `Tesla.Builder.adapter/2` macro for given API client module defmodule ExampleApi do use Tesla adapter Tesla.Adapter.Hackney ... end """ defmacro __using__(opts \\ []) do quote do use Tesla.Builder, unquote(opts) end end @doc false def execute(module, client, options) do {env, stack} = prepare(module, client, options) run(env, stack) end @doc false def execute!(module, client, options) do {env, stack} = prepare(module, client, options) case run(env, stack) do {:ok, env} -> env {:error, error} -> raise Tesla.Error, env: env, stack: stack, reason: error end end defp prepare(module, %{pre: pre, post: post} = client, options) do env = struct(Env, options ++ [__module__: module, __client__: client]) stack = pre ++ module.__middleware__ ++ post ++ [effective_adapter(module, client)] {env, stack} end @doc false def effective_adapter(module, client \\ %Tesla.Client{}) do with nil <- client.adapter, nil <- adapter_per_module_from_config(module), nil <- adapter_per_module(module), nil <- adapter_from_config() do adapter_default() end end defp adapter_per_module_from_config(module) do case Application.get_env(:tesla, module, [])[:adapter] do nil -> nil {adapter, opts} -> {adapter, :call, [opts]} adapter -> {adapter, :call, [[]]} end end defp adapter_per_module(module) do module.__adapter__ end defp adapter_from_config do case Application.get_env(:tesla, :adapter) do nil -> nil {adapter, opts} -> {adapter, :call, [opts]} adapter -> {adapter, :call, [[]]} end end defp adapter_default do {@default_adapter, :call, [[]]} end def run_default_adapter(env, opts \\ []) do apply(@default_adapter, :call, [env, opts]) end # empty stack case is useful for reusing/testing middlewares (just pass [] as next) def run(env, []), do: {:ok, env} # last item in stack is adapter - skip passing rest of stack def run(env, [{:fn, f}]), do: apply(f, [env]) def run(env, [{m, f, a}]), do: apply(m, f, [env | a]) # for all other elements pass (env, next, opts) def run(env, [{:fn, f} | rest]), do: apply(f, [env, rest]) def run(env, [{m, f, a} | rest]), do: apply(m, f, [env, rest | a]) @doc """ Adds given key/value pair to `:opts` field in `Tesla.Env`. Useful when there's need to store additional middleware data in `Tesla.Env` ## Example iex> %Tesla.Env{opts: []} |> Tesla.put_opt(:option, "value") %Tesla.Env{opts: [option: "value"]} """ @spec put_opt(Tesla.Env.t(), atom, any) :: Tesla.Env.t() def put_opt(env, key, value) do Map.update!(env, :opts, &Keyword.put(&1, key, value)) end @doc """ Returns value of header specified by `key` from `:headers` field in `Tesla.Env`. ## Examples # non existing header iex> env = %Tesla.Env{headers: [{"server", "Cowboy"}]} iex> Tesla.get_header(env, "some-key") nil # existing header iex> env = %Tesla.Env{headers: [{"server", "Cowboy"}]} iex> Tesla.get_header(env, "server") "Cowboy" # first of multiple headers with the same name iex> env = %Tesla.Env{headers: [{"cookie", "chocolate"}, {"cookie", "biscuits"}]} iex> Tesla.get_header(env, "cookie") "chocolate" """ @spec get_header(Env.t(), binary) :: binary | nil def get_header(%Env{headers: headers}, key) do case List.keyfind(headers, key, 0) do {_, value} -> value _ -> nil end end @spec get_headers(Env.t(), binary) :: [binary] def get_headers(%Env{headers: headers}, key) when is_binary(key) do for {k, v} <- headers, k == key, do: v end @spec put_header(Env.t(), binary, binary) :: Env.t() def put_header(%Env{} = env, key, value) when is_binary(key) and is_binary(value) do headers = List.keystore(env.headers, key, 0, {key, value}) %{env | headers: headers} end @spec put_headers(Env.t(), [{binary, binary}]) :: Env.t() def put_headers(%Env{} = env, list) when is_list(list) do %{env | headers: env.headers ++ list} end @spec delete_header(Env.t(), binary) :: Env.t() def delete_header(%Env{} = env, key) when is_binary(key) do headers = for {k, v} <- env.headers, k != key, do: {k, v} %{env | headers: headers} end @spec put_body(Env.t(), Env.body()) :: Env.t() def put_body(%Env{} = env, body), do: %{env | body: body} @doc """ Dynamically build client from list of middlewares and/or adapter. ``` # add dynamic middleware client = Tesla.client([{Tesla.Middleware.Headers, [{"authorization", token}]}]) Tesla.get(client, "/path") # configure adapter in runtime client = Tesla.client([], Tesla.Adapter.Hackney) client = Tesla.client([], {Tesla.Adapter.Hackney, pool: :my_pool}) Tesla.get(client, "/path") # complete module example defmodule MyApi do # note there is no need for `use Tesla` @middleware [ {Tesla.Middleware.BaseUrl, "https://example.com"}, Tesla.Middleware.JSON, Tesla.Middleware.Logger ] @adapter Tesla.Adapter.Hackney def new(opts) do # do any middleware manipulation you need middleware = [ {Tesla.Middleware.BasicAuth, username: opts[:username], password: opts[:password]} ] ++ @middleware # allow configuring adapter in runtime adapter = opts[:adapter] || @adapter # use Tesla.client/2 to put it all together Tesla.client(middleware, adapter) end def get_something(client, id) do # pass client directly to Tesla.get/2 Tesla.get(client, "/something/\#{id}") # ... end end client = MyApi.new(username: "admin", password: "secret") MyApi.get_something(client, 42) ``` """ if Version.match?(System.version(), "~> 1.7"), do: @doc(since: "1.2.0") @spec client([Tesla.Client.middleware()], Tesla.Client.adapter()) :: Tesla.Client.t() def client(middleware, adapter \\ nil), do: Tesla.Builder.client(middleware, [], adapter) @deprecated "Use client/1 or client/2 instead" def build_client(pre, post \\ []), do: Tesla.Builder.client(pre, post) @deprecated "Use client/1 or client/2 instead" def build_adapter(fun), do: Tesla.Builder.client([], [], fun) def build_url(url, []), do: url def build_url(url, query) do join = if String.contains?(url, "?"), do: "&", else: "?" url <> join <> encode_query(query) end defp encode_query(query) do query |> Enum.flat_map(&encode_pair/1) |> URI.encode_query() end @doc false def encode_pair({key, value}) when is_list(value) do if Keyword.keyword?(value) do Enum.flat_map(value, fn {k, v} -> encode_pair({"#{key}[#{k}]", v}) end) else Enum.map(value, fn e -> {"#{key}[]", e} end) end end @doc false def encode_pair({key, value}), do: [{key, value}] end