Page MenuHomePhorge

No OneTemporary

Size
200 KB
Referenced Files
None
Subscribers
None
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5d887fe..1813f1d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,90 +1,91 @@
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
elixir:
- 1.9-slim # otp 22
- 1.8-otp-22
- 1.8-slim # otp 21
- 1.7-slim # otp 21
- - 1.6-otp-21
+ - 1.6-otp-21
- 1.6-slim # otp 20
- 1.5-slim # opt 20
container:
image: elixir:${{ matrix.elixir }}
steps:
- uses: actions/checkout@master
- name: Install Dependencies
env:
MIX_ENV: test
run: |
mix local.rebar --force
mix local.hex --force
mix deps.get
- name: Run Tests
run: mix test --trace
test-1:
name: run tests against v1.0.0
runs-on: ubuntu-latest
container:
image: elixir:1.6-slim
steps:
- uses: actions/checkout@master
- name: Install Dependencies
env:
MIX_ENV: test
run: |
mix local.rebar --force
mix local.hex --force
mix deps.get
- uses: srt32/git-actions@v0.0.3
with:
args: |
git remote set-branches origin '*' && \
git fetch origin v1.0.0-compat && \
+ rm -r test && \
git checkout origin/v1.0.0-compat -- test
-
+
- name: Run Tests for 1.0.0 branch
run: mix test --trace
test-poison3:
runs-on: ubuntu-latest
container:
image: elixir:1.9-slim
steps:
- uses: actions/checkout@master
- name: Install Dependencies
- env:
- MIX_ENV: test
+ env:
+ MIX_ENV: test
LOCKFILE: poison3
run: |
mix local.rebar --force
mix local.hex --force
mix deps.get
- name: Run Tests for poison 3.x
- env:
+ env:
MIX_ENV: test
LOCKFILE: poison3
run: mix test --trace
format:
runs-on: ubuntu-latest
container:
image: elixir:1.9-slim
steps:
- uses: actions/checkout@master
- name: Install Dependencies
run: |
mix local.rebar --force
mix local.hex --force
mix deps.get
- name: Check Format
run: mix format --check-formatted --dry-run
diff --git a/README.md b/README.md
index 4f69eb8..080f5ae 100644
--- a/README.md
+++ b/README.md
@@ -1,499 +1,524 @@
# Tesla
[![Build Status](https://github.com/teamon/tesla/workflows/Test/badge.svg)](https://github.com/teamon/tesla/actions)
[![Hex.pm](https://img.shields.io/hexpm/v/tesla.svg)](http://hex.pm/packages/tesla)
[![Hex.pm](https://img.shields.io/hexpm/dt/tesla.svg)](https://hex.pm/packages/tesla)
[![Hex.pm](https://img.shields.io/hexpm/dw/tesla.svg)](https://hex.pm/packages/tesla)
[![codecov](https://codecov.io/gh/teamon/tesla/branch/master/graph/badge.svg)](https://codecov.io/gh/teamon/tesla)
[![Inline docs](https://inch-ci.org/github/teamon/tesla.svg)](http://inch-ci.org/github/teamon/tesla)
Tesla is an HTTP client loosely based on [Faraday](https://github.com/lostisland/faraday).
It embraces the concept of middleware when processing the request/response cycle.
> Note that this README refers to the `master` branch of Tesla, not the latest
released version on Hex. See [the documentation](http://hexdocs.pm/tesla) for
the documentation of the version you're using.
-<hr/>
+---
## [`0.x` to `1.0` Migration Guide](https://github.com/teamon/tesla/wiki/0.x-to-1.0-Migration-Guide)
```elixir
defp deps do
- [{:tesla, "~> 1.2.1"}]
+ [{:tesla, "~> 1.3.0"}]
end
```
[Documentation for 0.x branch](https://github.com/teamon/tesla/tree/0.x)
-<hr/>
+---
## HTTP Client example
Define module with `use Tesla` and choose from a variety of middleware.
```elixir
defmodule GitHub do
use Tesla
plug Tesla.Middleware.BaseUrl, "https://api.github.com"
plug Tesla.Middleware.Headers, [{"authorization", "token xyz"}]
plug Tesla.Middleware.JSON
def user_repos(login) do
- get("/user/" <> login <> "/repos")
+ get("/users/" <> login <> "/repos")
end
end
```
Then use it like this:
```elixir
{:ok, response} = GitHub.user_repos("teamon")
-response.status # => 200
-response.body # => [%{…}, …]
-response.headers # => [{"content-type", "application/json"}, ...]
+
+response.status
+# => 200
+
+response.body
+# => [%{…}, …]
+
+response.headers
+# => [{"content-type", "application/json"}, ...]
```
See below for documentation.
## Installation
Add `tesla` as dependency in `mix.exs`:
```elixir
defp deps do
[
- {:tesla, "~> 1.2.0"},
- {:hackney, "~> 1.14.0"}, # optional, but recommended adapter
- {:jason, ">= 1.0.0"} # optional, required by JSON middleware
- ]
+ {:tesla, "~> 1.3.0"},
+
+ # optional, but recommended adapter
+ {:hackney, "~> 1.14.0"},
+
+ # optional, required by JSON middleware
+ {:jason, ">= 1.0.0"}
+ ]
end
+
```
Configure default adapter in `config/config.exs` (optional).
```elixir
# config/config.exs
config :tesla, adapter: Tesla.Adapter.Hackney
```
> The default adapter is erlang's built-in `httpc`, but it is not recommended
to use it in production environment as it does not validate SSL certificates
[among other issues](https://github.com/teamon/tesla/issues?utf8=%E2%9C%93&q=is%3Aissue+label%3Ahttpc+).
## Documentation
+
- [Middleware](#middleware)
- [Runtime middleware](#runtime-middleware)
- [Adapters](#adapters)
- [Streaming](#streaming)
- [Multipart](#multipart)
- [Testing](#testing)
- [Writing middleware](#writing-middleware)
- [Direct usage](#direct-usage)
- [Cheatsheet](#cheatsheet)
- [Cookbook](https://github.com/teamon/tesla/wiki)
- [Changelog](https://github.com/teamon/tesla/releases)
-
## Middleware
+
Tesla is built around the concept of composable middlewares.
This is very similar to how [Plug Router](https://github.com/elixir-plug/plug#the-plug-router) works.
-#### Basic
+### Basic
+
- [`Tesla.Middleware.BaseUrl`](https://hexdocs.pm/tesla/Tesla.Middleware.BaseUrl.html) - set base url
- [`Tesla.Middleware.Headers`](https://hexdocs.pm/tesla/Tesla.Middleware.Headers.html) - set request headers
- [`Tesla.Middleware.Query`](https://hexdocs.pm/tesla/Tesla.Middleware.Query.html) - set query parameters
- [`Tesla.Middleware.Opts`](https://hexdocs.pm/tesla/Tesla.Middleware.Opts.html) - set request options
- [`Tesla.Middleware.FollowRedirects`](https://hexdocs.pm/tesla/Tesla.Middleware.FollowRedirects.html) - follow 3xx redirects
- [`Tesla.Middleware.MethodOverride`](https://hexdocs.pm/tesla/Tesla.Middleware.MethodOverride.html) - set X-Http-Method-Override
- [`Tesla.Middleware.Logger`](https://hexdocs.pm/tesla/Tesla.Middleware.Logger.html) - log requests (method, url, status, time)
- [`Tesla.Middleware.KeepRequest`](https://hexdocs.pm/tesla/Tesla.Middleware.KeepRequest.html) - keep request body & headers
- [`Tesla.Middleware.PathParams`](https://hexdocs.pm/tesla/Tesla.Middleware.PathParams.html) - use templated URLs
-#### Formats
-- [`Tesla.Middleware.FormUrlencoded`](https://hexdocs.pm/tesla/Tesla.Middleware.FormUrlencoded.html) - urlencode POST body parameter, useful for POSTing a map/keyword list
+### Formats
+
+- [`Tesla.Middleware.FormUrlencoded`](https://hexdocs.pm/tesla/Tesla.Middleware.FormUrlencoded.html) - urlencode POST body, useful for POSTing a map/keyword list
- [`Tesla.Middleware.JSON`](https://hexdocs.pm/tesla/Tesla.Middleware.JSON.html) - JSON request/response body
- [`Tesla.Middleware.Compression`](https://hexdocs.pm/tesla/Tesla.Middleware.Compression.html) - gzip & deflate
- [`Tesla.Middleware.DecodeRels`](https://hexdocs.pm/tesla/Tesla.Middleware.DecodeRels.html) - decode `Link` header into `opts[:rels]` field in response
-#### Auth
+### Auth
+
- [`Tesla.Middleware.BasicAuth`](https://hexdocs.pm/tesla/Tesla.Middleware.BasicAuth.html) - HTTP Basic Auth
- [`Tesla.Middleware.DigestAuth`](https://hexdocs.pm/tesla/Tesla.Middleware.DigestAuth.html) - Digest access authentication
-#### Error handling
+### Error handling
+
- [`Tesla.Middleware.Timeout`](https://hexdocs.pm/tesla/Tesla.Middleware.Timeout.html) - timeout request after X milliseconds despite of server response
- [`Tesla.Middleware.Retry`](https://hexdocs.pm/tesla/Tesla.Middleware.Retry.html) - retry few times in case of connection refused
- [`Tesla.Middleware.Fuse`](https://hexdocs.pm/tesla/Tesla.Middleware.Fuse.html) - fuse circuit breaker integration
-
## Runtime middleware
-All HTTP functions (`get`, `post`, etc.) can take a dynamic client as the first parameter.
+All HTTP functions (`get`, `post`, etc.) can take a dynamic client as the first argument.
This allow to use convenient syntax for modifying the behaviour in runtime.
Consider the following case: GitHub API can be accessed using OAuth token authorization.
We can't use `plug Tesla.Middleware.Headers, [{"authorization", "token here"}]`
since this would be compiled only once and there is no way to insert dynamic user token.
Instead, we can use `Tesla.client` to create a client with dynamic middleware:
```elixir
defmodule GitHub do
# notice there is no `use Tesla`
def user_repos(client, login) do
# pass `client` argument to `Tesla.get` function
Tesla.get(client, "/user/" <> login <> "/repos")
end
def issues(client) do
Tesla.get(client, "/issues")
end
# build dynamic client based on runtime arguments
def client(token) do
middleware = [
{Tesla.Middleware.BaseUrl, "https://api.github.com"},
Tesla.Middleware.JSON,
{Tesla.Middleware.Headers, [{"authorization", "token: " <> token }]}
]
Tesla.client(middleware)
end
end
```
and then:
```elixir
client = GitHub.client(user_token)
client |> GitHub.user_repos("teamon")
client |> GitHub.get("/me")
```
## Adapters
Tesla supports multiple HTTP adapter that do the actual HTTP request processing.
- [`Tesla.Adapter.Httpc`](https://hexdocs.pm/tesla/Tesla.Adapter.Httpc.html) - the default, built-in erlang [httpc](http://erlang.org/doc/man/httpc.html) adapter
- [`Tesla.Adapter.Hackney`](https://hexdocs.pm/tesla/Tesla.Adapter.Hackney.html) - [hackney](https://github.com/benoitc/hackney), "simple HTTP client in Erlang"
- [`Tesla.Adapter.Ibrowse`](https://hexdocs.pm/tesla/Tesla.Adapter.Ibrowse.html) - [ibrowse](https://github.com/cmullaparthi/ibrowse), "Erlang HTTP client"
-- [`Tesla.Adapter.Gun`](https://hexdocs.pm/tesla/Tesla.Adapter.Gun.html) - [gun](https://github.com/ninenines/gun), "HTTP/1.1, HTTP/2 and Websocket client for Erlang/OTP."
-- [`Tesla.Adapter.Mint`](https://hexdocs.pm/tesla/Tesla.Adapter.Mint.html) - [mint](https://github.com/ericmj/mint), "Functional HTTP client for Elixir with support for HTTP/1 and HTTP/2"
+- [`Tesla.Adapter.Gun`](https://hexdocs.pm/tesla/Tesla.Adapter.Gun.html) - [gun](https://github.com/ninenines/gun), "HTTP/1.1, HTTP/2 and Websocket client for Erlang/OTP"
+- [`Tesla.Adapter.Mint`](https://hexdocs.pm/tesla/Tesla.Adapter.Mint.html) - [mint](https://github.com/elixir-mint/mint), "Functional HTTP client for Elixir with support for HTTP/1 and HTTP/2"
When using adapter other than httpc remember to add it to the dependencies list in `mix.exs`
```elixir
defp deps do
- [{:tesla, "~> 1.2.0"},
+ [{:tesla, "~> 1.3.0"},
{:jason, ">= 1.0.0"}, # optional, required by JSON middleware
{:hackney, "~> 1.10"}] # or :gun etc.
end
```
### Adapter options
In case there is a need to pass specific adapter options you can do it in one of three ways:
Using `adapter` macro:
```elixir
defmodule GitHub do
use Tesla
adapter Tesla.Adapter.Hackney, recv_timeout: 30_000, ssl_options: [certfile: "certs/client.crt"]
end
```
Using `Tesla.client/2`:
```elixir
def new(...) do
middleware = [...]
adapter = {Tesla.Adapter.Hackney, [recv_timeout: 30_000]}
Tesla.client(middleware, adapter)
end
```
Passing directly to `get`/`post`/etc.
```elixir
MyClient.get("/", opts: [adapter: [recv_timeout: 30_000]])
Tesla.get(client, "/", opts: [adapter: [recv_timeout: 30_000]])
```
## Streaming
If adapter supports it, you can pass a [Stream](http://elixir-lang.org/docs/stable/elixir/Stream.html) as body, e.g.:
```elixir
defmodule ElasticSearch do
use Tesla
plug Tesla.Middleware.BaseUrl, "http://localhost:9200"
plug Tesla.Middleware.JSON
def index(records_stream) do
stream = records_stream |> Stream.map(fn record -> %{index: [some, data]} end)
post("/_bulk", stream)
end
end
```
Each piece of stream will be encoded as JSON and sent as a new line (conforming to JSON stream format)
-
-
-
## Multipart
You can pass a `Tesla.Multipart` struct as the body.
-
```elixir
alias Tesla.Multipart
mp =
- Multipart.new
+ Multipart.new()
|> Multipart.add_content_type_param("charset=utf-8")
|> Multipart.add_field("field1", "foo")
- |> Multipart.add_field("field2", "bar", headers: [{"content-id", "1"}, {"content-type", "text/plain"}])
+ |> Multipart.add_field("field2", "bar",
+ headers: [{"content-id", "1"}, {"content-type", "text/plain"}]
+ )
|> Multipart.add_file("test/tesla/multipart_test_file.sh")
|> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar")
|> Multipart.add_file_content("sample file content", "sample.txt")
{:ok, response} = MyApiClient.post("http://httpbin.org/post", mp)
```
## Testing
You can set the adapter to `Tesla.Mock` in tests.
```elixir
# config/test.exs
# Use mock adapter for all clients
config :tesla, adapter: Tesla.Mock
# or only for one
config :tesla, MyApi, adapter: Tesla.Mock
```
Then, mock requests before using your client:
-
```elixir
defmodule MyAppTest do
use ExUnit.Case
import Tesla.Mock
setup do
- mock fn
+ mock(fn
%{method: :get, url: "http://example.com/hello"} ->
%Tesla.Env{status: 200, body: "hello"}
+
%{method: :post, url: "http://example.com/world"} ->
json(%{"my" => "data"})
- end
+ end)
:ok
end
test "list things" do
assert {:ok, %Tesla.Env{} = env} = MyApp.get("/hello")
assert env.status == 200
assert env.body == "hello"
end
end
```
-
## Writing middleware
-A Tesla middleware is a module with `call/3` function, that at some point calls `Tesla.run(env, next)` to process
+A Tesla middleware is a module with `c:Tesla.Middleware.call/3` function, that at some point calls `Tesla.run/2` with `env` and `next` to process
the rest of stack.
```elixir
defmodule MyMiddleware do
@behaviour Tesla.Middleware
def call(env, next, options) do
env
- |> do_something_with_request
+ |> do_something_with_request()
|> Tesla.run(next)
- |> do_something_with_response
+ |> do_something_with_response()
end
end
```
The arguments are:
+
- `env` - `Tesla.Env` instance
-- `next` - middleware continuation stack; to be executed with `Tesla.run(env, next)`
+- `next` - middleware continuation stack; to be executed with `Tesla.run/2` with `env` and `next`
- `options` - arguments passed during middleware configuration (`plug MyMiddleware, options`)
There is no distinction between request and response middleware, it's all about executing `Tesla.run/2` function at the correct time.
For example, a request logger middleware could be implemented like this:
```elixir
defmodule Tesla.Middleware.RequestLogger do
@behaviour Tesla.Middleware
def call(env, next, _) do
- IO.inspect env # print request env
- Tesla.run(env, next)
+ env
+ |> IO.inspect()
+ |> Tesla.run(next)
end
end
```
and response logger middleware like this:
```elixir
defmodule Tesla.Middleware.ResponseLogger do
@behaviour Tesla.Middleware
def call(env, next, _) do
- res = Tesla.run(env, next)
- IO.inspect res # print response env
- res
+ env
+ |> Tesla.run(next)
+ |> IO.inspect()
end
end
```
See [built-in middlewares](https://github.com/teamon/tesla/tree/master/lib/tesla/middleware) for more examples.
Middleware should have documentation following this template:
````elixir
defmodule Tesla.Middleware.SomeMiddleware do
- @behaviour Tesla.Middleware
-
@moduledoc """
Short description what it does
Longer description, including e.g. additional dependencies.
### Example usage
+
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.SomeMiddleware, most: :common, options: "here"
end
```
### Options
+
- `:list` - all possible options
- `:with` - their default values
"""
+
+ @behaviour Tesla.Middleware
end
````
-
## Direct usage
You can also use Tesla directly, without creating a client module.
This however won’t include any middleware.
```elixir
# Example get request
{:ok, response} = Tesla.get("http://httpbin.org/ip")
-response.status # => 200
-response.body # => "{\n "origin": "87.205.72.203"\n}\n"
-response.headers # => [{"content-type", "application/json" ...}]
+response.status
+# => 200
+
+response.body
+# => "{\n "origin": "87.205.72.203"\n}\n"
+
+response.headers
+# => [{"content-type", "application/json" ...}]
{:ok, response} = Tesla.get("http://httpbin.org/get", query: [a: 1, b: "foo"])
-response.url # => "http://httpbin.org/get?a=1&b=foo"
+response.url
+# => "http://httpbin.org/get?a=1&b=foo"
# Example post request
-{:ok, response} = Tesla.post("http://httpbin.org/post", "data", headers: [{"content-type", "application/json"}])
+{:ok, response} =
+ Tesla.post("http://httpbin.org/post", "data", headers: [{"content-type", "application/json"}])
```
-
## Cheatsheet
+### Making requests 101
-#### Making requests 101
```elixir
# GET /path
get("/path")
# GET /path?a=hi&b[]=1&b[]=2&b[]=3
-get("/path", query: [a: "hi", b: [1,2,3]])
+get("/path", query: [a: "hi", b: [1, 2, 3]])
# GET with dynamic client
get(client, "/path")
get(client, "/path", query: [page: 3])
# arguments are the same for GET, HEAD, OPTIONS & TRACE
head("/path")
options("/path")
trace("/path")
# POST, PUT, PATCH
post("/path", "some-body-i-used-to-know")
put("/path", "some-body-i-used-to-know", query: [a: "0"])
patch("/path", multipart)
```
-#### Configuring HTTP functions visibility
+### Configuring HTTP functions visibility
+
```elixir
# generate only get and post function
use Tesla, only: ~w(get post)a
-# generate only delete fuction
+# generate only delete function
use Tesla, only: [:delete]
# generate all functions except delete and options
use Tesla, except: [:delete, :options]
```
-#### Disable docs for HTTP functions
+### Disable docs for HTTP functions
+
```elixir
use Tesla, docs: false
```
-#### Decode only JSON response (do not encode request)
+### Decode only JSON response (do not encode request)
+
```elixir
plug Tesla.Middleware.DecodeJson
```
-#### Use other JSON library
+### Use other JSON library
+
```elixir
# use JSX
plug Tesla.Middleware.JSON, engine: JSX, engine_opts: [strict: [:comments]]
# use custom functions
plug Tesla.Middleware.JSON, decode: &JSX.decode/1, encode: &JSX.encode/1
```
+### Custom middleware
-#### Custom middleware
```elixir
defmodule Tesla.Middleware.MyCustomMiddleware do
def call(env, next, options) do
env
- |> do_something_with_request
+ |> do_something_with_request()
|> Tesla.run(next)
- |> do_something_with_response
+ |> do_something_with_response()
end
end
```
## Contributing
1. Fork it (https://github.com/teamon/tesla/fork)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
Copyright (c) 2015-2018 [Tymon Tobolski](http://teamon.eu/about/)
diff --git a/config/config.exs b/config/config.exs
index 2c43344..9d67bb7 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -1,24 +1,23 @@
use Mix.Config
config :tesla, adapter: Tesla.Adapter.Httpc
if Mix.env() == :test do
config :logger, :console,
level: :debug,
format: "$date $time [$level] $metadata$message\n"
config :httparrot,
http_port: 5080,
https_port: 5443,
ssl: true,
unix_socket: false
config :sasl,
errlog_type: :error,
sasl_error_logger: false
config :tesla, MockClient, adapter: Tesla.Mock
- config :tesla, Tesla.Adapter.Mint,
- cacert: ["./deps/httparrot/priv/ssl/server-ca.crt"]
+ config :tesla, Tesla.Adapter.Mint, cacert: ["./deps/httparrot/priv/ssl/server-ca.crt"]
end
diff --git a/lib/tesla.ex b/lib/tesla.ex
index e48e5db..3bd432d 100644
--- a/lib/tesla.ex
+++ b/lib/tesla.ex
@@ -1,512 +1,532 @@
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]]`
+ - `: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
@type opts :: [any]
@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
- export `call/3` function.
+ 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 with `call/3` function that:
- * (optionally) read and/or writes request data
- * calls `Tesla.run/2`
- * (optionally) read and/or writes response data
+ Writing custom middleware is as simple as creating a module implementing `c:Tesla.Middleware.call/3`.
- `call/3` params:
- * env - `Tesla.Env` struct that stores request/response data
- * stack - middlewares that should be called after current one
- * options - middleware options provided by user
+ See `c:Tesla.Middleware.call/3` for details.
- #### Example
+ ### Example
defmodule MyProject.InspectHeadersMiddleware do
@behaviour Tesla.Middleware
- @impl true
+ @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
+ 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 export `call/2` function.
+ in order to store it back to `Tesla.Env`. It has to implement `c:Tesla.Adapter.call/2`.
## Writing custom adapter
- `call/2` params:
- * env - `Tesla.Env` struct that stores request/response data
- * options - middleware options provided by user
+ Create a module implementing `c:Tesla.Adapter.call/2`.
- #### Example
+ 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 true
+ @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
+ Helper function that merges all adapter options.
- ## Params:
- * `defaults` (optional) - useful to override lower level http client default configuration
- * `env` - `Tesla.Env` struct
- * `opts` - options provided to `Tesla.Builder.adapter/2` macro
+ ## Arguments
- ## 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
+ - `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
+ - `: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")`
+ 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
+ ### 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`
+ 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`
+ 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
diff --git a/lib/tesla/adapter/gun.ex b/lib/tesla/adapter/gun.ex
index 10177c7..5c929f0 100644
--- a/lib/tesla/adapter/gun.ex
+++ b/lib/tesla/adapter/gun.ex
@@ -1,381 +1,456 @@
if Code.ensure_loaded?(:gun) do
defmodule Tesla.Adapter.Gun do
@moduledoc """
- Adapter for [gun](https://github.com/ninenines/gun)
+ Adapter for [gun](https://github.com/ninenines/gun).
Remember to add `{:gun, "~> 1.3"}` to dependencies.
In version 1.3 gun sends `host` header with port. Fixed in master branch.
Also, you need to recompile tesla after adding `:gun` dependency:
+
```
mix deps.clean tesla
mix deps.compile tesla
```
- ### Example usage
+
+ ## Example usage
+
```
# set globally in config/config.exs
config :tesla, :adapter, Tesla.Adapter.Gun
+
# set per module
defmodule MyClient do
use Tesla
adapter Tesla.Adapter.Gun
end
```
- ### Adapter specific options:
-
- * `timeout` - Time, while process, will wait for gun messages.
- * `body_as` - What will be returned in `%Tesla.Env{}` body key. Possible values - `:plain`, `:stream`, `:chunks`. Defaults to `:plain`.
- * `:plain` - as binary.
- * `:stream` - as stream. If you don't want to close connection (because you want to reuse it later) pass `close_conn: false` in adapter opts.
- * `:chunks` - as chunks. You can get response body in chunks using `Tesla.Adapter.Gun.read_chunk/3` function.
-
- Processing of the chunks and checking body size must be done by yourself. Example of processing function is in `test/tesla/adapter/gun_test.exs` - `read_body/3`. If you don't need connection later don't forget to close it with `Tesla.Adapter.Gun.close/1`.
- * `max_body` - Max response body size in bytes. Works only with `body_as: :plain`, with other settings you need to check response body size by yourself.
- * `conn` - Opened connection pid with gun. Is used for reusing gun connections.
- * `original` - Original host with port, for which reused connection was open. Needed for `Tesla.Middleware.FollowRedirects`. Otherwise adapter will use connection for another open host.
- * `close_conn` - Close connection or not after receiving full response body. Is used for reusing gun connections. Defaults to `true`.
- * `certificates_verification` - Add SSL certificates verification. [erlang-certifi](https://github.com/certifi/erlang-certifi) [ssl_verify_fun.erl](https://github.com/deadtrickster/ssl_verify_fun.erl)
-
- ### [Gun options](https://ninenines.eu/docs/en/gun/1.3/manual/gun/):
-
- * `connect_timeout` - Connection timeout.
- * `http_opts` - Options specific to the HTTP protocol.
- * `http2_opts` - Options specific to the HTTP/2 protocol.
- * `protocols` - Ordered list of preferred protocols. Defaults: [http2, http] - for :tls, [http] - for :tcp.
- * `trace` - Whether to enable dbg tracing of the connection process. Should only be used during debugging. Default: false.
- * `transport` - Whether to use TLS or plain TCP. The default varies depending on the port used. Port 443 defaults to tls. All other ports default to tcp.
- * `transport_opts` - Transport options. They are TCP options or TLS options depending on the selected transport. Default: []. Gun version: 1.3
- * `tls_opts` - TLS transport options. Default: []. Gun from master branch.
- * `tcp_opts` - TCP trasnport options. Default: []. Gun from master branch.
- * `ws_opts` - Options specific to the Websocket protocol. Default: %{}.
- * `compress` - Whether to enable permessage-deflate compression. This does not guarantee that compression will be used as it is the server that ultimately decides. Defaults to false.
- * `protocols` - A non-empty list enables Websocket protocol negotiation. The list of protocols will be sent in the sec-websocket-protocol request header. The handler module interface is currently undocumented and must be set to `gun_ws_h`.
-
+ ## Adapter specific options
+
+ - `:timeout` - Time, while process, will wait for gun messages.
+ - `:body_as` - What will be returned in `%Tesla.Env{}` body key. Possible values - `:plain`, `:stream`, `:chunks`. Defaults to `:plain`.
+ - `:plain` - as binary.
+ - `:stream` - as stream. If you don't want to close connection (because you want to reuse it later) pass `close_conn: false` in adapter opts.
+ - `:chunks` - as chunks. You can get response body in chunks using `Tesla.Adapter.Gun.read_chunk/3` function.
+ Processing of the chunks and checking body size must be done by yourself. Example of processing function is in `test/tesla/adapter/gun_test.exs` - `Tesla.Adapter.GunTest.read_body/4`. If you don't need connection later don't forget to close it with `Tesla.Adapter.Gun.close/1`.
+ - `:max_body` - Max response body size in bytes. Works only with `body_as: :plain`, with other settings you need to check response body size by yourself.
+ - `:conn` - Opened connection pid with gun. Is used for reusing gun connections.
+ - `:original` - Original host with port, for which reused connection was open. Needed for `Tesla.Middleware.FollowRedirects`. Otherwise adapter will use connection for another open host. Example: `"example.com:80"`.
+ - `:close_conn` - Close connection or not after receiving full response body. Is used for reusing gun connections. Defaults to `true`.
+ - `:certificates_verification` - Add SSL certificates verification. [erlang-certifi](https://github.com/certifi/erlang-certifi) [ssl_verify_fun.erl](https://github.com/deadtrickster/ssl_verify_fun.erl)
+ - `:proxy` - Proxy for requests. **Socks proxy are supported only for gun master branch**. Examples: `{'localhost', 1234}`, `{{127, 0, 0, 1}, 1234}`, `{:socks5, 'localhost', 1234}`.
+
+ ## [Gun options](https://ninenines.eu/docs/en/gun/1.3/manual/gun/)
+
+ - `:connect_timeout` - Connection timeout.
+ - `:http_opts` - Options specific to the HTTP protocol.
+ - `:http2_opts` - Options specific to the HTTP/2 protocol.
+ - `:protocols` - Ordered list of preferred protocols. Defaults: `[:http2, :http]`- for :tls, `[:http]` - for :tcp.
+ - `:trace` - Whether to enable dbg tracing of the connection process. Should only be used during debugging. Default: false.
+ - `:transport` - Whether to use TLS or plain TCP. The default varies depending on the port used. Port 443 defaults to tls. All other ports default to tcp.
+ - `:transport_opts` - Transport options. They are TCP options or TLS options depending on the selected transport. Default: `[]`. Gun version: 1.3
+ - `:tls_opts` - TLS transport options. Default: `[]`. Gun from master branch.
+ - `:tcp_opts` - TCP trasnport options. Default: `[]`. Gun from master branch.
+ - `:socks_opts` - Options for socks. Default: `[]`. Gun from master branch.
+ - `:ws_opts` - Options specific to the Websocket protocol. Default: `%{}`.
+ - `:compress` - Whether to enable permessage-deflate compression. This does not guarantee that compression will be used as it is the server that ultimately decides. Defaults to false.
+ - `:protocols` - A non-empty list enables Websocket protocol negotiation. The list of protocols will be sent in the sec-websocket-protocol request header. The handler module interface is currently undocumented and must be set to `gun_ws_h`.
"""
@behaviour Tesla.Adapter
alias Tesla.Multipart
@gun_keys [
:connect_timeout,
:http_opts,
:http2_opts,
:protocols,
:retry,
:retry_timeout,
:trace,
:transport,
+ :socks_opts,
:ws_opts
]
@adapter_default_timeout 1_000
- @impl true
- @doc false
+ @impl Tesla.Adapter
def call(env, opts) do
with {:ok, status, headers, body} <- request(env, opts) do
{:ok, %{env | status: status, headers: format_headers(headers), body: body}}
end
end
+ @doc """
+ Reads chunk of the response body.
+
+ Returns `{:fin, binary()}` if all body received, otherwise returns `{:nofin, binary()}`.
+ """
+ @spec read_chunk(pid(), reference(), keyword() | map()) ::
+ {:fin, binary()} | {:nofin, binary()} | {:error, :timeout}
+ def read_chunk(pid, stream, opts) do
+ receive do
+ {:gun_data, ^pid, ^stream, :fin, body} ->
+ if opts[:close_conn], do: close(pid)
+ {:fin, body}
+
+ {:gun_data, ^pid, ^stream, :nofin, part} ->
+ {:nofin, part}
+
+ {:DOWN, _, _, _, reason} ->
+ if opts[:close_conn], do: close(pid)
+ {:error, reason}
+ after
+ opts[:timeout] || @adapter_default_timeout ->
+ {:error, :recv_chunk_timeout}
+ end
+ end
+
+ @doc """
+ Brutally close the `gun` connection.
+ """
+ @spec close(pid()) :: :ok
+ defdelegate close(pid), to: :gun
+
defp format_headers(headers) do
for {key, value} <- headers do
{String.downcase(to_string(key)), to_string(value)}
end
end
- defp format_method(method), do: String.upcase(to_string(method))
-
- defp format_url(nil, nil), do: "/"
- defp format_url(nil, query), do: "/?" <> query
- defp format_url(path, nil), do: path
- defp format_url(path, query), do: path <> "?" <> query
-
defp request(env, opts) do
request(
- format_method(env.method),
+ Tesla.Adapter.Shared.format_method(env.method),
Tesla.build_url(env.url, env.query),
- env.headers,
+ format_headers(env.headers),
env.body || "",
Tesla.Adapter.opts(
[close_conn: true, body_as: :plain, send_body: :at_once, receive: true],
env,
opts
)
|> Enum.into(%{})
)
end
defp request(method, url, headers, %Stream{} = body, opts),
do: request_stream(method, url, headers, body, Map.put(opts, :send_body, :stream))
defp request(method, url, headers, body, opts) when is_function(body),
do: request_stream(method, url, headers, body, Map.put(opts, :send_body, :stream))
defp request(method, url, headers, %Multipart{} = mp, opts) do
headers = headers ++ Multipart.headers(mp)
body = Multipart.body(mp)
request(method, url, headers, body, opts)
end
defp request(method, url, headers, body, opts),
do: do_request(method, url, headers, body, opts)
defp request_stream(method, url, headers, body, opts),
do: do_request(method, url, headers, body, opts)
defp do_request(method, url, headers, body, opts) do
with uri <- URI.parse(url),
- path_with_query <- format_url(uri.path, uri.query),
- {pid, opts} <- open_conn(uri, opts),
- stream <- open_stream(pid, method, path_with_query, headers, body, opts[:send_body]) do
+ path <- Tesla.Adapter.Shared.prepare_path(uri.path, uri.query),
+ opts <- check_original(uri, opts),
+ {:ok, pid, opts} <- open_conn(uri, opts),
+ stream <- open_stream(pid, method, path, headers, body, opts) do
read_response(pid, stream, opts)
end
end
- defp open_conn(uri, %{conn: conn, original: original} = opts) do
- if original == "#{uri.host}:#{uri.port}" do
- {conn, Map.put(opts, :receive, false)}
- else
- # current url is different from the original, maybe there were redirects
- # so we can't use transferred connection
- open_conn(uri, Map.delete(opts, :conn))
- end
+ defp check_original(%URI{host: host, port: port}, %{original: original} = opts) do
+ Map.put(opts, :original_matches, original == "#{domain_or_fallback(host)}:#{port}")
+ end
+
+ defp check_original(_uri, opts), do: opts
+
+ defp open_conn(_uri, %{conn: conn, original_matches: true} = opts) do
+ {:ok, conn, Map.put(opts, :receive, false)}
+ end
+
+ defp open_conn(uri, %{conn: conn, original_matches: false} = opts) do
+ # current url is different from the original, so we can't use transferred connection
+ opts =
+ opts
+ |> Map.put_new(:old_conn, conn)
+ |> Map.delete(:conn)
+
+ open_conn(uri, opts)
end
defp open_conn(uri, opts) do
opts =
if uri.scheme == "https" and uri.port != 443 do
Map.put(opts, :transport, :tls)
else
opts
end
- # We need to add `server_name_indication` option, because gun connects through ip in master branch.
- # [SNI] - http://erlang.org/doc/man/ssl.html#type-sni
- # Support for gun master branch where transport_opts, were splitted to tls_opts and tcp_opts
- # https://github.com/ninenines/gun/blob/491ddf58c0e14824a741852fdc522b390b306ae2/doc/src/manual/gun.asciidoc#changelog
-
tls_opts =
- Map.get(opts, :tls_opts, [])
+ opts
+ |> Map.get(:tls_opts, [])
|> Keyword.merge(Map.get(opts, :transport_opts, []))
tls_opts =
- if uri.scheme == "https" do
- host = uri.host |> to_charlist()
+ with "https" <- uri.scheme,
+ false <- opts[:original_matches] do
+ # current url is different from the original, so we can't use verify_fun for https requests
+ Keyword.delete(tls_opts, :verify_fun)
+ else
+ _ -> tls_opts
+ end
+
+ # Support for gun master branch where transport_opts, were splitted to tls_opts and tcp_opts
+ # https://github.com/ninenines/gun/blob/491ddf58c0e14824a741852fdc522b390b306ae2/doc/src/manual/gun.asciidoc#changelog
- ssl_opts = [
- server_name_indication: host
+ tls_opts =
+ with "https" <- uri.scheme,
+ true <- opts[:certificates_verification] do
+ security_opts = [
+ verify: :verify_peer,
+ cacertfile: CAStore.file_path(),
+ depth: 20,
+ reuse_sessions: false,
+ verify_fun:
+ {&:ssl_verify_hostname.verify_fun/3, [check_hostname: domain_or_fallback(uri.host)]}
]
- ssl_opts =
- if opts[:certificates_verification] do
- security_opts = [
- verify: :verify_peer,
- cacerts: :certifi.cacerts(),
- depth: 20,
- reuse_sessions: false,
- verify_fun: {&:ssl_verify_hostname.verify_fun/3, [check_hostname: host]}
- ]
-
- Keyword.merge(ssl_opts, security_opts)
- else
- ssl_opts
- end
-
- Keyword.merge(tls_opts, ssl_opts)
+ Keyword.merge(security_opts, tls_opts)
else
- tls_opts
+ _ -> tls_opts
end
gun_opts = Map.take(opts, @gun_keys)
with {:ok, pid} <- do_open_conn(uri, opts, gun_opts, tls_opts) do
# If there were redirects, and passed `closed_conn: false`, we need to close opened connections to these intermediate hosts.
- {pid, Map.put(opts, :close_conn, true)}
+ {:ok, pid, Map.put(opts, :close_conn, true)}
end
end
defp do_open_conn(uri, %{proxy: {proxy_host, proxy_port}}, gun_opts, tls_opts) do
- connect_opts = %{host: to_charlist(uri.host), port: uri.port}
-
connect_opts =
- if uri.scheme == "https" do
- Map.put(connect_opts, :protocols, [:http2])
- |> Map.put(:transport, :tls)
- |> Map.put(:tls_opts, tls_opts)
- else
- connect_opts
- end
+ uri
+ |> tunnel_opts()
+ |> tunnel_tls_opts(uri.scheme, tls_opts)
with {:ok, pid} <- :gun.open(proxy_host, proxy_port, gun_opts),
{:ok, _} <- :gun.await_up(pid),
stream <- :gun.connect(pid, connect_opts),
{:response, :fin, 200, _} <- :gun.await(pid, stream) do
{:ok, pid}
end
end
+ defp do_open_conn(uri, %{proxy: {proxy_type, proxy_host, proxy_port}}, gun_opts, tls_opts) do
+ version =
+ proxy_type
+ |> to_string()
+ |> String.last()
+ |> case do
+ "4" -> 4
+ _ -> 5
+ end
+
+ socks_opts =
+ uri
+ |> tunnel_opts()
+ |> tunnel_tls_opts(uri.scheme, tls_opts)
+ |> Map.put(:version, version)
+
+ gun_opts =
+ gun_opts
+ |> Map.put(:protocols, [:socks])
+ |> Map.update(:socks_opts, socks_opts, &Map.merge(socks_opts, &1))
+
+ with {:ok, pid} <- :gun.open(proxy_host, proxy_port, gun_opts),
+ {:ok, _} <- :gun.await_up(pid) do
+ {:ok, pid}
+ else
+ {:error, {:options, {:protocols, [:socks]}}} ->
+ {:error, "socks protocol is not supported"}
+
+ error ->
+ error
+ end
+ end
+
defp do_open_conn(uri, opts, gun_opts, tls_opts) do
tcp_opts = Map.get(opts, :tcp_opts, [])
# if gun used from master
opts_with_master_keys =
- Map.put(gun_opts, :tls_opts, tls_opts)
+ gun_opts
+ |> Map.put(:tls_opts, tls_opts)
|> Map.put(:tcp_opts, tcp_opts)
- host = to_charlist(uri.host)
+ {_type, host} = domain_or_ip(uri.host)
with {:ok, pid} <- :gun.open(host, uri.port, opts_with_master_keys) do
{:ok, pid}
else
{:error, {:options, {key, _}}} when key in [:tcp_opts, :tls_opts] ->
:gun.open(
host,
uri.port,
Map.put(gun_opts, :transport_opts, tls_opts)
)
+
+ error ->
+ error
end
end
- defp open_stream(pid, method, url, headers, body, :stream) do
- stream = :gun.request(pid, method, url, headers, "")
+ defp tunnel_opts(uri) do
+ {_type, host} = domain_or_ip(uri.host)
+ %{host: host, port: uri.port}
+ end
+
+ defp tunnel_tls_opts(opts, "https", tls_opts) do
+ http2_opts = %{protocols: [:http2], transport: :tls, tls_opts: tls_opts}
+ Map.merge(opts, http2_opts)
+ end
+
+ defp tunnel_tls_opts(opts, _, _), do: opts
+
+ defp open_stream(pid, method, path, headers, body, opts) do
+ req_opts = %{reply_to: opts[:reply_to] || self()}
+
+ open_stream(pid, method, path, headers, body, req_opts, opts[:send_body])
+ end
+
+ defp open_stream(pid, method, path, headers, body, req_opts, :stream) do
+ stream = :gun.request(pid, method, path, headers, "", req_opts)
for data <- body, do: :ok = :gun.data(pid, stream, :nofin, data)
:gun.data(pid, stream, :fin, "")
stream
end
- defp open_stream(pid, method, url, headers, body, :at_once),
- do: :gun.request(pid, method, url, headers, body)
+ defp open_stream(pid, method, path, headers, body, req_opts, :at_once),
+ do: :gun.request(pid, method, path, headers, body, req_opts)
defp read_response(pid, stream, opts) do
receive? = opts[:receive]
receive do
{:gun_response, ^pid, ^stream, :fin, status, headers} ->
if opts[:close_conn], do: close(pid)
{:ok, status, headers, ""}
{:gun_response, ^pid, ^stream, :nofin, status, headers} ->
format_response(pid, stream, opts, status, headers, opts[:body_as])
- {:error, error} ->
- if opts[:close_conn], do: close(pid)
- {:error, error}
-
{:gun_up, ^pid, _protocol} when receive? ->
read_response(pid, stream, opts)
{:gun_error, ^pid, reason} ->
if opts[:close_conn], do: close(pid)
{:error, reason}
{:gun_down, ^pid, _, _, _, _} when receive? ->
read_response(pid, stream, opts)
{:DOWN, _, _, _, reason} ->
if opts[:close_conn], do: close(pid)
{:error, reason}
after
opts[:timeout] || @adapter_default_timeout ->
if opts[:close_conn], do: :ok = close(pid)
- {:error, :timeout}
+ {:error, :recv_response_timeout}
end
end
defp format_response(pid, stream, opts, status, headers, :plain) do
case read_body(pid, stream, opts) do
{:ok, body} ->
if opts[:close_conn], do: close(pid)
{:ok, status, headers, body}
{:error, error} ->
if opts[:close_conn], do: close(pid)
{:error, error}
end
end
defp format_response(pid, stream, opts, status, headers, :stream) do
stream_body =
Stream.resource(
fn -> %{pid: pid, stream: stream} end,
fn
%{pid: pid, stream: stream} ->
case read_chunk(pid, stream, opts) do
{:nofin, part} -> {[part], %{pid: pid, stream: stream}}
{:fin, body} -> {[body], %{pid: pid, final: :fin}}
end
%{pid: pid, final: :fin} ->
{:halt, %{pid: pid}}
end,
fn %{pid: pid} ->
if opts[:close_conn], do: close(pid)
end
)
{:ok, status, headers, stream_body}
end
defp format_response(pid, stream, opts, status, headers, :chunks) do
{:ok, status, headers, %{pid: pid, stream: stream, opts: Enum.into(opts, [])}}
end
- @doc """
- Reads chunk of the response body.
- Returns `{:fin, binary()}` if all body received, otherwise returns `{:nofin, binary()}`.
- """
- @spec read_chunk(pid(), reference(), keyword() | map()) ::
- {:fin, binary()} | {:nofin, binary()} | {:error, :timeout}
- def read_chunk(pid, stream, opts) do
- receive do
- {:gun_data, ^pid, ^stream, :fin, body} ->
- {:fin, body}
-
- {:gun_data, ^pid, ^stream, :nofin, part} ->
- {:nofin, part}
- after
- opts[:timeout] || @adapter_default_timeout ->
- {:error, :timeout}
- end
- end
-
- @doc """
- Brutally close the `gun` connection
- """
- @spec close(pid()) :: :ok
- def close(pid) do
- :gun.close(pid)
- end
-
defp read_body(pid, stream, opts, acc \\ "") do
limit = opts[:max_body]
receive do
{:gun_data, ^pid, ^stream, :fin, body} ->
check_body_size(acc, body, limit)
{:gun_data, ^pid, ^stream, :nofin, part} ->
- case check_body_size(acc, part, limit) do
- {:ok, acc} -> read_body(pid, stream, opts, acc)
- {:error, error} -> {:error, error}
+ with {:ok, acc} <- check_body_size(acc, part, limit) do
+ read_body(pid, stream, opts, acc)
end
+
+ {:DOWN, _, _, _, reason} ->
+ {:error, reason}
after
opts[:timeout] || @adapter_default_timeout ->
- {:error, :timeout}
+ {:error, :recv_body_timeout}
end
end
defp check_body_size(acc, part, nil), do: {:ok, acc <> part}
defp check_body_size(acc, part, limit) do
body = acc <> part
if limit - byte_size(body) >= 0 do
{:ok, body}
else
{:error, :body_too_large}
end
end
+
+ defp domain_or_fallback(host) do
+ case domain_or_ip(host) do
+ {:domain, domain} -> domain
+ {:ip, _ip} -> to_charlist(host)
+ end
+ end
+
+ defp domain_or_ip(host) do
+ charlist = to_charlist(host)
+
+ case :inet.parse_address(charlist) do
+ {:error, :einval} ->
+ {:domain, :idna.encode(charlist)}
+
+ {:ok, ip} when is_tuple(ip) and tuple_size(ip) in [4, 8] ->
+ {:ip, ip}
+ end
+ end
end
end
diff --git a/lib/tesla/adapter/hackney.ex b/lib/tesla/adapter/hackney.ex
index 0e53382..ca2a8ec 100644
--- a/lib/tesla/adapter/hackney.ex
+++ b/lib/tesla/adapter/hackney.ex
@@ -1,112 +1,113 @@
if Code.ensure_loaded?(:hackney) do
defmodule Tesla.Adapter.Hackney do
@moduledoc """
- Adapter for [hackney](https://github.com/benoitc/hackney)
+ Adapter for [hackney](https://github.com/benoitc/hackney).
Remember to add `{:hackney, "~> 1.13"}` to dependencies (and `:hackney` to applications in `mix.exs`)
Also, you need to recompile tesla after adding `:hackney` dependency:
```
mix deps.clean tesla
mix deps.compile tesla
```
- ### Example usage
+ ## Example usage
+
```
# set globally in config/config.exs
config :tesla, :adapter, Tesla.Adapter.Hackney
# set per module
defmodule MyClient do
use Tesla
adapter Tesla.Adapter.Hackney
end
```
"""
@behaviour Tesla.Adapter
alias Tesla.Multipart
- @doc false
+ @impl Tesla.Adapter
def call(env, opts) do
with {:ok, status, headers, body} <- request(env, opts) do
{:ok, %{env | status: status, headers: format_headers(headers), body: format_body(body)}}
end
end
defp format_headers(headers) do
for {key, value} <- headers do
{String.downcase(to_string(key)), to_string(value)}
end
end
defp format_body(data) when is_list(data), do: IO.iodata_to_binary(data)
defp format_body(data) when is_binary(data) or is_reference(data), do: data
defp request(env, opts) do
request(
env.method,
Tesla.build_url(env.url, env.query),
env.headers,
env.body,
Tesla.Adapter.opts(env, opts)
)
end
defp request(method, url, headers, %Stream{} = body, opts),
do: request_stream(method, url, headers, body, opts)
defp request(method, url, headers, body, opts) when is_function(body),
do: request_stream(method, url, headers, body, opts)
defp request(method, url, headers, %Multipart{} = mp, opts) do
headers = headers ++ Multipart.headers(mp)
body = Multipart.body(mp)
request(method, url, headers, body, opts)
end
defp request(method, url, headers, body, opts) do
handle(:hackney.request(method, url, headers, body || '', opts))
end
defp request_stream(method, url, headers, body, opts) do
with {:ok, ref} <- :hackney.request(method, url, headers, :stream, opts) do
for data <- body, do: :ok = :hackney.send_body(ref, data)
handle(:hackney.start_response(ref))
else
e -> handle(e)
end
end
defp handle({:error, _} = error), do: error
defp handle({:ok, status, headers}), do: {:ok, status, headers, []}
defp handle({:ok, ref}) when is_reference(ref) do
handle_async_response({ref, %{status: nil, headers: nil}})
end
defp handle({:ok, status, headers, ref}) when is_reference(ref) do
with {:ok, body} <- :hackney.body(ref) do
{:ok, status, headers, body}
end
end
defp handle({:ok, status, headers, body}), do: {:ok, status, headers, body}
defp handle_async_response({ref, %{headers: headers, status: status}})
when not (is_nil(headers) or is_nil(status)) do
{:ok, status, headers, ref}
end
defp handle_async_response({ref, output}) do
receive do
{:hackney_response, ^ref, {:status, status, _}} ->
handle_async_response({ref, %{output | status: status}})
{:hackney_response, ^ref, {:headers, headers}} ->
handle_async_response({ref, %{output | headers: headers}})
end
end
end
end
diff --git a/lib/tesla/adapter/httpc.ex b/lib/tesla/adapter/httpc.ex
index 083ff9b..5374a02 100644
--- a/lib/tesla/adapter/httpc.ex
+++ b/lib/tesla/adapter/httpc.ex
@@ -1,103 +1,103 @@
defmodule Tesla.Adapter.Httpc do
@moduledoc """
- Adapter for [httpc](http://erlang.org/doc/man/httpc.html)
+ Adapter for [httpc](http://erlang.org/doc/man/httpc.html).
This is the default adapter.
**NOTE** Tesla overrides default autoredirect value with false to ensure
consistency between adapters
"""
@behaviour Tesla.Adapter
import Tesla.Adapter.Shared, only: [stream_to_fun: 1, next_chunk: 1]
alias Tesla.Multipart
@override_defaults autoredirect: false
@http_opts ~w(timeout connect_timeout ssl essl autoredirect proxy_auth version relaxed url_encode)a
- @doc false
+ @impl Tesla.Adapter
def call(env, opts) do
opts = Tesla.Adapter.opts(@override_defaults, env, opts)
with {:ok, {status, headers, body}} <- request(env, opts) do
{:ok, format_response(env, status, headers, body)}
end
end
defp format_response(env, {_, status, _}, headers, body) do
%{env | status: status, headers: format_headers(headers), body: format_body(body)}
end
# from http://erlang.org/doc/man/httpc.html
# headers() = [header()]
# header() = {field(), value()}
# field() = string()
# value() = string()
defp format_headers(headers) do
for {key, value} <- headers do
{String.downcase(to_string(key)), to_string(value)}
end
end
# from http://erlang.org/doc/man/httpc.html
# string() = list of ASCII characters
# Body = string() | binary()
defp format_body(data) when is_list(data), do: IO.iodata_to_binary(data)
defp format_body(data) when is_binary(data), do: data
defp request(env, opts) do
content_type = to_charlist(Tesla.get_header(env, "content-type") || "")
handle(
request(
env.method,
Tesla.build_url(env.url, env.query) |> to_charlist,
Enum.map(env.headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end),
content_type,
env.body,
Keyword.split(opts, @http_opts)
)
)
end
# fix for # see https://github.com/teamon/tesla/issues/147
defp request(:delete, url, headers, content_type, nil, {http_opts, opts}) do
request(:delete, url, headers, content_type, "", {http_opts, opts})
end
defp request(method, url, headers, _content_type, nil, {http_opts, opts}) do
:httpc.request(method, {url, headers}, http_opts, opts)
end
defp request(method, url, headers, _content_type, %Multipart{} = mp, opts) do
headers = headers ++ Multipart.headers(mp)
headers = for {key, value} <- headers, do: {to_charlist(key), to_charlist(value)}
{content_type, headers} =
case List.keytake(headers, 'content-type', 0) do
nil -> {'text/plain', headers}
{{_, ct}, headers} -> {ct, headers}
end
body = stream_to_fun(Multipart.body(mp))
request(method, url, headers, to_charlist(content_type), body, opts)
end
defp request(method, url, headers, content_type, %Stream{} = body, opts) do
fun = stream_to_fun(body)
request(method, url, headers, content_type, fun, opts)
end
defp request(method, url, headers, content_type, body, opts) when is_function(body) do
body = {:chunkify, &next_chunk/1, body}
request(method, url, headers, content_type, body, opts)
end
defp request(method, url, headers, content_type, body, {http_opts, opts}) do
:httpc.request(method, {url, headers, content_type, body}, http_opts, opts)
end
defp handle({:error, {:failed_connect, _}}), do: {:error, :econnrefused}
defp handle(response), do: response
end
diff --git a/lib/tesla/adapter/ibrowse.ex b/lib/tesla/adapter/ibrowse.ex
index b85f9fb..f637fab 100644
--- a/lib/tesla/adapter/ibrowse.ex
+++ b/lib/tesla/adapter/ibrowse.ex
@@ -1,98 +1,99 @@
if Code.ensure_loaded?(:ibrowse) do
defmodule Tesla.Adapter.Ibrowse do
@moduledoc """
- Adapter for [ibrowse](https://github.com/cmullaparthi/ibrowse)
+ Adapter for [ibrowse](https://github.com/cmullaparthi/ibrowse).
Remember to add `{:ibrowse, "~> 4.2"}` to dependencies (and `:ibrowse` to applications in `mix.exs`)
Also, you need to recompile tesla after adding `:ibrowse` dependency:
```
mix deps.clean tesla
mix deps.compile tesla
```
- ### Example usage
+ ## Example usage
+
```
# set globally in config/config.exs
config :tesla, :adapter, Tesla.Adapter.Ibrowse
# set per module
defmodule MyClient do
use Tesla
adapter Tesla.Adapter.Ibrowse
end
```
"""
@behaviour Tesla.Adapter
import Tesla.Adapter.Shared, only: [stream_to_fun: 1, next_chunk: 1]
alias Tesla.Multipart
- @doc false
+ @impl Tesla.Adapter
def call(env, opts) do
with {:ok, status, headers, body} <- request(env, opts) do
{:ok,
%{
env
| status: format_status(status),
headers: format_headers(headers),
body: format_body(body)
}}
end
end
defp format_status(status) when is_list(status) do
status |> to_string() |> String.to_integer()
end
defp format_headers(headers) do
for {key, value} <- headers do
{String.downcase(to_string(key)), to_string(value)}
end
end
defp format_body(data) when is_list(data), do: IO.iodata_to_binary(data)
defp format_body(data) when is_binary(data), do: data
defp request(env, opts) do
body = env.body || []
handle(
request(
Tesla.build_url(env.url, env.query) |> to_charlist,
env.headers,
env.method,
body,
Tesla.Adapter.opts(env, opts)
)
)
end
defp request(url, headers, method, %Multipart{} = mp, opts) do
headers = headers ++ Multipart.headers(mp)
body = stream_to_fun(Multipart.body(mp))
request(url, headers, method, body, opts)
end
defp request(url, headers, method, %Stream{} = body, opts) do
fun = stream_to_fun(body)
request(url, headers, method, fun, opts)
end
defp request(url, headers, method, body, opts) when is_function(body) do
body = {&next_chunk/1, body}
opts = Keyword.put(opts, :transfer_encoding, :chunked)
request(url, headers, method, body, opts)
end
defp request(url, headers, method, body, opts) do
{timeout, opts} = opts |> Keyword.pop(:timeout, 30_000)
:ibrowse.send_req(url, headers, method, body, opts, timeout)
end
defp handle({:error, {:conn_failed, error}}), do: error
defp handle(response), do: response
end
end
diff --git a/lib/tesla/adapter/mint.ex b/lib/tesla/adapter/mint.ex
index 44ce4fb..71893bb 100644
--- a/lib/tesla/adapter/mint.ex
+++ b/lib/tesla/adapter/mint.ex
@@ -1,192 +1,341 @@
if Code.ensure_loaded?(Mint.HTTP) do
defmodule Tesla.Adapter.Mint do
@moduledoc """
- Adapter for [mint](https://github.com/ericmj/mint)
+ Adapter for [mint](https://github.com/elixir-mint/mint).
- Caution: The minimum supported Elixir version for mint is 1.5.0
+ Caution: The minimum supported Elixir version for mint is 1.5.0
- Remember to add `{:mint, "~> 0.2.0"}` and `{:castore, "~> 0.1.0"}` to dependencies
- Also, you need to recompile tesla after adding `:mint` dependency:
+ Remember to add `{:mint, "~> 1.0"}` and `{:castore, "~> 0.1"}` to dependencies
+ Also, you need to recompile tesla after adding `:mint` dependency:
- ```
- mix deps.clean tesla
- mix deps.compile tesla
- ```
+ ```
+ mix deps.clean tesla
+ mix deps.compile tesla
+ ```
- ### Example usage
- ```
- # set globally in config/config.exs
- config :tesla, :adapter, Tesla.Adapter.Mint
+ ## Example usage
- # set per module
- defmodule MyClient do
- use Tesla
-
- adapter Tesla.Adapter.Mint
- end
-
- # set global custom cacert
- config :tesla, Tesla.Adapter.Mint, cacert: ["path_to_cacert"]
+ ```
+ # set globally in config/config.exs
+ config :tesla, :adapter, Tesla.Adapter.Mint
+ # set per module
+ defmodule MyClient do
+ use Tesla
+ adapter Tesla.Adapter.Mint
+ end
+ # set global custom cacertfile
+ config :tesla, Tesla.Adapter.Mint, cacert: ["path_to_cacert"]
+ ```
+
+ ## Adapter specific options:
+
+ - `:timeout` - Time, while process, will wait for mint messages.
+ - `:body_as` - What will be returned in `%Tesla.Env{}` body key. Possible values - `:plain`, `:stream`, `:chunks`. Defaults to `:plain`.
+ - `:plain` - as binary.
+ - `:stream` - as stream. If you don't want to close connection (because you want to reuse it later) pass `close_conn: false` in adapter opts.
+ - `:chunks` - as chunks. You can get response body in chunks using `Tesla.Adapter.Mint.read_chunk/3` function.
+ Processing of the chunks and checking body size must be done by yourself. Example of processing function is in `test/tesla/adapter/mint_test.exs` - `Tesla.Adapter.MintTest.read_body/4`. If you don't need connection later don't forget to close it with `Tesla.Adapter.Mint.close/1`.
+ - `:max_body` - Max response body size in bytes. Works only with `body_as: :plain`, with other settings you need to check response body size by yourself.
+ - `:conn` - Opened connection with mint. Is used for reusing mint connections.
+ - `:original` - Original host with port, for which reused connection was open. Needed for `Tesla.Middleware.FollowRedirects`. Otherwise adapter will use connection for another open host.
+ - `:close_conn` - Close connection or not after receiving full response body. Is used for reusing mint connections. Defaults to `true`.
+ - `:proxy` - Proxy settings. E.g.: `{:http, "localhost", 8888, []}`, `{:http, "127.0.0.1", 8888, []}`
"""
+
@behaviour Tesla.Adapter
- import Tesla.Adapter.Shared, only: [stream_to_fun: 1, next_chunk: 1]
+
+ import Tesla.Adapter.Shared
alias Tesla.Multipart
alias Mint.HTTP
- @default adapter: [timeout: 2_000]
+ @default timeout: 2_000, body_as: :plain, close_conn: true, mode: :active
- @doc false
+ @impl Tesla.Adapter
def call(env, opts) do
opts = Tesla.Adapter.opts(@default, env, opts)
with {:ok, status, headers, body} <- request(env, opts) do
{:ok, %{env | status: status, headers: headers, body: body}}
end
end
- defp request(env, opts) do
- # Break the URI
- %URI{host: host, scheme: scheme, port: port, path: path, query: query} = URI.parse(env.url)
- query = (query || "") |> URI.decode_query() |> Map.to_list()
- path = Tesla.build_url(path, env.query ++ query)
-
- method = env.method |> Atom.to_string() |> String.upcase()
+ @doc """
+ Reads chunk of the response body.
+ Returns `{:fin, HTTP.t(), binary()}` if all body received, otherwise returns `{:nofin, HTTP.t(), binary()}`.
+ """
- # Set the global cacert file
- opts =
- if scheme == "https" && !is_nil(get_global_default_ca()) do
- transport_opts = Access.get(opts, :transport_opts, [])
+ @spec read_chunk(HTTP.t(), reference(), keyword()) ::
+ {:fin, HTTP.t(), binary()} | {:nofin, HTTP.t(), binary()}
+ def read_chunk(conn, ref, opts) do
+ with {:ok, conn, acc} <- receive_packet(conn, ref, opts),
+ {state, data} <- response_state(acc) do
+ {:ok, conn} =
+ if state == :fin and opts[:close_conn] do
+ close(conn)
+ else
+ {:ok, conn}
+ end
- transport_opts =
- Keyword.put(
- transport_opts,
- :cacertfile,
- Keyword.get(transport_opts, :cacertfile, []) ++ get_global_default_ca()
- )
+ {state, conn, data}
+ end
+ end
- Keyword.put(opts, :transport_opts, transport_opts)
- else
- opts
- end
+ @doc """
+ Closes mint connection.
+ """
+ @spec close(HTTP.t()) :: {:ok, HTTP.t()}
+ defdelegate close(conn), to: HTTP
+ defp request(env, opts) do
request(
- method,
- scheme,
- host,
- port,
- path,
+ format_method(env.method),
+ Tesla.build_url(env.url, env.query),
env.headers,
env.body,
- opts
+ Enum.into(opts, %{})
)
end
- defp request(method, scheme, host, port, path, headers, %Stream{} = body, opts) do
+ defp request(method, url, headers, %Stream{} = body, opts) do
fun = stream_to_fun(body)
- request(method, scheme, host, port, path, headers, fun, opts)
+ request(method, url, headers, fun, opts)
end
- defp request(method, scheme, host, port, path, headers, %Multipart{} = body, opts) do
+ defp request(method, url, headers, %Multipart{} = body, opts) do
headers = headers ++ Multipart.headers(body)
fun = stream_to_fun(Multipart.body(body))
- request(method, scheme, host, port, path, headers, fun, opts)
+ request(method, url, headers, fun, opts)
end
- defp request(method, scheme, host, port, path, headers, body, opts) when is_function(body) do
- with {:ok, conn} <- HTTP.connect(String.to_atom(scheme), host, port, opts),
- # FIXME Stream function in Mint will not append the content length after eof
- # This will trigger the failure in unit test
- {:ok, body, length} <- stream_request(body),
- {:ok, conn, _req_ref} <-
- HTTP.request(
- conn,
- method,
- path || "/",
- headers ++ [{"content-length", "#{length}"}],
- body
- ),
- {:ok, conn, res = %{status: status, headers: headers}} <- stream_response(conn, opts),
- {:ok, _conn} <- HTTP.close(conn) do
- {:ok, status, headers, Map.get(res, :data)}
+ defp request(method, url, headers, body, opts),
+ do: do_request(method, url, headers, body, opts)
+
+ defp do_request(method, url, headers, body, opts) do
+ with uri <- URI.parse(url),
+ path <- prepare_path(uri.path, uri.query),
+ opts <- check_original(uri, opts),
+ {:ok, conn, opts} <- open_conn(uri, opts),
+ {:ok, conn, ref} <- make_request(conn, method, path, headers, body) do
+ format_response(conn, ref, opts)
end
end
- defp request(method, scheme, host, port, path, headers, body, opts) do
- with {:ok, conn} <- HTTP.connect(String.to_atom(scheme), host, port, opts),
- {:ok, conn, _req_ref} <- HTTP.request(conn, method, path || "/", headers, body),
- {:ok, conn, res = %{status: status, headers: headers}} <- stream_response(conn, opts),
- {:ok, _conn} <- HTTP.close(conn) do
- {:ok, status, headers, Map.get(res, :data)}
+ defp check_original(uri, %{original: original} = opts) do
+ Map.put(opts, :original_matches, original == "#{uri.host}:#{uri.port}")
+ end
+
+ defp check_original(_uri, opts), do: opts
+
+ defp open_conn(_uri, %{conn: conn, original_matches: true} = opts) do
+ {:ok, conn, opts}
+ end
+
+ defp open_conn(uri, %{conn: conn, original_matches: false} = opts) do
+ opts =
+ opts
+ |> Map.put_new(:old_conn, conn)
+ |> Map.delete(:conn)
+
+ open_conn(uri, opts)
+ end
+
+ defp open_conn(uri, opts) do
+ opts =
+ with "https" <- uri.scheme,
+ global_cacertfile when not is_nil(global_cacertfile) <-
+ Application.get_env(:tesla, Tesla.Adapter.Mint)[:cacert] do
+ Map.update(opts, :transport_opts, [cacertfile: global_cacertfile], fn tr_opts ->
+ Keyword.put_new(tr_opts, :cacertfile, global_cacertfile)
+ end)
+ else
+ _ -> opts
+ end
+
+ with {:ok, conn} <-
+ HTTP.connect(String.to_atom(uri.scheme), uri.host, uri.port, Enum.into(opts, [])) do
+ # If there were redirects, and passed `closed_conn: false`, we need to close opened connections to these intermediate hosts.
+ {:ok, conn, Map.put(opts, :close_conn, true)}
end
end
- defp get_global_default_ca() do
- case Application.get_env(:tesla, Tesla.Adapter.Mint) do
- nil -> nil
- env -> Keyword.get(env, :cacert)
+ defp make_request(conn, method, path, headers, body) when is_function(body) do
+ with {:ok, conn, ref} <-
+ HTTP.request(
+ conn,
+ method,
+ path,
+ headers,
+ :stream
+ ),
+ {:ok, conn} <- stream_request(conn, ref, body) do
+ {:ok, conn, ref}
end
end
- defp stream_request(fun, body \\ "") do
+ defp make_request(conn, method, path, headers, body),
+ do: HTTP.request(conn, method, path, headers, body)
+
+ defp stream_request(conn, ref, fun) do
case next_chunk(fun) do
{:ok, item, fun} when is_list(item) ->
- stream_request(fun, body <> List.to_string(item))
+ chunk = List.to_string(item)
+ {:ok, conn} = HTTP.stream_request_body(conn, ref, chunk)
+ stream_request(conn, ref, fun)
{:ok, item, fun} ->
- stream_request(fun, body <> item)
+ {:ok, conn} = HTTP.stream_request_body(conn, ref, item)
+ stream_request(conn, ref, fun)
:eof ->
- {:ok, body, byte_size(body)}
+ HTTP.stream_request_body(conn, ref, :eof)
end
end
- defp stream_response(conn, opts, response \\ %{}) do
- receive do
- msg ->
- case HTTP.stream(conn, msg) do
- {:ok, conn, stream} ->
- response =
- Enum.reduce(stream, response, fn
- {:status, _req_ref, code}, acc ->
- Map.put(acc, :status, code)
-
- {:headers, _req_ref, headers}, acc ->
- Map.put(acc, :headers, Map.get(acc, :headers, []) ++ headers)
+ defp format_response(conn, ref, %{body_as: :plain} = opts) do
+ with {:ok, response} <- receive_responses(conn, ref, opts) do
+ {:ok, response[:status], response[:headers], response[:data]}
+ end
+ end
- {:data, _req_ref, data}, acc ->
- Map.put(acc, :data, Map.get(acc, :data, "") <> data)
+ defp format_response(conn, ref, %{body_as: :chunks} = opts) do
+ with {:ok, conn, %{status: status, headers: headers} = acc} <-
+ receive_headers_and_status(conn, ref, opts),
+ {state, data} <-
+ response_state(acc) do
+ {:ok, conn} =
+ if state == :fin and opts[:close_conn] do
+ close(conn)
+ else
+ {:ok, conn}
+ end
- {:done, _req_ref}, acc ->
- Map.put(acc, :done, true)
+ {:ok, status, headers, %{conn: conn, ref: ref, opts: opts, body: {state, data}}}
+ end
+ end
- {:error, _req_ref, reason}, acc ->
- Map.put(acc, :error, reason)
+ defp format_response(conn, ref, %{body_as: :stream} = opts) do
+ # there can be some data already
+ with {:ok, conn, %{status: status, headers: headers} = acc} <-
+ receive_headers_and_status(conn, ref, opts) do
+ body_as_stream =
+ Stream.resource(
+ fn -> %{conn: conn, data: acc[:data], done: acc[:done]} end,
+ fn
+ %{conn: conn, data: data, done: true} ->
+ {[data], %{conn: conn, is_fin: true}}
+
+ %{conn: conn, data: data} when is_binary(data) ->
+ {[data], %{conn: conn}}
+
+ %{conn: conn, is_fin: true} ->
+ {:halt, %{conn: conn}}
+
+ %{conn: conn} ->
+ case receive_packet(conn, ref, opts) do
+ {:ok, conn, %{done: true, data: data}} ->
+ {[data], %{conn: conn, is_fin: true}}
+
+ {:ok, conn, %{done: true}} ->
+ {[], %{conn: conn, is_fin: true}}
+
+ {:ok, conn, %{data: data}} ->
+ {[data], %{conn: conn}}
+
+ {:ok, conn, _} ->
+ {[], %{conn: conn}}
+ end
+ end,
+ fn %{conn: conn} -> if opts[:close_conn], do: {:ok, _conn} = close(conn) end
+ )
+
+ {:ok, status, headers, body_as_stream}
+ end
+ end
- _, acc ->
- acc
- end)
+ defp receive_responses(conn, ref, opts, acc \\ %{}) do
+ with {:ok, conn, acc} <- receive_packet(conn, ref, opts, acc),
+ :ok <- check_data_size(acc, conn, opts) do
+ if acc[:done] do
+ if opts[:close_conn], do: {:ok, _conn} = close(conn)
+ {:ok, acc}
+ else
+ receive_responses(conn, ref, opts, acc)
+ end
+ end
+ end
- cond do
- Map.has_key?(response, :error) ->
- {:error, Map.get(response, :error)}
+ defp check_data_size(%{data: data}, conn, %{max_body: max_body} = opts)
+ when is_binary(data) do
+ if max_body - byte_size(data) >= 0 do
+ :ok
+ else
+ if opts[:close_conn], do: {:ok, _conn} = close(conn)
+ {:error, :body_too_large}
+ end
+ end
- Map.has_key?(response, :done) ->
- {:ok, conn, Map.drop(response, [:done])}
+ defp check_data_size(_, _, _), do: :ok
- true ->
- stream_response(conn, opts, response)
- end
+ defp receive_headers_and_status(conn, ref, opts, acc \\ %{}) do
+ with {:ok, conn, acc} <- receive_packet(conn, ref, opts, acc) do
+ case acc do
+ %{status: _status, headers: _headers} -> {:ok, conn, acc}
+ # if we don't have status or headers we try to get them in next packet
+ _ -> receive_headers_and_status(conn, ref, opts, acc)
+ end
+ end
+ end
- {:error, _conn, error, _res} ->
- {:error, "Encounter Mint error #{inspect(error)}"}
+ defp response_state(%{done: true, data: data}), do: {:fin, data}
+ defp response_state(%{data: data}), do: {:nofin, data}
+ defp response_state(%{done: true}), do: {:fin, ""}
+ defp response_state(_), do: {:nofin, ""}
+
+ defp receive_packet(conn, ref, opts, acc \\ %{}) do
+ with {:ok, conn, responses} <- receive_message(conn, opts),
+ acc <- reduce_responses(responses, ref, acc) do
+ {:ok, conn, acc}
+ else
+ {:error, error} ->
+ if opts[:close_conn], do: {:ok, _conn} = close(conn)
+ {:error, error}
+
+ {:error, _conn, error, _res} ->
+ if opts[:close_conn], do: {:ok, _conn} = close(conn)
+ {:error, "Encounter Mint error #{inspect(error)}"}
+
+ :unknown ->
+ if opts[:close_conn], do: {:ok, _conn} = close(conn)
+ {:error, :unknown}
+ end
+ end
- :unknown ->
- {:error, "Encounter unknown error"}
- end
+ defp receive_message(conn, %{mode: :active} = opts) do
+ receive do
+ message ->
+ HTTP.stream(conn, message)
after
- opts |> Keyword.get(:adapter) |> Keyword.get(:timeout) ->
- {:error, "Response timeout"}
+ opts[:timeout] -> {:error, :timeout}
end
end
+
+ defp receive_message(conn, %{mode: :passive} = opts),
+ do: HTTP.recv(conn, 0, opts[:timeout])
+
+ defp reduce_responses(responses, ref, acc) do
+ Enum.reduce(responses, acc, fn
+ {:status, ^ref, code}, acc ->
+ Map.put(acc, :status, code)
+
+ {:headers, ^ref, headers}, acc ->
+ Map.update(acc, :headers, headers, &(&1 ++ headers))
+
+ {:data, ^ref, data}, acc ->
+ Map.update(acc, :data, data, &(&1 <> data))
+
+ {:done, ^ref}, acc ->
+ Map.put(acc, :done, true)
+ end)
+ end
end
end
diff --git a/lib/tesla/adapter/shared.ex b/lib/tesla/adapter/shared.ex
index 379552f..1d75589 100644
--- a/lib/tesla/adapter/shared.ex
+++ b/lib/tesla/adapter/shared.ex
@@ -1,15 +1,24 @@
defmodule Tesla.Adapter.Shared do
@moduledoc false
def stream_to_fun(stream) do
reductor = fn item, _acc -> {:suspend, item} end
{_, _, fun} = Enumerable.reduce(stream, {:suspend, nil}, reductor)
fun
end
def next_chunk(fun), do: parse_chunk(fun.({:cont, nil}))
defp parse_chunk({:suspended, item, fun}), do: {:ok, item, fun}
defp parse_chunk(_), do: :eof
+
+ @spec prepare_path(String.t() | nil, String.t() | nil) :: String.t()
+ def prepare_path(nil, nil), do: "/"
+ def prepare_path(nil, query), do: "/?" <> query
+ def prepare_path(path, nil), do: path
+ def prepare_path(path, query), do: path <> "?" <> query
+
+ @spec format_method(atom()) :: String.t()
+ def format_method(method), do: to_string(method) |> String.upcase()
end
diff --git a/lib/tesla/builder.ex b/lib/tesla/builder.ex
index cfe81c3..c1dc4ff 100644
--- a/lib/tesla/builder.ex
+++ b/lib/tesla/builder.ex
@@ -1,314 +1,311 @@
defmodule Tesla.Builder do
@http_verbs ~w(head get delete trace options post put patch)a
@body ~w(post put patch)a
defmacro __using__(opts \\ []) do
opts = Macro.prewalk(opts, &Macro.expand(&1, __CALLER__))
docs = Keyword.get(opts, :docs, true)
quote do
Module.register_attribute(__MODULE__, :__middleware__, accumulate: true)
Module.register_attribute(__MODULE__, :__adapter__, [])
@type option ::
{:method, Tesla.Env.method()}
| {:url, Tesla.Env.url()}
| {:query, Tesla.Env.query()}
| {:headers, Tesla.Env.headers()}
| {:body, Tesla.Env.body()}
| {:opts, Tesla.Env.opts()}
if unquote(docs) do
@doc """
- Perform a request
-
- Options:
- - `:method` - the request method, one of [:head, :get, :delete, :trace, :options, :post, :put, :patch]
- - `:url` - either full url e.g. "http://example.com/some/path" or just "/some/path" if using `Tesla.Middleware.BaseUrl`
- - `:query` - a keyword list of query params, e.g. `[page: 1, per_page: 100]`
- - `:headers` - a keyworld list of headers, e.g. `[{"content-type", "text/plain"}]`
- - `:body` - depends on used middleware:
+ Perform a request.
+
+ ## Options
+
+ - `:method` - the request method, one of [:head, :get, :delete, :trace, :options, :post, :put, :patch]
+ - `:url` - either full url e.g. "http://example.com/some/path" or just "/some/path" if using `Tesla.Middleware.BaseUrl`
+ - `:query` - a keyword list of query params, e.g. `[page: 1, per_page: 100]`
+ - `:headers` - a keyworld list of headers, e.g. `[{"content-type", "text/plain"}]`
+ - `:body` - depends on used middleware:
- by default it can be a binary
- if using e.g. JSON encoding middleware it can be a nested map
- if adapter supports it it can be a Stream with any of the above
- - `:opts` - custom, per-request middleware or adapter options
+ - `:opts` - custom, per-request middleware or adapter options
- Examples:
+ ## Examples
ExampleApi.request(method: :get, url: "/users/path")
- You can also use shortcut methods like:
-
+ # use shortcut methods
ExampleApi.get("/users/1")
-
- or
-
ExampleApi.post(client, "/users", %{name: "Jon"})
"""
else
@doc false
end
@spec request(Tesla.Env.client(), [option]) :: Tesla.Env.result()
def request(%Tesla.Client{} = client \\ %Tesla.Client{}, options) do
Tesla.execute(__MODULE__, client, options)
end
if unquote(docs) do
@doc """
Perform request and raise in case of error.
This is similar to `request/2` behaviour from Tesla 0.x
See `request/2` for list of available options.
"""
else
@doc false
end
@spec request!(Tesla.Env.client(), [option]) :: Tesla.Env.t() | no_return
def request!(%Tesla.Client{} = client \\ %Tesla.Client{}, options) do
Tesla.execute!(__MODULE__, client, options)
end
unquote(generate_http_verbs(opts))
import Tesla.Builder, only: [plug: 1, plug: 2, adapter: 1, adapter: 2]
@before_compile Tesla.Builder
end
end
@doc """
- Attach middleware to your API client
+ Attach middleware to your API client.
```
defmodule ExampleApi do
use Tesla
# plug middleware module with options
plug Tesla.Middleware.BaseUrl, "http://api.example.com"
# or without options
plug Tesla.Middleware.JSON
# or a custom middleware
plug MyProject.CustomMiddleware
end
"""
defmacro plug(middleware, opts) do
quote do
@__middleware__ {
{unquote(Macro.escape(middleware)), unquote(Macro.escape(opts))},
{:middleware, unquote(Macro.escape(__CALLER__))}
}
end
end
defmacro plug(middleware) do
quote do
@__middleware__ {
unquote(Macro.escape(middleware)),
{:middleware, unquote(Macro.escape(__CALLER__))}
}
end
end
@doc """
- Choose adapter for your API client
+ Choose adapter for your API client.
```
defmodule ExampleApi do
use Tesla
# set adapter as module
adapter Tesla.Adapter.Hackney
# set adapter as anonymous function
adapter fn env ->
...
env
end
end
"""
defmacro adapter(name, opts) do
quote do
@__adapter__ {
{unquote(Macro.escape(name)), unquote(Macro.escape(opts))},
{:adapter, unquote(Macro.escape(__CALLER__))}
}
end
end
defmacro adapter(name) do
quote do
@__adapter__ {
unquote(Macro.escape(name)),
{:adapter, unquote(Macro.escape(__CALLER__))}
}
end
end
defmacro __before_compile__(env) do
adapter =
env.module
|> Module.get_attribute(:__adapter__)
|> compile()
middleware =
env.module
|> Module.get_attribute(:__middleware__)
|> Enum.reverse()
|> compile()
quote location: :keep do
def __middleware__, do: unquote(middleware)
def __adapter__, do: unquote(adapter)
end
end
def client(pre, post, adapter \\ nil)
def client(pre, post, nil) do
%Tesla.Client{pre: runtime(pre), post: runtime(post)}
end
def client(pre, post, adapter) do
%Tesla.Client{pre: runtime(pre), post: runtime(post), adapter: runtime(adapter)}
end
@default_opts []
defp compile(nil), do: nil
defp compile(list) when is_list(list), do: Enum.map(list, &compile/1)
# {Tesla.Middleware.Something, opts}
defp compile({{{:__aliases__, _, _} = ast_mod, ast_opts}, {_kind, _caller}}) do
quote do: {unquote(ast_mod), :call, [unquote(ast_opts)]}
end
# Tesla.Middleware.Something
defp compile({{:__aliases__, _, _} = ast_mod, {_kind, _caller}}) do
quote do: {unquote(ast_mod), :call, [unquote(@default_opts)]}
end
# fn env -> ... end
defp compile({{:fn, _, _} = ast_fun, {_kind, _caller}}) do
quote do: {:fn, unquote(ast_fun)}
end
defp runtime(list) when is_list(list), do: Enum.map(list, &runtime/1)
defp runtime({module, opts}) when is_atom(module), do: {module, :call, [opts]}
defp runtime(fun) when is_function(fun), do: {:fn, fun}
defp runtime(module) when is_atom(module), do: {module, :call, [@default_opts]}
defp generate_http_verbs(opts) do
only = Keyword.get(opts, :only, @http_verbs)
except = Keyword.get(opts, :except, [])
docs = Keyword.get(opts, :docs, true)
for method <- @http_verbs do
for bang <- [:safe, :bang],
client <- [:client, :noclient],
opts <- [:opts, :noopts],
method in only && not (method in except) do
gen(method, bang, client, opts, docs)
end
end
end
defp gen(method, safe, client, opts, docs) do
quote location: :keep do
unquote(gen_doc(method, safe, client, opts, docs))
unquote(gen_spec(method, safe, client, opts))
unquote(gen_fun(method, safe, client, opts))
end
end
defp gen_doc(method, safe, :client, :opts, true) do
request = to_string(req(safe))
name = name(method, safe)
body = if method in @body, do: ~s|, %{name: "Jon"}|, else: ""
quote location: :keep do
@doc """
Perform a #{unquote(method |> to_string |> String.upcase())} request.
See `#{unquote(request)}/1` or `#{unquote(request)}/2` for options definition.
#{unquote(name)}("/users"#{unquote(body)})
#{unquote(name)}("/users"#{unquote(body)}, query: [scope: "admin"])
#{unquote(name)}(client, "/users"#{unquote(body)})
#{unquote(name)}(client, "/users"#{unquote(body)}, query: [scope: "admin"])
"""
end
end
defp gen_doc(_method, _bang, _client, _opts, _) do
quote location: :keep do
@doc false
end
end
defp gen_spec(method, safe, client, opts) do
quote location: :keep do
@spec unquote(name(method, safe))(unquote_splicing(types(method, client, opts))) ::
unquote(type(safe))
end
end
defp gen_fun(method, safe, client, opts) do
quote location: :keep do
def unquote(name(method, safe))(unquote_splicing(inputs(method, client, opts))) do
unquote(req(safe))(unquote_splicing(outputs(method, client, opts)))
end
end
|> gen_guards(opts)
end
defp gen_guards({:def, _, [head, [do: body]]}, :opts) do
quote do
def unquote(head) when is_list(opts), do: unquote(body)
end
end
defp gen_guards(def, _opts), do: def
defp name(method, :safe), do: method
defp name(method, :bang), do: String.to_atom("#{method}!")
defp req(:safe), do: :request
defp req(:bang), do: :request!
defp types(method, client, opts), do: type(client) ++ type(:url) ++ type(method) ++ type(opts)
defp type(:safe), do: quote(do: Tesla.Env.result())
defp type(:bang), do: quote(do: Tesla.Env.t() | no_return)
defp type(:client), do: [quote(do: Tesla.Env.client())]
defp type(:noclient), do: []
defp type(:opts), do: [quote(do: [option])]
defp type(:noopts), do: []
defp type(:url), do: [quote(do: Tesla.Env.url())]
defp type(method) when method in @body, do: [quote(do: Tesla.Env.body())]
defp type(_method), do: []
defp inputs(method, client, opts),
do: input(client) ++ input(:url) ++ input(method) ++ input(opts)
defp input(:client), do: [quote(do: %Tesla.Client{} = client)]
defp input(:noclient), do: []
defp input(:opts), do: [quote(do: opts)]
defp input(:noopts), do: []
defp input(:url), do: [quote(do: url)]
defp input(method) when method in @body, do: [quote(do: body)]
defp input(_method), do: []
defp outputs(method, client, opts), do: output(client) ++ [output(output(method), opts)]
defp output(:client), do: [quote(do: client)]
defp output(:noclient), do: []
defp output(m) when m in @body, do: quote(do: [method: unquote(m), url: url, body: body])
defp output(m), do: quote(do: [method: unquote(m), url: url])
defp output(prev, :opts), do: quote(do: unquote(prev) ++ opts)
defp output(prev, :noopts), do: prev
end
diff --git a/lib/tesla/middleware/basic_auth.ex b/lib/tesla/middleware/basic_auth.ex
index 8e6c92c..8623069 100644
--- a/lib/tesla/middleware/basic_auth.ex
+++ b/lib/tesla/middleware/basic_auth.ex
@@ -1,61 +1,63 @@
defmodule Tesla.Middleware.BasicAuth do
- @behaviour Tesla.Middleware
-
@moduledoc """
- Basic authentication middleware
+ Basic authentication middleware.
[Wiki on the topic](https://en.wikipedia.org/wiki/Basic_access_authentication)
- ### Example
+ ## Example
+
```
defmodule MyClient do
use Tesla
# static configuration
plug Tesla.Middleware.BasicAuth, username: "user", password: "pass"
# dynamic user & pass
def new(username, password, opts \\\\ %{}) do
Tesla.client [
{Tesla.Middleware.BasicAuth, Map.merge(%{username: username, password: password}, opts)}
]
end
end
```
- ### Options
- - `:username` - username (defaults to `""`)
- - `:password` - password (defaults to `""`)
+ ## Options
+
+ - `:username` - username (defaults to `""`)
+ - `:password` - password (defaults to `""`)
"""
- @doc false
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, opts) do
opts = opts || %{}
env
|> Tesla.put_headers(authorization_header(opts))
|> Tesla.run(next)
end
defp authorization_header(opts) do
opts
|> authorization_vars()
|> encode()
|> create_header()
end
defp authorization_vars(opts) do
%{
username: opts[:username] || "",
password: opts[:password] || ""
}
end
defp create_header(auth) do
[{"authorization", "Basic #{auth}"}]
end
defp encode(%{username: username, password: password}) do
Base.encode64("#{username}:#{password}")
end
end
diff --git a/lib/tesla/middleware/compression.ex b/lib/tesla/middleware/compression.ex
index 9cdc4e7..b9bb204 100644
--- a/lib/tesla/middleware/compression.ex
+++ b/lib/tesla/middleware/compression.ex
@@ -1,98 +1,104 @@
defmodule Tesla.Middleware.Compression do
- @behaviour Tesla.Middleware
-
@moduledoc """
Compress requests and decompress responses.
Supports "gzip" and "deflate" encodings using erlang's built-in `:zlib` module.
- ### Example usage
+ ## Example usage
+
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.Compression, format: "gzip"
end
```
- ### Options
+ ## Options
+
- `:format` - request compression format, `"gzip"` (default) or `"deflate"`
"""
- @doc false
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, opts) do
env
|> compress(opts)
|> Tesla.run(next)
|> decompress()
end
defp compressable?(body), do: is_binary(body)
@doc """
- Compress request, used by `Tesla.Middleware.CompressRequest`
+ Compress request.
+
+ It is used by `Tesla.Middleware.CompressRequest`.
"""
def compress(env, opts) do
if compressable?(env.body) do
format = Keyword.get(opts || [], :format, "gzip")
env
|> Tesla.put_body(compress_body(env.body, format))
|> Tesla.put_headers([{"content-encoding", format}])
else
env
end
end
defp compress_body(body, "gzip"), do: :zlib.gzip(body)
defp compress_body(body, "deflate"), do: :zlib.zip(body)
@doc """
- Decompress response, used by `Tesla.Middleware.DecompressResponse`
+ Decompress response.
+
+ It is used by `Tesla.Middleware.DecompressResponse`.
"""
def decompress({:ok, env}), do: {:ok, decompress(env)}
def decompress({:error, reasonn}), do: {:error, reasonn}
def decompress(env) do
env
|> Tesla.put_body(decompress_body(env.body, Tesla.get_header(env, "content-encoding")))
end
defp decompress_body(<<31, 139, 8, _::binary>> = body, "gzip"), do: :zlib.gunzip(body)
defp decompress_body(body, "deflate"), do: :zlib.unzip(body)
defp decompress_body(body, _content_encoding), do: body
end
defmodule Tesla.Middleware.CompressRequest do
- @behaviour Tesla.Middleware
-
@moduledoc """
Only compress request.
See `Tesla.Middleware.Compression` for options.
"""
- @doc false
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, opts) do
env
|> Tesla.Middleware.Compression.compress(opts)
|> Tesla.run(next)
end
end
defmodule Tesla.Middleware.DecompressResponse do
- @behaviour Tesla.Middleware
-
@moduledoc """
Only decompress response.
See `Tesla.Middleware.Compression` for options.
"""
- @doc false
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, _opts) do
env
|> Tesla.run(next)
|> Tesla.Middleware.Compression.decompress()
end
end
diff --git a/lib/tesla/middleware/core.ex b/lib/tesla/middleware/core.ex
index 3ae8519..bae9069 100644
--- a/lib/tesla/middleware/core.ex
+++ b/lib/tesla/middleware/core.ex
@@ -1,124 +1,131 @@
defmodule Tesla.Middleware.BaseUrl do
- @behaviour Tesla.Middleware
-
@moduledoc """
Set base URL for all requests.
The base URL will be prepended to request path/url only
if it does not include http(s).
- ### Example usage
+ ## Example usage
+
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.BaseUrl, "https://example.com/foo"
end
MyClient.get("/path") # equals to GET https://example.com/foo/path
MyClient.get("path") # equals to GET https://example.com/foo/path
MyClient.get("") # equals to GET https://example.com/foo
MyClient.get("http://example.com/bar") # equals to GET http://example.com/bar
```
"""
- @doc false
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, base) do
env
|> apply_base(base)
|> Tesla.run(next)
end
defp apply_base(env, base) do
if Regex.match?(~r/^https?:\/\//i, env.url) do
# skip if url is already with scheme
env
else
%{env | url: join(base, env.url)}
end
end
defp join(base, url) do
case {String.last(to_string(base)), url} do
{nil, url} -> url
{"/", "/" <> rest} -> base <> rest
{"/", rest} -> base <> rest
{_, ""} -> base
{_, "/" <> rest} -> base <> "/" <> rest
{_, rest} -> base <> "/" <> rest
end
end
end
defmodule Tesla.Middleware.Headers do
- @behaviour Tesla.Middleware
-
@moduledoc """
Set default headers for all requests
- ### Example usage
+ ## Example usage
+
```
defmodule Myclient do
use Tesla
plug Tesla.Middleware.Headers, [{"user-agent", "Tesla"}]
end
```
"""
- @doc false
+
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, headers) do
env
|> Tesla.put_headers(headers)
|> Tesla.run(next)
end
end
defmodule Tesla.Middleware.Query do
- @behaviour Tesla.Middleware
-
@moduledoc """
Set default query params for all requests
- ### Example usage
+ ## Example usage
+
```
defmodule Myclient do
use Tesla
plug Tesla.Middleware.Query, [token: "some-token"]
end
```
"""
- @doc false
+
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, query) do
env
|> merge(query)
|> Tesla.run(next)
end
defp merge(env, nil), do: env
defp merge(env, query) do
Map.update!(env, :query, &(&1 ++ query))
end
end
defmodule Tesla.Middleware.Opts do
- @behaviour Tesla.Middleware
-
@moduledoc """
- Set default opts for all requests
+ Set default opts for all requests.
+
+ ## Example usage
- ### Example usage
```
defmodule Myclient do
use Tesla
plug Tesla.Middleware.Opts, [some: "option"]
end
```
"""
- @doc false
+
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, opts) do
Tesla.run(%{env | opts: env.opts ++ opts}, next)
end
end
diff --git a/lib/tesla/middleware/decode_rels.ex b/lib/tesla/middleware/decode_rels.ex
index 9490df0..8c7c43c 100644
--- a/lib/tesla/middleware/decode_rels.ex
+++ b/lib/tesla/middleware/decode_rels.ex
@@ -1,53 +1,54 @@
defmodule Tesla.Middleware.DecodeRels do
- @behaviour Tesla.Middleware
-
@moduledoc """
Decode `Link` Hypermedia HTTP header into `opts[:rels]` field in response.
+ ## Example usage
- ### Example usage
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.DecodeRels
end
env = MyClient.get("/...")
- env.opts[:rels] # => %{"Next" => "http://...", "Prev" => "..."}
- ```
+ env.opts[:rels]
+ # => %{"Next" => "http://...", "Prev" => "..."}
+ ```
"""
- @doc false
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, _opts) do
env
|> Tesla.run(next)
|> parse_rels
end
defp parse_rels({:ok, env}), do: {:ok, parse_rels(env)}
defp parse_rels({:error, reason}), do: {:error, reason}
defp parse_rels(env) do
if link = Tesla.get_header(env, "link") do
Tesla.put_opt(env, :rels, rels(link))
else
env
end
end
defp rels(link) do
link
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(&rel/1)
|> Enum.into(%{})
end
defp rel(item) do
Regex.run(~r/\A<(.+)>; rel="(.+)"\z/, item, capture: :all_but_first)
|> Enum.reverse()
|> List.to_tuple()
end
end
diff --git a/lib/tesla/middleware/digest_auth.ex b/lib/tesla/middleware/digest_auth.ex
index 63085a0..241819a 100644
--- a/lib/tesla/middleware/digest_auth.ex
+++ b/lib/tesla/middleware/digest_auth.ex
@@ -1,132 +1,133 @@
defmodule Tesla.Middleware.DigestAuth do
- @behaviour Tesla.Middleware
-
@moduledoc """
Digest access authentication middleware
[Wiki on the topic](https://en.wikipedia.org/wiki/Digest_access_authentication)
**NOTE**: Currently the implementation is incomplete and works only for MD5 algorithm
and auth qop.
- ### Example
+ ## Example
+
```
defmodule MyClient do
use Tesla
def client(username, password, opts \\ %{}) do
- Tesla.client [
+ Tesla.client([
{Tesla.Middleware.DigestAuth, Map.merge(%{username: username, password: password}, opts)}
- ]
+ ])
end
end
```
- ### Options
- - `:username` - username (defaults to `""`)
- - `:password` - password (defaults to `""`)
+ ## Options
+ - `:username` - username (defaults to `""`)
+ - `:password` - password (defaults to `""`)
- `:cnonce_fn` - custom function generating client nonce (defaults to `&Tesla.Middleware.DigestAuth.cnonce/0`)
- - `:nc` - nonce counter (defaults to `"00000000"`)
+ - `:nc` - nonce counter (defaults to `"00000000"`)
"""
- @doc false
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, opts) do
if env.opts && Keyword.get(env.opts, :digest_auth_handshake) do
Tesla.run(env, next)
else
opts = opts || %{}
with {:ok, headers} <- authorization_header(env, opts) do
env
|> Tesla.put_headers(headers)
|> Tesla.run(next)
end
end
end
defp authorization_header(env, opts) do
with {:ok, vars} <- authorization_vars(env, opts) do
{:ok,
vars
|> calculated_authorization_values
|> create_header}
end
end
defp authorization_vars(env, opts) do
with {:ok, unauthorized_response} <-
env.__module__.get(
env.__client__,
env.url,
opts: Keyword.put(env.opts || [], :digest_auth_handshake, true)
) do
{:ok,
%{
username: opts[:username] || "",
password: opts[:password] || "",
path: URI.parse(env.url).path,
auth:
Tesla.get_header(unauthorized_response, "www-authenticate")
|> parse_www_authenticate_header,
method: env.method |> to_string |> String.upcase(),
client_nonce: (opts[:cnonce_fn] || (&cnonce/0)).(),
nc: opts[:nc] || "00000000"
}}
end
end
defp calculated_authorization_values(%{auth: auth}) when auth == %{}, do: []
defp calculated_authorization_values(auth_vars) do
[
{"username", auth_vars.username},
{"realm", auth_vars.auth["realm"]},
{"uri", auth_vars[:path]},
{"nonce", auth_vars.auth["nonce"]},
{"nc", auth_vars.nc},
{"cnonce", auth_vars.client_nonce},
{"response", response(auth_vars)},
# hard-coded, will not work for MD5-sess
{"algorithm", "MD5"},
# hard-coded, will not work for auth-int or unspecified
{"qop", "auth"}
]
end
defp single_header_val({k, v}) when k in ~w(nc qop algorithm), do: "#{k}=#{v}"
defp single_header_val({k, v}), do: "#{k}=\"#{v}\""
defp create_header([]), do: []
defp create_header(calculated_authorization_values) do
vals =
calculated_authorization_values
|> Enum.reduce([], fn val, acc -> [single_header_val(val) | acc] end)
|> Enum.join(", ")
[{"authorization", "Digest #{vals}"}]
end
defp ha1(%{username: username, auth: %{"realm" => realm}, password: password}) do
md5("#{username}:#{realm}:#{password}")
end
defp ha2(%{method: method, path: path}) do
md5("#{method}:#{path}")
end
defp response(%{auth: %{"nonce" => nonce}, nc: nc, client_nonce: client_nonce} = auth_vars) do
md5("#{ha1(auth_vars)}:#{nonce}:#{nc}:#{client_nonce}:auth:#{ha2(auth_vars)}")
end
defp parse_www_authenticate_header(nil), do: %{}
defp parse_www_authenticate_header(header) do
Regex.scan(~r/(\w+?)="(.+?)"/, header)
|> Enum.reduce(%{}, fn [_, key, val], acc -> Map.merge(acc, %{key => val}) end)
end
defp md5(data), do: Base.encode16(:erlang.md5(data), case: :lower)
defp cnonce, do: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)
end
diff --git a/lib/tesla/middleware/follow_redirects.ex b/lib/tesla/middleware/follow_redirects.ex
index da3b87e..13e5562 100644
--- a/lib/tesla/middleware/follow_redirects.ex
+++ b/lib/tesla/middleware/follow_redirects.ex
@@ -1,80 +1,88 @@
defmodule Tesla.Middleware.FollowRedirects do
- @behaviour Tesla.Middleware
-
@moduledoc """
Follow 3xx redirects
- ### Example
+ ## Example
+
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.FollowRedirects, max_redirects: 3 # defaults to 5
end
```
- ### Options
- - `:max_redirects` - limit number of redirects (default: `5`)
+ ## Options
+ - `:max_redirects` - limit number of redirects (default: `5`)
"""
+ @behaviour Tesla.Middleware
+
@max_redirects 5
@redirect_statuses [301, 302, 303, 307, 308]
- @doc false
+ @impl Tesla.Middleware
def call(env, next, opts \\ []) do
max = Keyword.get(opts || [], :max_redirects, @max_redirects)
redirect(env, next, max)
end
defp redirect(env, next, left) when left == 0 do
case Tesla.run(env, next) do
{:ok, %{status: status} = env} when not (status in @redirect_statuses) ->
{:ok, env}
{:ok, _env} ->
{:error, {__MODULE__, :too_many_redirects}}
error ->
error
end
end
defp redirect(env, next, left) do
case Tesla.run(env, next) do
{:ok, %{status: status} = res} when status in @redirect_statuses ->
case Tesla.get_header(res, "location") do
nil ->
{:ok, res}
location ->
location = parse_location(location, res)
- %{env | status: res.status}
- |> new_request(location)
+ env
+ |> new_request(res.status, location)
|> redirect(next, left - 1)
end
other ->
other
end
end
# The 303 (See Other) redirect was added in HTTP/1.1 to indicate that the originally
# requested resource is not available, however a related resource (or another redirect)
# available via GET is available at the specified location.
# https://tools.ietf.org/html/rfc7231#section-6.4.4
- defp new_request(%{status: 303} = env, location), do: %{env | url: location, method: :get}
- defp new_request(env, location), do: %{env | url: location}
+ defp new_request(env, 303, location), do: %{env | url: location, method: :get, query: []}
+
+ # The 307 (Temporary Redirect) status code indicates that the target
+ # resource resides temporarily under a different URI and the user agent
+ # MUST NOT change the request method (...)
+ # https://tools.ietf.org/html/rfc7231#section-6.4.7
+ defp new_request(env, 307, location), do: %{env | url: location}
+
+ defp new_request(env, _, location), do: %{env | url: location, query: []}
defp parse_location("https://" <> _rest = location, _env), do: location
defp parse_location("http://" <> _rest = location, _env), do: location
defp parse_location(location, env) do
env.url
|> URI.parse()
|> URI.merge(location)
|> URI.to_string()
end
end
diff --git a/lib/tesla/middleware/form_urlencoded.ex b/lib/tesla/middleware/form_urlencoded.ex
index da1419f..5bbf346 100644
--- a/lib/tesla/middleware/form_urlencoded.ex
+++ b/lib/tesla/middleware/form_urlencoded.ex
@@ -1,109 +1,114 @@
defmodule Tesla.Middleware.FormUrlencoded do
- @behaviour Tesla.Middleware
-
@moduledoc """
Send request body as `application/x-www-form-urlencoded`.
+
Performs encoding of `body` from a `Map` such as `%{"foo" => "bar"}` into
url encoded data.
+
Performs decoding of the response into a map when urlencoded and content-type
is `application/x-www-form-urlencoded`, so `"foo=bar"` becomes
`%{"foo" => "bar"}`.
+ ## Example usage
- ### Example usage
```
defmodule Myclient do
use Tesla
plug Tesla.Middleware.FormUrlencoded
end
Myclient.post("/url", %{key: :value})
```
- ### Options
+ ## Options
+
- `:decode` - decoding function, defaults to `URI.decode_query/1`
- `:encode` - encoding function, defaults to `URI.encode_query/1`
- ### Nested Maps
+ ## Nested Maps
+
Natively, nested maps are not supported in the body, so
`%{"foo" => %{"bar" => "baz"}}` won't be encoded and raise an error.
Support for this specific case is obtained by configuring the middleware to
encode (and decode) with `Plug.Conn.Query`
```
defmodule Myclient do
use Tesla
plug Tesla.Middleware.FormUrlencoded,
encode: &Plug.Conn.Query.encode/1,
decode: &Plug.Conn.Query.decode/1
end
Myclient.post("/url", %{key: %{nested: "value"}})
+ ```
"""
+ @behaviour Tesla.Middleware
+
@content_type "application/x-www-form-urlencoded"
- @doc false
+ @impl Tesla.Middleware
def call(env, next, opts) do
env
|> encode(opts)
|> Tesla.run(next)
|> case do
{:ok, env} -> {:ok, decode(env, opts)}
error -> error
end
end
defp encode(env, opts) do
if encodable?(env) do
env
|> Map.update!(:body, &encode_body(&1, opts))
|> Tesla.put_headers([{"content-type", @content_type}])
else
env
end
end
defp encodable?(%{body: nil}), do: false
defp encodable?(%{body: %Tesla.Multipart{}}), do: false
defp encodable?(_), do: true
defp encode_body(body, _opts) when is_binary(body), do: body
defp encode_body(body, opts), do: do_encode(body, opts)
defp decode(env, opts) do
if decodable?(env) do
env
|> Map.update!(:body, &decode_body(&1, opts))
else
env
end
end
defp decodable?(env), do: decodable_body?(env) && decodable_content_type?(env)
defp decodable_body?(env) do
(is_binary(env.body) && env.body != "") || (is_list(env.body) && env.body != [])
end
defp decodable_content_type?(env) do
case Tesla.get_header(env, "content-type") do
nil -> false
content_type -> String.starts_with?(content_type, @content_type)
end
end
defp decode_body(body, opts), do: do_decode(body, opts)
defp do_encode(data, opts) do
encoder = Keyword.get(opts, :encode, &URI.encode_query/1)
encoder.(data)
end
defp do_decode(data, opts) do
decoder = Keyword.get(opts, :decode, &URI.decode_query/1)
decoder.(data)
end
end
diff --git a/lib/tesla/middleware/fuse.ex b/lib/tesla/middleware/fuse.ex
index 42ba0ca..83a17d9 100644
--- a/lib/tesla/middleware/fuse.ex
+++ b/lib/tesla/middleware/fuse.ex
@@ -1,75 +1,77 @@
if Code.ensure_loaded?(:fuse) do
defmodule Tesla.Middleware.Fuse do
- @behaviour Tesla.Middleware
-
@moduledoc """
Circuit Breaker middleware using [fuse](https://github.com/jlouis/fuse)
Remember to add `{:fuse, "~> 2.4"}` to dependencies (and `:fuse` to applications in `mix.exs`)
Also, you need to recompile tesla after adding `:fuse` dependency:
```
mix deps.clean tesla
mix deps.compile tesla
```
- ### Example usage
+ ## Example usage
+
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.Fuse, opts: {{:standard, 2, 10_000}, {:reset, 60_000}}
end
```
- ### Options
+ ## Options
+
- `:name` - fuse name (defaults to module name)
- `:opts` - fuse options (see fuse docs for reference)
- ### SASL logger
+ ## SASL logger
fuse library uses [SASL (System Architecture Support Libraries)](http://erlang.org/doc/man/sasl_app.html).
You can disable its logger output using:
```
config :sasl, sasl_error_logger: :false
```
Read more at [jlouis/fuse#32](https://github.com/jlouis/fuse/issues/32) and [jlouis/fuse#19](https://github.com/jlouis/fuse/issues/19).
"""
+ @behaviour Tesla.Middleware
+
# options borrowed from http://blog.rokkincat.com/circuit-breakers-in-elixir/
# most probably not valid for your use case
@defaults {{:standard, 2, 10_000}, {:reset, 60_000}}
- @doc false
+ @impl Tesla.Middleware
def call(env, next, opts) do
opts = opts || []
name = Keyword.get(opts, :name, env.__module__)
case :fuse.ask(name, :sync) do
:ok ->
run(env, next, name)
:blown ->
{:error, :unavailable}
{:error, :not_found} ->
:fuse.install(name, Keyword.get(opts, :opts, @defaults))
run(env, next, name)
end
end
defp run(env, next, name) do
case Tesla.run(env, next) do
{:ok, env} ->
{:ok, env}
{:error, _reason} ->
:fuse.melt(name)
{:error, :unavailable}
end
end
end
end
diff --git a/lib/tesla/middleware/json.ex b/lib/tesla/middleware/json.ex
index 92777f1..fa7b109 100644
--- a/lib/tesla/middleware/json.ex
+++ b/lib/tesla/middleware/json.ex
@@ -1,168 +1,173 @@
defmodule Tesla.Middleware.JSON do
- @behaviour Tesla.Middleware
-
@moduledoc """
Encode requests and decode responses as JSON.
This middleware requires [jason](https://hex.pm/packages/jason) (or other engine) as dependency.
Remember to add `{:jason, ">= 1.0"}` to dependencies
Also, you need to recompile tesla after adding `:jason` dependency:
```
mix deps.clean tesla
mix deps.compile tesla
```
+ ## Example usage
- ### Example usage
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.JSON # use jason engine
# or
plug Tesla.Middleware.JSON, engine: JSX, engine_opts: [strict: [:comments]]
# or
plug Tesla.Middleware.JSON, engine: Poison, engine_opts: [keys: :atoms]
# or
plug Tesla.Middleware.JSON, decode: &JSX.decode/1, encode: &JSX.encode/1
end
```
- ### Options
+ ## Options
+
- `:decode` - decoding function
- `:encode` - encoding function
- `:encode_content_type` - content-type to be used in request header
- `:engine` - encode/decode engine, e.g `Jason`, `Poison` or `JSX` (defaults to Jason)
- `:engine_opts` - optional engine options
- `:decode_content_types` - list of additional decodable content-types
"""
+ @behaviour Tesla.Middleware
+
# NOTE: text/javascript added to support Facebook Graph API.
# see https://github.com/teamon/tesla/pull/13
@default_content_types ["application/json", "text/javascript"]
@default_encode_content_type "application/json"
@default_engine Jason
- @doc false
+ @impl Tesla.Middleware
def call(env, next, opts) do
opts = opts || []
with {:ok, env} <- encode(env, opts),
{:ok, env} <- Tesla.run(env, next) do
decode(env, opts)
end
end
@doc """
- Encode request body as JSON. Used by `Tesla.Middleware.EncodeJson`
+ Encode request body as JSON.
+
+ It is used by `Tesla.Middleware.EncodeJson`.
"""
def encode(env, opts) do
with true <- encodable?(env),
{:ok, body} <- encode_body(env.body, opts) do
{:ok,
env
|> Tesla.put_body(body)
|> Tesla.put_headers([{"content-type", encode_content_type(opts)}])}
else
false -> {:ok, env}
error -> error
end
end
defp encode_body(%Stream{} = body, opts), do: {:ok, encode_stream(body, opts)}
defp encode_body(body, opts) when is_function(body), do: {:ok, encode_stream(body, opts)}
defp encode_body(body, opts), do: process(body, :encode, opts)
defp encode_content_type(opts),
do: Keyword.get(opts, :encode_content_type, @default_encode_content_type)
defp encode_stream(body, opts) do
Stream.map(body, fn item ->
{:ok, body} = encode_body(item, opts)
body <> "\n"
end)
end
defp encodable?(%{body: nil}), do: false
defp encodable?(%{body: body}) when is_binary(body), do: false
defp encodable?(%{body: %Tesla.Multipart{}}), do: false
defp encodable?(_), do: true
@doc """
- Decode response body as JSON. Used by `Tesla.Middleware.DecodeJson`
+ Decode response body as JSON.
+
+ It is used by `Tesla.Middleware.DecodeJson`.
"""
def decode(env, opts) do
with true <- decodable?(env, opts),
{:ok, body} <- decode_body(env.body, opts) do
{:ok, %{env | body: body}}
else
false -> {:ok, env}
error -> error
end
end
defp decode_body(body, opts), do: process(body, :decode, opts)
defp decodable?(env, opts), do: decodable_body?(env) && decodable_content_type?(env, opts)
defp decodable_body?(env) do
(is_binary(env.body) && env.body != "") || (is_list(env.body) && env.body != [])
end
defp decodable_content_type?(env, opts) do
case Tesla.get_header(env, "content-type") do
nil -> false
content_type -> Enum.any?(content_types(opts), &String.starts_with?(content_type, &1))
end
end
defp content_types(opts),
do: @default_content_types ++ Keyword.get(opts, :decode_content_types, [])
defp process(data, op, opts) do
case do_process(data, op, opts) do
{:ok, data} -> {:ok, data}
{:error, reason} -> {:error, {__MODULE__, op, reason}}
{:error, reason, _pos} -> {:error, {__MODULE__, op, reason}}
end
rescue
ex in Protocol.UndefinedError ->
{:error, {__MODULE__, op, ex}}
end
defp do_process(data, op, opts) do
# :encode/:decode
if fun = opts[op] do
fun.(data)
else
engine = Keyword.get(opts, :engine, @default_engine)
opts = Keyword.get(opts, :engine_opts, [])
apply(engine, op, [data, opts])
end
end
end
defmodule Tesla.Middleware.DecodeJson do
@moduledoc false
def call(env, next, opts) do
opts = opts || []
with {:ok, env} <- Tesla.run(env, next) do
Tesla.Middleware.JSON.decode(env, opts)
end
end
end
defmodule Tesla.Middleware.EncodeJson do
@moduledoc false
def call(env, next, opts) do
opts = opts || []
with {:ok, env} <- Tesla.Middleware.JSON.encode(env, opts) do
Tesla.run(env, next)
end
end
end
diff --git a/lib/tesla/middleware/keep_request.ex b/lib/tesla/middleware/keep_request.ex
index 828531f..60b9b77 100644
--- a/lib/tesla/middleware/keep_request.ex
+++ b/lib/tesla/middleware/keep_request.ex
@@ -1,28 +1,36 @@
defmodule Tesla.Middleware.KeepRequest do
- @behaviour Tesla.Middleware
-
@moduledoc """
Store request body & headers into opts.
- ### Example
+ ## Example
+
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.KeepRequest
end
{:ok, env} = MyClient.post("/", "request-data")
- env.body # => "response-data"
- env.opts[:req_body] # => "request-data"
- env.opts[:req_headers] # => [{"request-headers", "are-safe"}, ...]
+
+ env.body
+ # => "response-data"
+
+ env.opts[:req_body]
+ # => "request-data"
+
+ env.opts[:req_headers]
+ # => [{"request-headers", "are-safe"}, ...]
```
"""
- @doc false
+
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, _opts) do
env
|> Tesla.put_opt(:req_body, env.body)
|> Tesla.put_opt(:req_headers, env.headers)
|> Tesla.run(next)
end
end
diff --git a/lib/tesla/middleware/logger.ex b/lib/tesla/middleware/logger.ex
index e94944d..4e05af7 100644
--- a/lib/tesla/middleware/logger.ex
+++ b/lib/tesla/middleware/logger.ex
@@ -1,247 +1,247 @@
defmodule Tesla.Middleware.Logger.Formatter do
@moduledoc false
# Heavily based on Elixir's Logger.Formatter
# https://github.com/elixir-lang/elixir/blob/v1.6.4/lib/logger/lib/logger/formatter.ex
@default_format "$method $url -> $status ($time ms)"
@keys ~w(method url status time)
@type format :: [atom | binary]
@spec compile(binary | nil) :: format
def compile(nil), do: compile(@default_format)
def compile(binary) do
~r/(?<h>)\$[a-z]+(?<t>)/
|> Regex.split(binary, on: [:h, :t], trim: true)
|> Enum.map(&compile_key/1)
end
defp compile_key("$" <> key) when key in @keys, do: String.to_atom(key)
defp compile_key("$" <> key), do: raise(ArgumentError, "$#{key} is an invalid format pattern.")
defp compile_key(part), do: part
@spec format(Tesla.Env.t(), Tesla.Env.result(), integer, format) :: IO.chardata()
def format(request, response, time, format) do
Enum.map(format, &output(&1, request, response, time))
end
defp output(:method, env, _, _), do: env.method |> to_string() |> String.upcase()
defp output(:url, env, _, _), do: env.url
defp output(:status, _, {:ok, env}, _), do: to_string(env.status)
defp output(:status, _, {:error, reason}, _), do: "error: " <> inspect(reason)
defp output(:time, _, _, time), do: :io_lib.format("~.3f", [time / 1000])
defp output(binary, _, _, _), do: binary
end
defmodule Tesla.Middleware.Logger do
- @behaviour Tesla.Middleware
-
@moduledoc """
Log requests using Elixir's Logger.
With the default settings it logs request method, url, response status and time taken in milliseconds.
- ### Example usage
+ ## Example usage
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.Logger
end
```
- ### Options
+ ## Options
- `:log_level` - custom function for calculating log level (see below)
- `:filter_headers` - sanitizes sensitive headers before logging in debug mode (see below)
## Custom log format
The default log format is `"$method $url -> $status ($time ms)"`
which shows in logs like:
```
2018-03-25 18:32:40.397 [info] GET https://bitebot.io -> 200 (88.074 ms)
```
Because log format is processed during compile time it needs to be set in config:
```
config :tesla, Tesla.Middleware.Logger, format: "$method $url ====> $status / time=$time"
```
## Custom log levels
By default, the following log levels will be used:
- `:error` - for errors, 5xx and 4xx responses
- `:warn` - for 3xx responses
- `:info` - for 2xx responses
You can customize this setting by providing your own `log_level/1` function:
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.Logger, log_level: &my_log_level/1
def my_log_level(env) do
case env.status do
404 -> :info
_ -> :default
end
end
end
```
- ### Logger Debug output
+ ## Logger Debug output
When the Elixir Logger log level is set to `:debug`
Tesla Logger will show full request & response.
If you want to disable detailed request/response logging
but keep the `:debug` log level (i.e. in development)
you can set `debug: false` in your config:
```
# config/dev.local.exs
config :tesla, Tesla.Middleware.Logger, debug: false
```
- #### Filter headers
+ ### Filter headers
To sanitize sensitive headers such as `authorization` in
debug logs, add them to the `:filter_headers` option.
`:filter_headers` expects a list of header names as strings.
```
# config/dev.local.exs
config :tesla, Tesla.Middleware.Logger,
filter_headers: ["authorization"]
```
"""
+ @behaviour Tesla.Middleware
+
alias Tesla.Middleware.Logger.Formatter
@config Application.get_env(:tesla, __MODULE__, [])
@format Formatter.compile(@config[:format])
@type log_level :: :info | :warn | :error
require Logger
- @doc false
+ @impl Tesla.Middleware
def call(env, next, opts) do
{time, response} = :timer.tc(Tesla, :run, [env, next])
level = log_level(response, opts)
Logger.log(level, fn -> Formatter.format(env, response, time, @format) end)
if Keyword.get(@config, :debug, true) do
Logger.debug(fn -> debug(env, response, opts) end)
end
response
end
defp log_level({:error, _}, _), do: :error
defp log_level({:ok, env}, opts) do
case Keyword.get(opts, :log_level) do
nil ->
default_log_level(env)
fun when is_function(fun) ->
case fun.(env) do
:default -> default_log_level(env)
level -> level
end
atom when is_atom(atom) ->
atom
end
end
@spec default_log_level(Tesla.Env.t()) :: log_level
def default_log_level(env) do
cond do
env.status >= 400 -> :error
env.status >= 300 -> :warn
true -> :info
end
end
@debug_no_query "(no query)"
@debug_no_headers "(no headers)"
@debug_no_body "(no body)"
@debug_stream "[Elixir.Stream]"
defp debug(request, {:ok, response}, opts) do
[
"\n>>> REQUEST >>>\n",
debug_query(request.query),
?\n,
debug_headers(request.headers, opts),
?\n,
debug_body(request.body),
?\n,
"\n<<< RESPONSE <<<\n",
debug_headers(response.headers, opts),
?\n,
debug_body(response.body)
]
end
defp debug(request, {:error, error}, opts) do
[
"\n>>> REQUEST >>>\n",
debug_query(request.query),
?\n,
debug_headers(request.headers, opts),
?\n,
debug_body(request.body),
?\n,
"\n<<< RESPONSE ERROR <<<\n",
inspect(error)
]
end
defp debug_query([]), do: @debug_no_query
defp debug_query(query) do
query
|> Enum.flat_map(&Tesla.encode_pair/1)
|> Enum.map(fn {k, v} -> ["Query: ", to_string(k), ": ", to_string(v), ?\n] end)
end
defp debug_headers([], _opts), do: @debug_no_headers
defp debug_headers(headers, opts) do
filtered = Keyword.get(opts, :filter_headers, [])
Enum.map(headers, fn {k, v} ->
v = if k in filtered, do: "[FILTERED]", else: v
[k, ": ", v, ?\n]
end)
end
defp debug_body(nil), do: @debug_no_body
defp debug_body([]), do: @debug_no_body
defp debug_body(%Stream{}), do: @debug_stream
defp debug_body(stream) when is_function(stream), do: @debug_stream
defp debug_body(%Tesla.Multipart{} = mp) do
[
"[Tesla.Multipart]\n",
"boundary: ",
mp.boundary,
?\n,
"content_type_params: ",
inspect(mp.content_type_params),
?\n
| Enum.map(mp.parts, &[inspect(&1), ?\n])
]
end
defp debug_body(data) when is_binary(data) or is_list(data), do: data
defp debug_body(term), do: inspect(term)
end
diff --git a/lib/tesla/middleware/method_override.ex b/lib/tesla/middleware/method_override.ex
index 40039f9..e351b4e 100644
--- a/lib/tesla/middleware/method_override.ex
+++ b/lib/tesla/middleware/method_override.ex
@@ -1,49 +1,51 @@
defmodule Tesla.Middleware.MethodOverride do
- @behaviour Tesla.Middleware
-
@moduledoc """
Middleware that adds X-Http-Method-Override header with original request
method and sends the request as post.
Useful when there's an issue with sending non-post request.
- ### Example
+ ## Example
+
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.MethodOverride
end
```
- ### Options
+ ## Options
+
- `:override` - list of http methods that should be overriden,
everything except `:get` and `:post` if not specified
"""
- @doc false
+ @behaviour Tesla.Middleware
+
+ @impl Tesla.Middleware
def call(env, next, opts) do
if overridable?(env, opts) do
env
|> override
|> Tesla.run(next)
else
env
|> Tesla.run(next)
end
end
defp override(env) do
env
|> Tesla.put_headers([{"x-http-method-override", "#{env.method}"}])
|> Map.put(:method, :post)
end
defp overridable?(env, opts) do
if opts[:override] do
env.method in opts[:override]
else
not (env.method in [:get, :post])
end
end
end
diff --git a/lib/tesla/middleware/path_params.ex b/lib/tesla/middleware/path_params.ex
index 5a78db9..60d012f 100644
--- a/lib/tesla/middleware/path_params.ex
+++ b/lib/tesla/middleware/path_params.ex
@@ -1,43 +1,42 @@
defmodule Tesla.Middleware.PathParams do
- @behaviour Tesla.Middleware
-
@moduledoc """
Use templated URLs with separate params.
Useful when logging or reporting metric per URL.
+ ## Example usage
- ### Example usage
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.BaseURl, "https://api.example.com"
plug Tesla.Middleware.Logger # or some monitoring middleware
plug Tesla.Middleware.PathParams
def user(id) do
params = [id: id]
get("/users/:id", opts: [path_params: params])
end
end
```
"""
+ @behaviour Tesla.Middleware
+
@rx ~r/:([\w_]+)/
- @doc false
- @impl true
+ @impl Tesla.Middleware
def call(env, next, _) do
url = build_url(env.url, env.opts[:path_params])
Tesla.run(%{env | url: url}, next)
end
defp build_url(url, nil), do: url
defp build_url(url, params) do
Regex.replace(@rx, url, fn match, key ->
to_string(params[String.to_existing_atom(key)] || match)
end)
end
end
diff --git a/lib/tesla/middleware/retry.ex b/lib/tesla/middleware/retry.ex
index e6fc744..392324b 100644
--- a/lib/tesla/middleware/retry.ex
+++ b/lib/tesla/middleware/retry.ex
@@ -1,98 +1,100 @@
defmodule Tesla.Middleware.Retry do
- @behaviour Tesla.Middleware
-
@moduledoc """
Retry using exponential backoff and full jitter. This middleware only retries in the
case of connection errors (`nxdomain`, `connrefused` etc). Application error
checking for retry can be customized through `:should_retry` option by
providing a function in returning a boolean.
## Backoff algorithm
The backoff algorithm optimizes for tight bounds on completing a request successfully.
It does this by first calculating an exponential backoff factor based on the
number of retries that have been performed. It then multiplies this factor against the
base delay. The total maximum delay is found by taking the minimum of either the calculated delay
or the maximum delay specified. This creates an upper bound on the maximum delay
we can see.
In order to find the actual delay value we take a random number between 0 and
the maximum delay based on a uniform distribution. This randomness ensures that
our retried requests don't "harmonize" making it harder for the downstream
service to heal.
- ### Example
+ ## Example
+
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.Retry,
delay: 500,
max_retries: 10,
max_delay: 4_000,
should_retry: fn
{:ok, %{status: status}} when status in [400, 500] -> true
{:ok, _} -> false
{:error, _} -> true
end
end
```
- ### Options
- - `:delay` - The base delay in milliseconds (defaults to 50)
- - `:max_retries` - maximum number of retries (defaults to 5)
- - `:max_delay` - maximum delay in milliseconds (defaults to 5000)
+ ## Options
+
+ - `:delay` - The base delay in milliseconds (defaults to 50)
+ - `:max_retries` - maximum number of retries (defaults to 5)
+ - `:max_delay` - maximum delay in milliseconds (defaults to 5000)
- `:should_retry` - function to determine if request should be retried
"""
+ @behaviour Tesla.Middleware
+
@defaults [
delay: 50,
max_retries: 5,
max_delay: 5_000
]
- @doc false
+ @impl Tesla.Middleware
def call(env, next, opts) do
opts = opts || []
context = %{
retries: 0,
delay: Keyword.get(opts, :delay, @defaults[:delay]),
max_retries: Keyword.get(opts, :max_retries, @defaults[:max_retries]),
max_delay: Keyword.get(opts, :max_delay, @defaults[:max_delay]),
should_retry: Keyword.get(opts, :should_retry, &match?({:error, _}, &1))
}
retry(env, next, context)
end
# If we have max retries set to 0 don't retry
defp retry(env, next, %{max_retries: 0}), do: Tesla.run(env, next)
# If we're on our last retry then just run and don't handle the error
defp retry(env, next, %{max_retries: max, retries: max}) do
Tesla.run(env, next)
end
# Otherwise we retry if we get a retriable error
defp retry(env, next, context) do
res = Tesla.run(env, next)
if context.should_retry.(res) do
backoff(context.max_delay, context.delay, context.retries)
context = update_in(context, [:retries], &(&1 + 1))
retry(env, next, context)
else
res
end
end
# Exponential backoff with jitter
defp backoff(cap, base, attempt) do
factor = :math.pow(2, attempt)
max_sleep = trunc(min(cap, base * factor))
delay = :rand.uniform(max_sleep)
:timer.sleep(delay)
end
end
diff --git a/lib/tesla/middleware/telemetry.ex b/lib/tesla/middleware/telemetry.ex
index cb0ac65..a2fcbb9 100644
--- a/lib/tesla/middleware/telemetry.ex
+++ b/lib/tesla/middleware/telemetry.ex
@@ -1,116 +1,114 @@
if Code.ensure_loaded?(:telemetry) do
defmodule Tesla.Middleware.Telemetry do
- @behaviour Tesla.Middleware
-
@moduledoc """
Emits events using the `:telemetry` library to expose instrumentation.
## Example usage
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.Telemetry
end
:telemetry.attach("my-tesla-telemetry", [:tesla, :request, stop], fn event, measurements, meta, config ->
# Do something with the event
end)
```
## Options
* `:event_prefix` - a list of atoms to prefix to the telemetry event name. This can be set if you need to distinguish events from different clients. Defaults to `[]`
## Telemetry Events
* `[:tesla, :request, :start]` - emitted at the beginning of the request.
* Measurement: `%{time: System.monotonic_time}`
* Metadata: `%{env: Tesla.Env.t}`
* `[:tesla, :request, :stop]` - emitted at the end of the request.
* Measurement: `%{duration: native_time}`
* Metadata: `%{env: Tesla.Env.t}`
* `[:tesla, :request, :error]` - emitted when there is an error.
* Measurement: `%{value: 1}`
* Metadata: `%{env: Tesla.Env.t, kind: Exception.kind | nil, reason: term, stacktrace: Exception.stacktrace}`
## Legacy Telemetry Events
* `[:tesla, :request]` - This event is emitted for backwards compatibility only and should be considered deprecated.
This event can be disabled by setting `config :tesla, Tesla.Middleware.Telemetry, disable_legacy_event: true` in your config. Be sure to run `mix deps.compile --force tesla` after changing this setting to ensure the change is picked up.
Please check the [telemetry](https://hexdocs.pm/telemetry/) for the further usage.
"""
@disable_legacy_event Application.get_env(:tesla, Tesla.Middleware.Telemetry,
disable_legacy_event: false
)[:disable_legacy_event]
@behaviour Tesla.Middleware
@impl Tesla.Middleware
def call(env, next, opts) do
prefix = Keyword.get(opts, :event_prefix, [])
start_time = System.monotonic_time()
emit_start(env, start_time, prefix)
try do
Tesla.run(env, next)
catch
kind, reason ->
stacktrace = System.stacktrace()
metadata = %{env: env, kind: kind, reason: reason, stacktrace: stacktrace}
:telemetry.execute(
prefix ++ [:tesla, :request, :error],
%{value: 1},
metadata
)
emit_stop(env, start_time, prefix, {:error, reason})
:erlang.raise(kind, reason, stacktrace)
else
{:ok, env} = result ->
emit_stop(env, start_time, prefix, result)
result
{:error, error} = result ->
:telemetry.execute(
prefix ++ [:tesla, :request, :error],
%{value: 1},
%{env: env, kind: nil, reason: error, stacktrace: []}
)
emit_stop(env, start_time, prefix, result)
result
end
end
defp emit_start(env, start_time, prefix) do
:telemetry.execute(prefix ++ [:tesla, :request, :start], %{time: start_time}, %{
env: env
})
end
defp emit_stop(env, start_time, prefix, result) do
duration = System.monotonic_time() - start_time
:telemetry.execute(
prefix ++ [:tesla, :request, :stop],
%{duration: duration},
%{env: env}
)
if !@disable_legacy_event do
# retained for backwards compatibility - remove in 2.0
:telemetry.execute([:tesla, :request], %{request_time: duration}, %{result: result})
end
end
end
end
diff --git a/lib/tesla/middleware/timeout.ex b/lib/tesla/middleware/timeout.ex
index bb4be11..80d7c08 100644
--- a/lib/tesla/middleware/timeout.ex
+++ b/lib/tesla/middleware/timeout.ex
@@ -1,61 +1,63 @@
defmodule Tesla.Middleware.Timeout do
- @behaviour Tesla.Middleware
-
@moduledoc """
Timeout http request after X seconds.
- ### Example
+ ## Example
+
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.Timeout, timeout: 2_000
end
```
- ### Options
+ ## Options
+
- `:timeout` - number of milliseconds a request is allowed to take (defaults to 1000)
"""
+ @behaviour Tesla.Middleware
+
@default_timeout 1_000
- @doc false
+ @impl Tesla.Middleware
def call(env, next, opts) do
opts = opts || []
timeout = Keyword.get(opts, :timeout, @default_timeout)
task = safe_async(fn -> Tesla.run(env, next) end)
try do
task
|> Task.await(timeout)
|> repass_error
catch
:exit, {:timeout, _} ->
Task.shutdown(task, 0)
{:error, :timeout}
end
end
defp safe_async(func) do
Task.async(fn ->
try do
{:ok, func.()}
rescue
e in _ ->
{:exception, e}
catch
type, value ->
{type, value}
end
end)
end
defp repass_error({:exception, error}), do: raise(error)
defp repass_error({:throw, value}), do: throw(value)
defp repass_error({:exit, value}), do: exit(value)
defp repass_error({:ok, result}), do: result
end
diff --git a/lib/tesla/mock.ex b/lib/tesla/mock.ex
index aef6e50..61b38d7 100644
--- a/lib/tesla/mock.ex
+++ b/lib/tesla/mock.ex
@@ -1,233 +1,239 @@
defmodule Tesla.Mock do
@moduledoc """
Mock adapter for better testing.
- ### Setup
+ ## Setup
```
# config/test.exs
config :tesla, adapter: Tesla.Mock
# in case MyClient defines specific adapter with `adapter SpecificAdapter`
config :tesla, MyClient, adapter: Tesla.Mock
```
- ### Example test
+ ## Example test
+
```
defmodule MyAppTest do
use ExUnit.Case
setup do
- Tesla.Mock.mock fn
+ Tesla.Mock.mock(fn
%{method: :get} ->
%Tesla.Env{status: 200, body: "hello"}
- end
+ end)
:ok
end
test "list things" do
assert {:ok, env} = MyApp.get("...")
assert env.status == 200
assert env.body == "hello"
end
end
```
- ### Setting up mocks
+ ## Setting up mocks
+
```
# Match on method & url and return whole Tesla.Env
- Tesla.Mock.mock fn
- %{method: :get, url: "http://example.com/list"} ->
+ Tesla.Mock.mock(fn
+ %{method: :get, url: "http://example.com/list"} ->
%Tesla.Env{status: 200, body: "hello"}
- end
+ end)
# You can use any logic required
- Tesla.Mock.mock fn env ->
+ Tesla.Mock.mock(fn env ->
case env.url do
"http://example.com/list" ->
%Tesla.Env{status: 200, body: "ok!"}
+
_ ->
%Tesla.Env{status: 404, body: "NotFound"}
- end
+ end
+ end)
+
# mock will also accept short version of response
# in the form of {status, headers, body}
- Tesla.Mock.mock fn
+ Tesla.Mock.mock(fn
%{method: :post} -> {201, %{}, %{id: 42}}
- end
+ end)
```
- ### Global mocks
+ ## Global mocks
+
By default, mocks are bound to the current process,
i.e. the process running a single test case.
This design allows proper isolation between test cases
and make testing in parallel (`async: true`) possible.
While this style is recommended, there is one drawback:
if Tesla client is called from different process
it will not use the setup mock.
To solve this issue it is possible to setup a global mock
using `mock_global/1` function.
```
defmodule MyTest do
use ExUnit.Case, async: false # must be false!
setup_all do
Tesla.Mock.mock_global fn
env -> # ...
end
:ok
end
# ...
end
```
**WARNING**: Using global mocks may affect tests with local mock
(because of fallback to global mock in case local one is not found)
"""
defmodule Error do
defexception env: nil, ex: nil, stacktrace: []
def message(%__MODULE__{ex: nil}) do
"""
There is no mock set for process #{inspect(self())}.
Use Tesla.Mock.mock/1 to mock HTTP requests.
See https://github.com/teamon/tesla#testing
"""
end
def message(%__MODULE__{env: env, ex: %FunctionClauseError{} = ex, stacktrace: stacktrace}) do
"""
Request not mocked
The following request was not mocked:
#{inspect(env, pretty: true)}
#{Exception.format(:error, ex, stacktrace)}
"""
end
end
## PUBLIC API
@doc """
Setup mocks for current test.
This mock will only be available to the current process.
"""
@spec mock((Tesla.Env.t() -> Tesla.Env.t() | {integer, map, any})) :: no_return
def mock(fun) when is_function(fun), do: pdict_set(fun)
@doc """
Setup global mocks.
**WARNING**: This mock will be available to ALL processes.
It might cause conflicts when running tests in parallel!
"""
@spec mock_global((Tesla.Env.t() -> Tesla.Env.t() | {integer, map, any})) :: no_return
def mock_global(fun) when is_function(fun), do: agent_set(fun)
## HELPERS
@type response_opt :: :headers | :status
@type response_opts :: [{response_opt, any}]
@doc """
Return json response.
Example
import Tesla.Mock
mock fn
%{url: "/ok"} -> json(%{"some" => "data"})
%{url: "/404"} -> json(%{"some" => "data"}, status: 404)
end
"""
@spec json(body :: term, opts :: [response_opts]) :: Tesla.Env.t()
def json(body, opts \\ []), do: response(json_encode(body), "application/json", opts)
defp json_encode(body) do
engine = Keyword.get(Application.get_env(:tesla, Tesla.Mock, []), :json_engine, Jason)
engine.encode!(body)
end
@doc """
Return text response.
Example
import Tesla.Mock
mock fn
%{url: "/ok"} -> text(%{"some" => "data"})
%{url: "/404"} -> text(%{"some" => "data"}, status: 404)
end
"""
@spec text(body :: term, opts :: [response_opts]) :: Tesla.Env.t()
def text(body, opts \\ []), do: response(body, "text/plain", opts)
defp response(body, content_type, opts) do
defaults = [status: 200, headers: [{"content-type", content_type}]]
struct(Tesla.Env, Keyword.merge(defaults, [{:body, body} | opts]))
end
## ADAPTER IMPLEMENTATION
def call(env, _opts) do
case pdict_get() || agent_get() do
nil ->
raise Tesla.Mock.Error, env: env
fun ->
case rescue_call(fun, env) do
{status, headers, body} ->
{:ok, %{env | status: status, headers: headers, body: body}}
%Tesla.Env{} = env ->
{:ok, env}
{:ok, %Tesla.Env{} = env} ->
{:ok, env}
{:error, reason} ->
{:error, reason}
error ->
{:error, error}
end
end
end
defp pdict_set(fun), do: Process.put(__MODULE__, fun)
defp pdict_get, do: Process.get(__MODULE__)
defp agent_set(fun) do
case Process.whereis(__MODULE__) do
nil -> Agent.start_link(fn -> fun end, name: __MODULE__)
pid -> Agent.update(pid, fn _ -> fun end)
end
end
defp agent_get do
case Process.whereis(__MODULE__) do
nil -> nil
pid -> Agent.get(pid, fn f -> f end)
end
end
defp rescue_call(fun, env) do
fun.(env)
rescue
ex in FunctionClauseError ->
raise Tesla.Mock.Error, env: env, ex: ex, stacktrace: System.stacktrace()
end
end
diff --git a/lib/tesla/multipart.ex b/lib/tesla/multipart.ex
index 8560a33..69c10ba 100644
--- a/lib/tesla/multipart.ex
+++ b/lib/tesla/multipart.ex
@@ -1,195 +1,200 @@
defmodule Tesla.Multipart do
@moduledoc """
Multipart functionality.
- ### Example
+ ## Example
+
```
mp =
- Multipart.new
+ Multipart.new()
|> Multipart.add_content_type_param("charset=utf-8")
|> Multipart.add_field("field1", "foo")
- |> Multipart.add_field("field2", "bar", headers: [{"content-id", "1"}, {"content-type", "text/plain"}])
+ |> Multipart.add_field("field2", "bar",
+ headers: [{"content-id", "1"}, {"content-type", "text/plain"}]
+ )
|> Multipart.add_file("test/tesla/multipart_test_file.sh")
|> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar")
|> Multipart.add_file_content("sample file content", "sample.txt")
- response = client.post(url, mp)
+ response = client.post(url, mp)
```
"""
defmodule Part do
@moduledoc false
defstruct body: nil,
dispositions: [],
headers: []
@type t :: %__MODULE__{
body: String.t(),
headers: Tesla.Env.headers(),
dispositions: Keyword.t()
}
end
@boundary_chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|> String.split("")
@type part_stream :: Enum.t()
@type part_value :: iodata | part_stream
defstruct parts: [],
boundary: nil,
content_type_params: []
@type t :: %__MODULE__{
parts: list(Tesla.Multipart.Part.t()),
boundary: String.t(),
content_type_params: [String.t()]
}
@doc """
Create a new Multipart struct to be used for a request body.
"""
@spec new() :: t
def new do
- %__MODULE__{boundary: unique_string(32)}
+ %__MODULE__{boundary: unique_string()}
end
@doc """
Add a parameter to the multipart content-type.
"""
@spec add_content_type_param(t, String.t()) :: t
def add_content_type_param(%__MODULE__{} = mp, param) do
%{mp | content_type_params: mp.content_type_params ++ [param]}
end
@doc """
Add a field part.
"""
@spec add_field(t, String.t(), part_value, Keyword.t()) :: t
def add_field(%__MODULE__{} = mp, name, value, opts \\ []) do
:ok = assert_part_value!(value)
{headers, opts} = Keyword.pop_first(opts, :headers, [])
part = %Part{
body: value,
headers: headers,
dispositions: [{:name, name}] ++ opts
}
%{mp | parts: mp.parts ++ [part]}
end
@doc """
Add a file part. The file will be streamed.
- Options:
+ ## Options
+
- `:name` - name of form param
- `:filename` - filename (defaults to path basename)
- `:headers` - additional headers
- `:detect_content_type` - auto-detect file content-type (defaults to false)
"""
@spec add_file(t, String.t(), Keyword.t()) :: t
def add_file(%__MODULE__{} = mp, path, opts \\ []) do
{filename, opts} = Keyword.pop_first(opts, :filename, Path.basename(path))
{headers, opts} = Keyword.pop_first(opts, :headers, [])
{detect_content_type, opts} = Keyword.pop_first(opts, :detect_content_type, false)
# add in detected content-type if necessary
headers =
case detect_content_type do
true -> List.keystore(headers, "content-type", 0, {"content-type", MIME.from_path(path)})
false -> headers
end
data = File.stream!(path, [:read], 2048)
add_file_content(mp, data, filename, opts ++ [headers: headers])
end
@doc """
- Add a file part. Same of `add_file/3` but the file content is read from `data` input parameter.
+ Add a file part with value.
+
+ Same of `add_file/3` but the file content is read from `data` input argument.
+
+ ## Options
- Options:
- `:name` - name of form param
- `:headers` - additional headers
"""
@spec add_file_content(t, part_value, String.t(), Keyword.t()) :: t
def add_file_content(%__MODULE__{} = mp, data, filename, opts \\ []) do
{name, opts} = Keyword.pop_first(opts, :name, "file")
add_field(mp, name, data, opts ++ [filename: filename])
end
@doc false
@spec headers(t) :: Tesla.Env.headers()
def headers(%__MODULE__{boundary: boundary, content_type_params: params}) do
ct_params = (["boundary=#{boundary}"] ++ params) |> Enum.join("; ")
[{"content-type", "multipart/form-data; #{ct_params}"}]
end
@doc false
@spec body(t) :: part_stream
def body(%__MODULE__{boundary: boundary, parts: parts}) do
part_streams = Enum.map(parts, &part_as_stream(&1, boundary))
Stream.concat(part_streams ++ [["--#{boundary}--\r\n"]])
end
@doc false
@spec part_as_stream(Part.t(), String.t()) :: part_stream
def part_as_stream(
%Part{body: body, dispositions: dispositions, headers: part_headers},
boundary
) do
part_headers = Enum.map(part_headers, fn {k, v} -> "#{k}: #{v}\r\n" end)
part_headers = part_headers ++ [part_headers_for_disposition(dispositions)]
enum_body =
case body do
b when is_binary(b) -> [b]
b -> b
end
Stream.concat([
["--#{boundary}\r\n"],
part_headers,
["\r\n"],
enum_body,
["\r\n"]
])
end
@doc false
@spec part_headers_for_disposition(Keyword.t()) :: [String.t()]
def part_headers_for_disposition([]), do: []
def part_headers_for_disposition(kvs) do
ds =
kvs
|> Enum.map(fn {k, v} -> "#{k}=\"#{v}\"" end)
|> Enum.join("; ")
["content-disposition: form-data; #{ds}\r\n"]
end
- @doc false
- @spec unique_string(pos_integer) :: String.t()
- defp unique_string(length) do
- Enum.reduce(1..length, [], fn _i, acc ->
- [Enum.random(@boundary_chars) | acc]
- end)
- |> Enum.join("")
+ @spec unique_string() :: String.t()
+ defp unique_string() do
+ 16
+ |> :crypto.strong_rand_bytes()
+ |> Base.encode16(case: :lower)
end
@spec assert_part_value!(any) :: :ok | no_return
defp assert_part_value!(%maybe_stream{})
when maybe_stream in [IO.Stream, File.Stream, Stream],
do: :ok
defp assert_part_value!(value)
when is_list(value)
when is_binary(value),
do: :ok
defp assert_part_value!(val) do
raise(ArgumentError, "#{inspect(val)} is not a supported multipart value.")
end
end
diff --git a/mix.exs b/mix.exs
index a4d3399..71f2f98 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,134 +1,134 @@
defmodule Tesla.Mixfile do
use Mix.Project
@version "1.3.0"
def project do
[
app: :tesla,
version: @version,
description: description(),
package: package(),
- source_ref: "v#{@version}",
source_url: "https://github.com/teamon/tesla",
elixir: "~> 1.5",
elixirc_paths: elixirc_paths(Mix.env()),
deps: deps(),
lockfile: lockfile(System.get_env("LOCKFILE")),
test_coverage: [tool: ExCoveralls],
dialyzer: [
plt_add_apps: [:inets],
plt_add_deps: :project
],
docs: docs()
]
end
# Configuration for the OTP application
#
# Type `mix help compile.app` for more information
def application do
[applications: applications(Mix.env())]
end
def applications(:test), do: applications(:dev) ++ [:httparrot, :hackney, :ibrowse, :gun]
def applications(_), do: [:logger, :ssl, :inets]
defp description do
"HTTP client library, with support for middleware and multiple adapters."
end
defp package do
[
maintainers: ["Tymon Tobolski"],
licenses: ["MIT"],
links: %{"GitHub" => "https://github.com/teamon/tesla"}
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
defp lockfile(nil), do: "mix.lock"
defp lockfile(lockfile), do: "test/lockfiles/#{lockfile}.lock"
defp deps do
[
{:mime, "~> 1.0"},
# http clients
{:ibrowse, "~> 4.4.0", optional: true},
{:hackney, "~> 1.6", optional: true},
{:gun, "~> 1.3", optional: true},
{:castore, "~> 0.1", optional: true},
- {:mint, "~> 0.4", optional: true},
+ {:mint, "~> 1.0", optional: true},
# json parsers
{:jason, ">= 1.0.0", optional: true},
{:poison, ">= 1.0.0", optional: true},
{:exjsx, ">= 3.0.0", optional: true},
# other
{:fuse, "~> 2.4", optional: true},
{:telemetry, "~> 0.4", optional: true},
# testing & docs
{:excoveralls, "~> 0.8", only: :test},
{:httparrot, "~> 1.2", only: :test},
- {:ex_doc, "~> 0.21.1", only: :dev},
- {:mix_test_watch, "~> 0.5", only: :dev},
+ {:ex_doc, "~> 0.21", only: :dev},
+ {:mix_test_watch, "~> 1.0", only: :dev},
{:dialyxir, "~> 1.0.0-rc.3", only: [:dev, :test]},
{:inch_ex, "~> 0.5.6", only: :docs}
]
end
defp docs do
[
main: "readme",
+ source_ref: "v#{@version}",
extras: ["README.md"],
groups_for_modules: [
Behaviours: [
Tesla.Adapter,
Tesla.Middleware
],
Adapters: [
Tesla.Adapter.Gun,
Tesla.Adapter.Hackney,
Tesla.Adapter.Httpc,
Tesla.Adapter.Ibrowse,
Tesla.Adapter.Mint
],
Middlewares: [
Tesla.Middleware.BaseUrl,
Tesla.Middleware.BasicAuth,
Tesla.Middleware.Compression,
Tesla.Middleware.CompressRequest,
Tesla.Middleware.DecodeJson,
Tesla.Middleware.DecodeRels,
Tesla.Middleware.DecompressResponse,
Tesla.Middleware.DigestAuth,
Tesla.Middleware.EncodeJson,
Tesla.Middleware.FollowRedirects,
Tesla.Middleware.FormUrlencoded,
Tesla.Middleware.Fuse,
Tesla.Middleware.Headers,
Tesla.Middleware.JSON,
Tesla.Middleware.KeepRequest,
Tesla.Middleware.Logger,
Tesla.Middleware.MethodOverride,
Tesla.Middleware.Opts,
Tesla.Middleware.PathParams,
Tesla.Middleware.Query,
Tesla.Middleware.Retry,
Tesla.Middleware.Telemetry,
Tesla.Middleware.Timeout
]
],
nest_modules_by_prefix: [
Tesla.Adapter,
Tesla.Middleware
]
]
end
end
diff --git a/mix.lock b/mix.lock
index 3968b2d..3b0c814 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,37 +1,37 @@
%{
- "castore": {:hex, :castore, "0.1.3", "61d720c168d8e3a7d96f188f73d50d7ec79aa619cdabf0acd3782b01ff3a9f10", [:mix], [], "hexpm"},
+ "castore": {:hex, :castore, "0.1.4", "e7fd082c0e755716826a20b95c479f5dd42f536f9d12b8da8f47c92f1d4aed58", [:mix], [], "hexpm"},
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"con_cache": {:hex, :con_cache, "0.13.1", "047e097ab2a8c6876e12d0c29e29a86d487b592df97b98e3e2abedad574e215d", [:mix], [], "hexpm"},
"cowboy": {:hex, :cowboy, "2.5.0", "4ef3ae066ee10fe01ea3272edc8f024347a0d3eb95f6fbb9aed556dacbfc1337", [:rebar3], [{:cowlib, "~> 2.6.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.6.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
"cowlib": {:hex, :cowlib, "2.6.0", "8aa629f81a0fc189f261dc98a42243fa842625feea3c7ec56c48f4ccdb55490f", [:rebar3], [], "hexpm"},
- "dialyxir": {:hex, :dialyxir, "1.0.0-rc.6", "78e97d9c0ff1b5521dd68041193891aebebce52fc3b93463c0a6806874557d7d", [:mix], [{:erlex, "~> 0.2.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"},
- "earmark": {:hex, :earmark, "1.3.5", "0db71c8290b5bc81cb0101a2a507a76dca659513984d683119ee722828b424f6", [:mix], [], "hexpm"},
- "erlex": {:hex, :erlex, "0.2.4", "23791959df45fe8f01f388c6f7eb733cc361668cbeedd801bf491c55a029917b", [:mix], [], "hexpm"},
- "ex_doc": {:hex, :ex_doc, "0.21.1", "5ac36660846967cd869255f4426467a11672fec3d8db602c429425ce5b613b90", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
- "excoveralls": {:hex, :excoveralls, "0.11.1", "dd677fbdd49114fdbdbf445540ec735808250d56b011077798316505064edb2c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
+ "dialyxir": {:hex, :dialyxir, "1.0.0-rc.7", "6287f8f2cb45df8584317a4be1075b8c9b8a69de8eeb82b4d9e6c761cf2664cd", [:mix], [{:erlex, ">= 0.2.5", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"},
+ "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm"},
+ "erlex": {:hex, :erlex, "0.2.5", "e51132f2f472e13d606d808f0574508eeea2030d487fc002b46ad97e738b0510", [:mix], [], "hexpm"},
+ "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
+ "excoveralls": {:hex, :excoveralls, "0.12.0", "50e17a1b116fdb7facc2fe127a94db246169f38d7627b391376a0bc418413ce1", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"},
"file_system": {:hex, :file_system, "0.2.7", "e6f7f155970975789f26e77b8b8d8ab084c59844d8ecfaf58cbda31c494d14aa", [:mix], [], "hexpm"},
"fuse": {:hex, :fuse, "2.4.2", "9106b08db8793a34cc156177d7e24c41bd638ee1b28463cb76562fde213e8ced", [:rebar3], [], "hexpm"},
- "gun": {:hex, :gun, "1.3.0", "18e5d269649c987af95aec309f68a27ffc3930531dd227a6eaa0884d6684286e", [:rebar3], [{:cowlib, "~> 2.6.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm"},
- "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
+ "gun": {:hex, :gun, "1.3.1", "1489fd96018431b89f401041a9ce0b02b45265247f0fdcf71273bf087c64ea4f", [:rebar3], [{:cowlib, "~> 2.6.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm"},
+ "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"httparrot": {:hex, :httparrot, "1.2.0", "5ca2cb7aa936e8f418051b615fb8ec419ec7f29e792ae9fb698393e82513457b", [:mix], [{:con_cache, "~> 0.13.0", [hex: :con_cache, repo: "hexpm", optional: false]}, {:cowboy, "~> 2.5.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:exjsx, "~> 3.0 or ~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}], "hexpm"},
"ibrowse": {:hex, :ibrowse, "4.4.1", "2b7d0637b0f8b9b4182de4bd0f2e826a4da2c9b04898b6e15659ba921a8d6ec2", [:rebar3], [], "hexpm"},
"idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"},
"makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
"makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
- "mint": {:hex, :mint, "0.4.0", "b93a10192957624ed4a8b8641eff1819019c36487bdf49e2b505afd2cc9b7911", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm"},
- "mix_test_watch": {:hex, :mix_test_watch, "0.9.0", "c72132a6071261893518fa08e121e911c9358713f62794a90c95db59042af375", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"},
+ "mint": {:hex, :mint, "1.0.0", "ca5ab33497ba2bdcc42f6cdd3927420a6159116be87c8173658e93c8746703da", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm"},
+ "mix_test_watch": {:hex, :mix_test_watch, "1.0.1", "ae6fc45bbc80b826046fb84208df4b06035e10fae6d44d0cb48c5a2f92ee2e1d", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
"ranch": {:hex, :ranch, "1.6.2", "6db93c78f411ee033dbb18ba8234c5574883acb9a75af0fb90a9b82ea46afa00", [:rebar3], [], "hexpm"},
- "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"},
+ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
"telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
}
diff --git a/test/support/adapter_case.ex b/test/support/adapter_case.ex
index ac289ea..3e54940 100644
--- a/test/support/adapter_case.ex
+++ b/test/support/adapter_case.ex
@@ -1,13 +1,13 @@
defmodule Tesla.AdapterCase do
defmacro __using__(adapter: adapter) do
quote do
@adapter unquote(adapter)
- @http "http://0.0.0.0:#{Application.get_env(:httparrot, :http_port)}"
- @https "https://0.0.0.0:#{Application.get_env(:httparrot, :https_port)}"
+ @http "http://localhost:#{Application.get_env(:httparrot, :http_port)}"
+ @https "https://localhost:#{Application.get_env(:httparrot, :https_port)}"
defp call(env, opts \\ []) do
@adapter.call(env, opts)
end
end
end
end
diff --git a/test/support/docs.ex b/test/support/docs.ex
index 0300367..6c3ef88 100644
--- a/test/support/docs.ex
+++ b/test/support/docs.ex
@@ -1,14 +1,14 @@
defmodule TeslaDocsTest do
defmodule Default do
use Tesla
end
defmodule NoDocs do
use Tesla, docs: false
@doc """
- Something something
+ Something something.
"""
def custom(url), do: get(url)
end
end
diff --git a/test/tesla/adapter/gun_test.exs b/test/tesla/adapter/gun_test.exs
index 9a005eb..1517588 100644
--- a/test/tesla/adapter/gun_test.exs
+++ b/test/tesla/adapter/gun_test.exs
@@ -1,253 +1,382 @@
defmodule Tesla.Adapter.GunTest do
use ExUnit.Case
use Tesla.AdapterCase, adapter: Tesla.Adapter.Gun
use Tesla.AdapterCase.Basic
use Tesla.AdapterCase.Multipart
use Tesla.AdapterCase.StreamRequestBody
use Tesla.AdapterCase.SSL
alias Tesla.Adapter.Gun
test "fallback adapter timeout option" do
request = %Env{
method: :get,
url: "#{@http}/delay/2"
}
- assert {:error, :timeout} = call(request, timeout: 1_000)
+ assert {:error, :recv_response_timeout} = call(request, timeout: 1_000)
end
test "max_body option" do
request = %Env{
method: :get,
- url: "#{@http}/stream-bytes/100",
- query: [
- message: "Hello world!"
- ]
+ url: "#{@http}/stream-bytes/100"
}
assert {:error, :body_too_large} = call(request, max_body: 5)
end
test "query without path" do
request = %Env{
method: :get,
url: "#{@http}"
}
assert {:ok, %Env{} = response} = call(request)
assert response.status == 200
end
test "query without path with query" do
request = %Env{
method: :get,
url: "#{@http}",
query: [
param: "value"
]
}
assert {:ok, %Env{} = response} = call(request)
assert response.status == 200
end
test "response stream" do
request = %Env{
method: :get,
url: "#{@http}/stream-bytes/10"
}
assert {:ok, %Env{} = response} = call(request)
assert response.status == 200
assert byte_size(response.body) == 16
end
test "read response body in chunks" do
request = %Env{
method: :get,
url: "#{@http}/stream-bytes/10"
}
assert {:ok, %Env{} = response} = call(request, body_as: :chunks)
assert response.status == 200
%{pid: pid, stream: stream, opts: opts} = response.body
assert opts[:body_as] == :chunks
assert is_pid(pid)
assert is_reference(stream)
- assert read_body(pid, stream) |> byte_size() == 16
+ assert read_body(pid, stream, opts) |> byte_size() == 16
+ refute Process.alive?(pid)
+ end
+
+ test "read response body in chunks with closing connection by default opts" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} = call(request, body_as: :chunks)
+ assert response.status == 200
+ %{pid: pid, stream: stream, opts: opts} = response.body
+ assert opts[:body_as] == :chunks
+ assert is_pid(pid)
+ assert is_reference(stream)
+
+ assert read_body(pid, stream, opts) |> byte_size() == 16
refute Process.alive?(pid)
end
- test "with body_as :plain reusinng connection" do
+ test "with body_as :plain reusing connection" do
uri = URI.parse(@http)
{:ok, conn} = :gun.open(to_charlist(uri.host), uri.port)
original = "#{uri.host}:#{uri.port}"
request = %Env{
method: :get,
url: "#{@http}/ip"
}
assert {:ok, %Env{} = response} =
call(request, conn: conn, close_conn: false, original: original)
assert response.status == 200
assert Process.alive?(conn)
assert {:ok, %Env{} = response} =
call(request, conn: conn, close_conn: false, original: original)
assert response.status == 200
assert Process.alive?(conn)
:ok = Gun.close(conn)
refute Process.alive?(conn)
end
test "read response body in chunks with reused connection and closing it" do
uri = URI.parse(@http)
{:ok, conn} = :gun.open(to_charlist(uri.host), uri.port)
original = "#{uri.host}:#{uri.port}"
request = %Env{
method: :get,
url: "#{@http}/stream-bytes/10"
}
assert {:ok, %Env{} = response} =
- call(request, body_as: :chunks, conn: conn, original: original)
+ call(request, body_as: :chunks, conn: conn, original: original, close_conn: false)
assert response.status == 200
%{pid: pid, stream: stream, opts: opts} = response.body
assert opts[:body_as] == :chunks
assert is_pid(pid)
assert is_reference(stream)
assert conn == pid
- assert read_body(pid, stream, "", false) |> byte_size() == 16
+ assert read_body(pid, stream, opts) |> byte_size() == 16
assert Process.alive?(pid)
# reusing connection
assert {:ok, %Env{} = response} =
- call(request, body_as: :chunks, conn: conn, original: original)
+ call(request, body_as: :chunks, conn: conn, original: original, close_conn: false)
assert response.status == 200
%{pid: pid, stream: stream, opts: opts} = response.body
assert opts[:body_as] == :chunks
assert is_pid(pid)
assert is_reference(stream)
assert conn == pid
- assert read_body(pid, stream, "", false) |> byte_size() == 16
+ assert read_body(pid, stream, opts) |> byte_size() == 16
assert Process.alive?(pid)
:ok = Gun.close(pid)
refute Process.alive?(pid)
end
test "don't reuse connection if original does not match" do
uri = URI.parse(@http)
{:ok, conn} = :gun.open(to_charlist(uri.host), uri.port)
request = %Env{
method: :get,
url: "#{@http}/stream-bytes/10"
}
assert {:ok, %Env{} = response} =
call(request, body_as: :chunks, conn: conn, original: "example.com:80")
assert response.status == 200
%{pid: pid, stream: stream, opts: opts} = response.body
assert opts[:body_as] == :chunks
assert is_pid(pid)
assert is_reference(stream)
- assert read_body(pid, stream, "", false) |> byte_size() == 16
- assert Process.alive?(pid)
+ assert read_body(pid, stream, opts) |> byte_size() == 16
+ refute Process.alive?(pid)
refute conn == pid
end
+ test "don't reuse connection and verify fun if original does not match in https request" do
+ uri = URI.parse(@https)
+
+ host = to_charlist(uri.host)
+
+ {:ok, conn} = :gun.open(host, uri.port)
+
+ request = %Env{
+ method: :get,
+ url: "#{@https}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} =
+ call(request,
+ body_as: :chunks,
+ conn: conn,
+ original: "example.com:443",
+ transport_opts: [
+ verify_fun: {&:ssl_verify_hostname.verify_fun/3, [check_hostname: 'example.com']}
+ ]
+ )
+
+ assert response.status == 200
+ %{pid: pid, stream: stream, opts: opts} = response.body
+
+ assert is_pid(pid)
+ assert is_reference(stream)
+
+ assert read_body(pid, stream, opts) |> byte_size() == 16
+
+ refute Process.alive?(pid)
+ assert opts[:old_conn] == conn
+ refute conn == pid
+ end
+
+ test "certificates_verification" do
+ request = %Env{
+ method: :get,
+ url: "#{@https}"
+ }
+
+ assert {:ok, %Env{} = response} =
+ call(request,
+ certificates_verification: true,
+ transport_opts: [
+ verify_fun:
+ {fn
+ _cert, _reason, state ->
+ {:valid, state}
+ end, nil}
+ ]
+ )
+ end
+
+ test "certificates_verification with domain" do
+ request = %Env{
+ method: :get,
+ url: "https://localhost:5443"
+ }
+
+ assert {:ok, %Env{} = response} =
+ call(request,
+ certificates_verification: true,
+ transport_opts: [
+ cacertfile: "./deps/httparrot/priv/ssl/server-ca.crt"
+ ]
+ )
+ end
+
test "read response body in stream" do
request = %Env{
method: :get,
url: "#{@http}/stream-bytes/10"
}
assert {:ok, %Env{} = response} = call(request, body_as: :stream)
assert response.status == 200
assert is_function(response.body)
- assert Enum.to_list(response.body) |> List.to_string() |> byte_size() == 16
+ assert Enum.join(response.body) |> byte_size() == 16
end
test "read response body in stream with opened connection without closing connection" do
uri = URI.parse(@http)
{:ok, conn} = :gun.open(to_charlist(uri.host), uri.port)
original = "#{uri.host}:#{uri.port}"
request = %Env{
method: :get,
url: "#{@http}/stream-bytes/10"
}
assert {:ok, %Env{} = response} =
call(request, body_as: :stream, conn: conn, close_conn: false, original: original)
assert response.status == 200
assert is_function(response.body)
- assert Enum.to_list(response.body) |> List.to_string() |> byte_size() == 16
+ assert Enum.join(response.body) |> byte_size() == 16
assert Process.alive?(conn)
:ok = Gun.close(conn)
refute Process.alive?(conn)
end
test "read response body in stream with opened connection with closing connection" do
uri = URI.parse(@http)
{:ok, conn} = :gun.open(to_charlist(uri.host), uri.port)
original = "#{uri.host}:#{uri.port}"
request = %Env{
method: :get,
url: "#{@http}/stream-bytes/10"
}
assert {:ok, %Env{} = response} =
call(request, body_as: :stream, conn: conn, original: original)
assert response.status == 200
assert is_function(response.body)
- assert Enum.to_list(response.body) |> List.to_string() |> byte_size() == 16
+ assert Enum.join(response.body) |> byte_size() == 16
refute Process.alive?(conn)
end
test "error response" do
request = %Env{
method: :get,
url: "#{@http}/status/500"
}
assert {:ok, %Env{} = response} = call(request, timeout: 1_000)
assert response.status == 500
end
- defp read_body(pid, stream, acc \\ "", close_conn \\ true) do
- case Gun.read_chunk(pid, stream, timeout: 1_000) do
- {:fin, body} ->
- if close_conn do
- :ok = Gun.close(pid)
- end
+ test "error on socks proxy" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/status/500"
+ }
+
+ assert {:error, "socks protocol is not supported"} ==
+ call(request, proxy: {:socks5, 'localhost', 1234})
+ end
+
+ test "receive gun_up message when receive is false" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}"
+ }
+ assert {:ok, %Env{} = response} = call(request, receive: false)
+ assert response.status == 200
+ assert_receive {:gun_up, pid, :http}
+ assert is_pid(pid)
+ end
+
+ test "ipv4" do
+ request = %Env{
+ method: :get,
+ url: "http://0.0.0.0:5080/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} = call(request, body_as: :chunks, original: "localhost:5080")
+ assert response.status == 200
+ %{pid: pid, stream: stream, opts: opts} = response.body
+ assert %{origin_host: {0, 0, 0, 0}} = :gun.info(pid)
+ assert opts[:original_matches] == false
+ assert read_body(pid, stream, opts) |> byte_size() == 16
+ end
+
+ test "original does not match" do
+ request = %Env{
+ method: :get,
+ url: "http://localhost:5080/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} = call(request, body_as: :chunks, original: "0.0.0.0:5080")
+ assert response.status == 200
+ %{pid: pid, stream: stream, opts: opts} = response.body
+ assert %{origin_host: 'localhost'} = :gun.info(pid)
+ assert opts[:original_matches] == false
+ assert read_body(pid, stream, opts) |> byte_size() == 16
+ end
+
+ defp read_body(pid, stream, opts, acc \\ "") do
+ case Gun.read_chunk(pid, stream, opts) do
+ {:fin, body} ->
acc <> body
{:nofin, part} ->
- read_body(pid, stream, acc <> part, close_conn)
+ read_body(pid, stream, opts, acc <> part)
end
end
end
diff --git a/test/tesla/adapter/ibrowse_test.exs b/test/tesla/adapter/ibrowse_test.exs
index e50f452..1737572 100644
--- a/test/tesla/adapter/ibrowse_test.exs
+++ b/test/tesla/adapter/ibrowse_test.exs
@@ -1,9 +1,13 @@
defmodule Tesla.Adapter.IbrowseTest do
use ExUnit.Case
use Tesla.AdapterCase, adapter: Tesla.Adapter.Ibrowse
+
+ @http "http://0.0.0.0:#{Application.get_env(:httparrot, :http_port)}"
+ @https "https://0.0.0.0:#{Application.get_env(:httparrot, :https_port)}"
+
use Tesla.AdapterCase.Basic
use Tesla.AdapterCase.Multipart
use Tesla.AdapterCase.StreamRequestBody
use Tesla.AdapterCase.SSL
end
diff --git a/test/tesla/adapter/mint_test.exs b/test/tesla/adapter/mint_test.exs
index 8d92233..27094dc 100644
--- a/test/tesla/adapter/mint_test.exs
+++ b/test/tesla/adapter/mint_test.exs
@@ -1,19 +1,286 @@
defmodule Tesla.Adapter.MintTest do
use ExUnit.Case
use Tesla.AdapterCase, adapter: Tesla.Adapter.Mint
use Tesla.AdapterCase.Basic
use Tesla.AdapterCase.Multipart
use Tesla.AdapterCase.StreamRequestBody
- # TODO: Disabled temporarily
- # use Tesla.AdapterCase.SSL
+
+ use Tesla.AdapterCase.SSL,
+ transport_opts: [cacertfile: "./deps/httparrot/priv/ssl/server-ca.crt"]
test "Delay request" do
request = %Env{
method: :head,
url: "#{@http}/delay/1"
}
- assert {:error, "Response timeout"} = call(request, adapter: [timeout: 100])
+ assert {:error, :timeout} = call(request, timeout: 100)
+ end
+
+ test "max_body option" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/100"
+ }
+
+ assert {:error, :body_too_large} = call(request, max_body: 5)
+ end
+
+ test "response body as stream" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/1500"
+ }
+
+ assert {:ok, %Env{} = response} = call(request, body_as: :stream)
+ assert response.status == 200
+ assert is_function(response.body)
+ assert Enum.join(response.body) |> byte_size() == 2245
+ end
+
+ test "response body as chunks with closing body with default" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} = call(request, body_as: :chunks)
+ assert response.status == 200
+ %{conn: conn, ref: ref, opts: opts, body: body} = response.body
+ assert opts[:body_as] == :chunks
+
+ {:ok, conn, received_body} = read_body(conn, ref, opts, body)
+ assert byte_size(received_body) == 16
+
+ assert conn.state == :closed
+ end
+
+ test "certificates_verification" do
+ request = %Env{
+ method: :get,
+ url: "#{@https}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} =
+ call(request,
+ certificates_verification: true,
+ transport_opts: [
+ verify_fun:
+ {fn
+ _cert, _reason, state ->
+ {:valid, state}
+ end, nil}
+ ]
+ )
+
+ assert response.status == 200
+ assert byte_size(response.body) == 16
+ end
+
+ describe "mode: :passive" do
+ test "body_as: :plain" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} = call(request, mode: :passive)
+ assert response.status == 200
+ assert byte_size(response.body) == 16
+ end
+
+ test "body_as: :stream" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} = call(request, body_as: :stream, mode: :passive)
+ assert response.status == 200
+ assert Enum.join(response.body) |> byte_size() == 16
+ end
+
+ test "body_as: :chunks" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} = call(request, body_as: :chunks, mode: :passive)
+ assert response.status == 200
+ %{conn: conn, ref: ref, opts: opts, body: body} = response.body
+
+ {:ok, _conn, received_body} = read_body(conn, ref, opts, body)
+ assert byte_size(received_body) == 16
+ end
+ end
+
+ describe "500 error" do
+ test "body_as :plain" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/status/500"
+ }
+
+ assert {:ok, %Env{} = response} = call(request)
+ assert response.status == 500
+ end
+
+ test "body_as :stream" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/status/500"
+ }
+
+ assert {:ok, %Env{} = response} = call(request, body_as: :stream)
+ assert response.status == 500
+ end
+
+ test "body_as :chunks" do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/status/500"
+ }
+
+ assert {:ok, %Env{} = response} = call(request, body_as: :chunks)
+ assert response.status == 500
+ end
+ end
+
+ describe "reusing connection" do
+ setup do
+ uri = URI.parse(@http)
+ {:ok, conn} = Mint.HTTP.connect(:http, uri.host, uri.port)
+ {:ok, conn: conn, original: "#{uri.host}:#{uri.port}"}
+ end
+
+ test "body_as :plain", %{conn: conn, original: original} do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} =
+ call(request, conn: conn, original: original, close_conn: false)
+
+ assert response.status == 200
+ assert byte_size(response.body) == 16
+
+ assert {:ok, %Env{} = response} =
+ call(request, conn: conn, original: original, close_conn: false)
+
+ assert response.status == 200
+ assert byte_size(response.body) == 16
+
+ assert {:ok, conn} = Tesla.Adapter.Mint.close(conn)
+ assert conn.state == :closed
+ end
+
+ test "body_as :stream", %{conn: conn, original: original} do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} =
+ call(request,
+ conn: conn,
+ original: original,
+ close_conn: false,
+ body_as: :stream
+ )
+
+ assert response.status == 200
+ assert is_function(response.body)
+ assert Enum.join(response.body) |> byte_size() == 16
+
+ assert {:ok, %Env{} = response} =
+ call(request,
+ conn: conn,
+ original: original,
+ close_conn: false,
+ body_as: :stream
+ )
+
+ assert response.status == 200
+ assert is_function(response.body)
+ assert Enum.join(response.body) |> byte_size() == 16
+
+ assert {:ok, conn} = Tesla.Adapter.Mint.close(conn)
+ assert conn.state == :closed
+ end
+
+ test "body_as :chunks", %{conn: conn, original: original} do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} =
+ call(request,
+ conn: conn,
+ original: original,
+ close_conn: false,
+ body_as: :chunks
+ )
+
+ assert response.status == 200
+ assert %{conn: received_conn, ref: ref, opts: opts, body: body} = response.body
+ {:ok, conn, received_body} = read_body(received_conn, ref, opts, body)
+ assert byte_size(received_body) == 16
+ assert conn.socket == received_conn.socket
+
+ assert {:ok, %Env{} = response} =
+ call(request,
+ conn: conn,
+ original: original,
+ close_conn: false,
+ body_as: :chunks
+ )
+
+ assert response.status == 200
+ assert %{conn: received_conn, ref: ref, opts: opts, body: body} = response.body
+ {:ok, conn, received_body} = read_body(received_conn, ref, opts, body)
+ assert byte_size(received_body) == 16
+ assert conn.socket == received_conn.socket
+
+ {:ok, conn} = Tesla.Adapter.Mint.close(received_conn)
+ assert conn.state == :closed
+ end
+
+ test "don't reuse connection if original does not match", %{conn: conn} do
+ request = %Env{
+ method: :get,
+ url: "#{@http}/stream-bytes/10"
+ }
+
+ assert {:ok, %Env{} = response} =
+ call(request, body_as: :chunks, conn: conn, original: "example.com:80")
+
+ assert response.status == 200
+ %{conn: received_conn, ref: ref, opts: opts, body: body} = response.body
+
+ {:ok, received_conn, received_body} = read_body(received_conn, ref, opts, body)
+ assert byte_size(received_body) == 16
+ refute conn.socket == received_conn.socket
+ refute opts[:conn]
+ assert opts[:old_conn].socket == conn.socket
+ end
+ end
+
+ def read_body(conn, _ref, _opts, {:fin, body}), do: {:ok, conn, body}
+
+ def read_body(conn, ref, opts, {:nofin, acc}),
+ do: read_body(conn, ref, opts, acc)
+
+ def read_body(conn, ref, opts, acc) do
+ case Tesla.Adapter.Mint.read_chunk(conn, ref, opts) do
+ {:fin, conn, body} ->
+ {:ok, conn, acc <> body}
+
+ {:nofin, conn, part} ->
+ read_body(conn, ref, opts, acc <> part)
+ end
end
end
diff --git a/test/tesla/middleware/follow_redirects_test.exs b/test/tesla/middleware/follow_redirects_test.exs
index 23a4cbf..c3cccda 100644
--- a/test/tesla/middleware/follow_redirects_test.exs
+++ b/test/tesla/middleware/follow_redirects_test.exs
@@ -1,182 +1,190 @@
defmodule Tesla.Middleware.FollowRedirectsTest do
use ExUnit.Case
defmodule Client do
use Tesla
plug Tesla.Middleware.FollowRedirects
adapter fn env ->
{status, headers, body} =
case env.url do
"http://example.com/0" ->
+ assert env.query == []
{200, [{"content-type", "text/plain"}], "foo bar"}
"http://example.com/" <> n ->
next = String.to_integer(n) - 1
{301, [{"location", "http://example.com/#{next}"}], ""}
end
{:ok, %{env | status: status, headers: headers, body: body}}
end
end
test "redirects if default max redirects isn't exceeded" do
assert {:ok, env} = Client.get("http://example.com/5")
assert env.status == 200
end
test "raise error when redirect default max redirects is exceeded" do
assert {:error, {Tesla.Middleware.FollowRedirects, :too_many_redirects}} ==
Client.get("http://example.com/6")
end
+ test "drop the query" do
+ assert {:ok, env} = Client.get("http://example.com/5", some_query: "params")
+ assert env.query == []
+ end
+
defmodule CustomMaxRedirectsClient do
use Tesla
plug Tesla.Middleware.FollowRedirects, max_redirects: 1
adapter fn env ->
{status, headers, body} =
case env.url do
"http://example.com/0" ->
+ assert env.query == []
{200, [{"content-type", "text/plain"}], "foo bar"}
"http://example.com/" <> n ->
next = String.to_integer(n) - 1
{301, [{"location", "http://example.com/#{next}"}], ""}
end
{:ok, %{env | status: status, headers: headers, body: body}}
end
end
alias CustomMaxRedirectsClient, as: CMRClient
test "redirects if custom max redirects isn't exceeded" do
assert {:ok, env} = CMRClient.get("http://example.com/1")
assert env.status == 200
end
test "raise error when custom max redirects is exceeded" do
assert {:error, {Tesla.Middleware.FollowRedirects, :too_many_redirects}} ==
CMRClient.get("http://example.com/2")
end
defmodule RelativeLocationClient do
use Tesla
plug Tesla.Middleware.FollowRedirects
adapter fn env ->
{status, headers, body} =
case env.url do
"https://example.com/pl" ->
{200, [{"content-type", "text/plain"}], "foo bar"}
"http://example.com" ->
{301, [{"location", "https://example.com"}], ""}
"https://example.com" ->
{301, [{"location", "/pl"}], ""}
"https://example.com/" ->
{301, [{"location", "/pl"}], ""}
"https://example.com/article" ->
{301, [{"location", "/pl"}], ""}
"https://example.com/one/two" ->
{301, [{"location", "three"}], ""}
"https://example.com/one/three" ->
{200, [{"content-type", "text/plain"}], "foo bar baz"}
end
{:ok, %{env | status: status, headers: headers, body: body}}
end
end
alias RelativeLocationClient, as: RLClient
test "supports relative address in location header" do
assert {:ok, env} = RLClient.get("http://example.com")
assert env.status == 200
end
test "doesn't create double slashes inside new url" do
assert {:ok, env} = RLClient.get("https://example.com/")
assert env.url == "https://example.com/pl"
end
test "rewrites URLs to their root" do
assert {:ok, env} = RLClient.get("https://example.com/article")
assert env.url == "https://example.com/pl"
end
test "rewrites URLs relative to the original URL" do
assert {:ok, env} = RLClient.get("https://example.com/one/two")
assert env.url == "https://example.com/one/three"
end
defmodule CustomRewriteMethodClient do
use Tesla
plug Tesla.Middleware.FollowRedirects
adapter fn env ->
{status, headers, body} =
case env.url do
"http://example.com/0" ->
{200, [{"content-type", "text/plain"}], "foo bar"}
"http://example.com/" <> n ->
next = String.to_integer(n) - 1
{303, [{"location", "http://example.com/#{next}"}], ""}
end
{:ok, %{env | status: status, headers: headers, body: body}}
end
end
alias CustomRewriteMethodClient, as: CRMClient
test "rewrites method to get for 303 requests" do
assert {:ok, env} = CRMClient.post("http://example.com/1", "")
assert env.method == :get
end
defmodule CustomPreservesRequestClient do
use Tesla
plug Tesla.Middleware.FollowRedirects
adapter fn env ->
{status, headers, body} =
case env.url do
"http://example.com/0" ->
{200, env.headers, env.body}
"http://example.com/" <> n ->
next = String.to_integer(n) - 1
- {301, [{"location", "http://example.com/#{next}"}], ""}
+ {307, [{"location", "http://example.com/#{next}"}], ""}
end
{:ok, %{env | status: status, headers: headers, body: body}}
end
end
alias CustomPreservesRequestClient, as: CPRClient
- test "Preserves original request" do
+ test "Preserves original request for 307" do
assert {:ok, env} =
CPRClient.post(
"http://example.com/1",
"Body data",
headers: [{"X-Custom-Header", "custom value"}]
)
+ assert env.method == :post
assert env.body == "Body data"
assert env.headers == [{"X-Custom-Header", "custom value"}]
end
end

File Metadata

Mime Type
text/x-diff
Expires
Mon, Nov 25, 10:41 PM (1 d, 4 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
40101
Default Alt Text
(200 KB)

Event Timeline