Page MenuHomePhorge

No OneTemporary

Size
40 KB
Referenced Files
None
Subscribers
None
diff --git a/README.md b/README.md
index a90bfd3..501d983 100644
--- a/README.md
+++ b/README.md
@@ -1,309 +1,329 @@
# Tesla
[![Build Status](https://travis-ci.org/teamon/tesla.svg?branch=master)](https://travis-ci.org/teamon/tesla)
[![Hex.pm](https://img.shields.io/hexpm/v/tesla.svg)](http://hex.pm/packages/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.
## Direct usage
```elixir
# Example get request
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 = Tesla.get("http://httpbin.org/get", query: [a: 1, b: "foo"])
response.url # => "http://httpbin.org/get?a=1&b=foo"
# Example post request
response = Tesla.post("http://httpbin.org/post", "data", headers: %{"Content-Type" => "application/json"})
```
## Installation
Add `tesla` as dependency in `mix.exs`
```elixir
defp deps do
[{:tesla, "~> 0.7.2"},
{:poison, ">= 1.0.0"}] # for JSON middleware
end
```
Also, unless using Elixir 1.4, add `:tesla` to the `applications` list:
```ex
def application do
[applications: [:tesla, ...], ...]
end
```
### Adapters
When using `ibrowse` or `hackney` adapters remember to alter applications list in `mix.exs`
```elixir
def application do
[applications: [:tesla, :ibrowse, ...], ...] # or :hackney
end
```
and add it to the dependency list
```elixir
defp deps do
[{:tesla, "~> 0.7.0"},
{:ibrowse, "~> 4.2"}, # or :hackney
{:poison, ">= 1.0.0"}] # for JSON middleware
end
```
## Creating API clients
Use `Tesla` module to create API wrappers.
For example
```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
adapter Tesla.Adapter.Hackney
def user_repos(login) do
get("/user/" <> login <> "/repos")
end
end
```
Then use it like this:
```elixir
GitHub.get("/user/teamon/repos")
GitHub.user_repos("teamon")
```
### Supported options
Tesla.Builder allows to pass following options
#### `:only` and `:except`
Useful when you don't need functions for all http verbs to be generated.
```elixir
#examples
use Tesla, only: ~w(get post)a
use Tesla, only: [:delete]
use Tesla, except: [:delete, :options]
```
#### `:docs`
You can disable docs for tesla generated functions if you don't want them to be included in your own project docs.
```elixir
defmodule MyProject.ApiModule do
@moduledoc "Module that does something"
use Tesla, docs: false
@doc "Function to get something from somewhere"
def custom_function(), do: get(...)
end
```
## Adapters
Tesla has support for different adapters that do the actual HTTP request processing.
### [httpc](http://erlang.org/doc/man/httpc.html)
The default adapter, available in all erlang installations
### [hackney](https://github.com/benoitc/hackney)
This adapter supports real streaming body.
To use it simply include `adapter :hackney` line in your API client definition.
NOTE: Remember to include hackney in applications list.
### [ibrowse](https://github.com/cmullaparthi/ibrowse)
Tesla has built-in support for [ibrowse](https://github.com/cmullaparthi/ibrowse) Erlang HTTP client.
To use it simply include `adapter :ibrowse` line in your API client definition.
NOTE: Remember to include ibrowse in applications list.
### Test / Mock
When testing it might be useful to use simple function as adapter:
```elixir
defmodule MyApi do
use Tesla
adapter fn (env) ->
case env.url do
"/" -> %{env | status: 200, body: "home"}
"/about" -> %{env | status: 200, body: "about us"}
end
end
end
```
## Middleware
### Basic
- `Tesla.Middleware.BaseUrl` - set base url for all request
- `Tesla.Middleware.Headers` - set request headers
- `Tesla.Middleware.Query` - set query parameters
- `Tesla.Middleware.DecodeRels` - decode `Link` header into `opts[:rels]` field in response
- `Tesla.Middleware.Retry` - retry few times in case of connection refused
- `Tesla.Middleware.FormUrlencoded` - urlencode POST body parameter, useful for POSTing a map/keyword list
- `Tesla.Middleware.FollowRedirects` - follow 3xx redirects
- `Tesla.Middleware.Tuples` - return `{:ok, env} | {:error, reason}` instead of raising exception
### JSON
NOTE: requires [poison](https://hex.pm/packages/poison) (or other engine) as dependency
- `Tesla.Middleware.JSON` - encode/decode request/response bodies as JSON
If you are using different json library it can be easily configured:
```elixir
plug Tesla.Middleware.JSON, engine: JSX, engine_opts: [strict: [:comments]]
# or
plug Tesla.Middleware.JSON, decode: &JSX.decode/1, encode: &JSX.encode/1
```
See [`json.ex`](https://github.com/teamon/tesla/blob/master/lib/tesla/middleware/json.ex) for implementation details.
### Logging
- `Tesla.Middleware.Logger` - log each request in single line including method, path, status and execution time (colored)
- `Tesla.Middleware.DebugLogger` - log full request and response (incl. headers and body)
### Authentication
- `Tesla.Middleware.DigestAuth` - [Digest access authentication](https://en.wikipedia.org/wiki/Digest_access_authentication)
## Dynamic middleware
All functions can take a middleware function as the first parameter.
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.build_client` to create a dynamic middleware function:
```elixir
defmodule GitHub do
# same as above with a slightly change to `user_repos/1`
def user_repos(client, login) do
get(client, "/user/" <> login <> "/repos")
end
def client(token) do
Tesla.build_client [
{Tesla.Middleware.Headers, %{"Authorization" => "token: " <> token }}
]
end
end
```
and then:
```elixir
client = GitHub.client(user_token)
client |> GitHub.user_repos("teamon")
client |> GitHub.get("/me")
```
## Writing your own middleware
A Tesla middleware is a module with `call/3` function, that at some point calls `Tesla.run(env, next)` to process
the rest of stack
```elixir
defmodule MyMiddleware do
def call(env, next, options) do
env
|> do_something_with_request
|> Tesla.run(next)
|> 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)`
- `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, z request logger middleware could be implemented like this:
```elixir
defmodule Tesla.Middleware.RequestLogger do
def call(env, next, _) do
IO.inspect env # print request env
Tesla.run(env, next)
end
end
```
and response logger middleware like this:
```elixir
defmodule Tesla.Middleware.ResponseLogger do
def call(env, next, _) do
res = Tesla.run(env, next)
IO.inspect res # print response env
res
end
end
```
See [`core.ex`](https://github.com/teamon/tesla/blob/master/lib/tesla/middleware/core.ex) and [`json.ex`](https://github.com/teamon/tesla/blob/master/lib/tesla/middleware/json.ex) for more examples.
## Streaming body
If adapter supports it, you can pass a [Stream](http://elixir-lang.org/docs/stable/elixir/Stream.html) as body, e.g.:
```elixir
defmodule ES do
use Tesla.Builder
plug Tesla.Middleware.BaseUrl, "http://localhost:9200"
plug Tesla.Middleware.DecodeJson
plug Tesla.Middleware.EncodeJson
def index(records) do
stream = records |> Stream.map(fn record -> %{index: [some, data]})
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 functionality
+
+You can pass a Multipart struct as the body.
+
+Example:
+```ex
+ alias Tesla.Multipart
+
+ mp =
+ 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_file("test/tesla/multipart_test_file.sh")
+ |> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar")
+
+ response = Tesla.post("http://httpbin.org/post", mp)
+```
diff --git a/lib/tesla/adapter/hackney.ex b/lib/tesla/adapter/hackney.ex
index 7510b96..4d724ac 100644
--- a/lib/tesla/adapter/hackney.ex
+++ b/lib/tesla/adapter/hackney.ex
@@ -1,44 +1,51 @@
if Code.ensure_loaded?(:hackney) do
defmodule Tesla.Adapter.Hackney do
+ alias Tesla.Multipart
+
def call(env, opts) do
with {:ok, status, headers, body} <- request(env, opts || []) do
%{env | status: status,
headers: headers,
body: body}
end
end
defp request(env, opts) do
request(
env.method,
Tesla.build_url(env.url, env.query),
Enum.into(env.headers, []),
env.body,
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, status, headers, ref}) do
with {:ok, body} <- :hackney.body(ref) do
{:ok, status, headers, body}
end
end
end
end
diff --git a/lib/tesla/adapter/httpc.ex b/lib/tesla/adapter/httpc.ex
index eae7e60..8894547 100644
--- a/lib/tesla/adapter/httpc.ex
+++ b/lib/tesla/adapter/httpc.ex
@@ -1,59 +1,69 @@
defmodule Tesla.Adapter.Httpc do
@moduledoc """
Adapter for `:httpc`
**NOTE** Tesla overrides default autoredirect value with false to ensure
consistency between adapters
"""
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
def call(env, opts) do
opts = Keyword.merge(@override_defaults, opts || [])
with {:ok, {status, headers, body}} <- request(env, opts) do
format_response(env, status, headers, body)
end
end
defp format_response(env, {_, status, _}, headers, body) do
%{env | status: status,
headers: headers,
body: body}
end
defp request(env, opts) do
content_type = to_charlist(env.headers["content-type"] || "")
handle request(
env.method || :get,
Tesla.build_url(env.url, env.query) |> to_charlist,
Enum.into(env.headers, [], fn {k,v} -> {to_charlist(k), to_charlist(v)} end),
content_type,
env.body,
Keyword.split(opts ++ env.opts, @http_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} = Keyword.pop_first(headers, 'Content-Type', 'text/plain')
+ 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 4052b63..2710499 100644
--- a/lib/tesla/adapter/ibrowse.ex
+++ b/lib/tesla/adapter/ibrowse.ex
@@ -1,42 +1,50 @@
if Code.ensure_loaded?(:ibrowse) do
defmodule Tesla.Adapter.Ibrowse do
import Tesla.Adapter.Shared, only: [stream_to_fun: 1, next_chunk: 1]
+ alias Tesla.Multipart
def call(env, opts) do
with {:ok, status, headers, body} <- request(env, opts || []) do
%{env | status: status,
headers: headers,
body: body}
end
end
defp request(env, opts) do
body = env.body || []
handle request(
Tesla.build_url(env.url, env.query) |> to_charlist,
Enum.into(env.headers, []),
env.method,
body,
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
:ibrowse.send_req(url, headers, method, body, opts)
end
defp handle({:error, {:conn_failed, error}}), do: error
defp handle(response), do: response
end
end
diff --git a/lib/tesla/middleware/form_urlencoded.ex b/lib/tesla/middleware/form_urlencoded.ex
index fbb0438..a8e8216 100644
--- a/lib/tesla/middleware/form_urlencoded.ex
+++ b/lib/tesla/middleware/form_urlencoded.ex
@@ -1,41 +1,43 @@
defmodule Tesla.Middleware.FormUrlencoded do
@doc """
Send request body as application/x-www-form-urlencoded
Example:
defmodule Myclient do
use Tesla
plug Tesla.Middleware.FormUrlencoded
end
Myclient.post("/url", %{key: :value})
"""
def call(env, next, opts) do
opts = opts || []
env
|> encode(opts)
|> Tesla.run(next)
end
def encode(env, opts) do
if encodable?(env) do
env
|> Map.update!(:body, &encode_body(&1, opts))
|> Tesla.Middleware.Headers.call([], %{"content-type" => "application/x-www-form-urlencoded"})
else
env
end
end
defp encode_body(body, _opts) when is_binary(body), do: body
defp encode_body(body, _opts), do: do_process(body)
- def encodable?(env), do: env.body != nil
+ def encodable?(%{body: nil}), do: false
+ def encodable?(%{body: %Tesla.Multipart{}}), do: false
+ def encodable?(_), do: true
defp do_process(data) do
URI.encode_query(data)
end
end
diff --git a/lib/tesla/middleware/json.ex b/lib/tesla/middleware/json.ex
index b4d28eb..9555730 100644
--- a/lib/tesla/middleware/json.ex
+++ b/lib/tesla/middleware/json.ex
@@ -1,110 +1,112 @@
defmodule Tesla.Middleware.JSON do
# 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_engine Poison
@doc """
Encode and decode response body as JSON
Available options:
- `:decode` - decoding function
- `:encode` - encoding function
- `:engine` - encode/decode engine, e.g `Poison` or `JSX` (defaults to Poison)
- `:engine_opts` - optional engine options
- `:decode_content_types` - list of additional decodable content-types
"""
def call(env, next, opts) do
opts = opts || []
env
|> encode(opts)
|> Tesla.run(next)
|> decode(opts)
end
def encode(env, opts) do
if encodable?(env) do
env
|> Map.update!(:body, &encode_body(&1, opts))
|> Tesla.Middleware.Headers.call([], %{"content-type" => "application/json"})
else
env
end
end
defp encode_body(%Stream{} = body, opts), do: encode_stream(body, opts)
defp encode_body(body, opts) when is_function(body), do: encode_stream(body, opts)
defp encode_body(body, opts), do: process(body, :encode, opts)
defp encode_stream(body, opts) do
Stream.map body, fn item -> encode_body(item, opts) <> "\n" end
end
- def encodable?(env), do: env.body != nil
+ def encodable?(%{body: nil}), do: false
+ def encodable?(%{body: %Tesla.Multipart{}}), do: false
+ def encodable?(_), do: true
def decode(env, opts) do
if decodable?(env, opts) do
Map.update!(env, :body, &process(&1, :decode, opts))
else
env
end
end
def decodable?(env, opts), do: decodable_body?(env) && decodable_content_type?(env, opts)
def decodable_body?(env) do
(is_binary(env.body) && env.body != "") ||
(is_list(env.body) && env.body != [])
end
def decodable_content_type?(env, opts) do
case env.headers["content-type"] do
nil -> false
content_type -> Enum.any?(content_types(opts), &String.starts_with?(content_type, &1))
end
end
def content_types(opts), do: @default_content_types ++ Keyword.get(opts, :decode_content_types, [])
defp process(data, op, opts) do
with {:ok, value} <- do_process(data, op, opts) do
value
else
{:error, reason} -> raise %Tesla.Error{message: "JSON #{op} error: #{inspect reason}", reason: reason}
end
end
defp do_process(data, op, opts) do
if fun = opts[op] do # :encode/:decode
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
def call(env, next, opts) do
opts = opts || []
env
|> Tesla.run(next)
|> Tesla.Middleware.JSON.decode(opts)
end
end
defmodule Tesla.Middleware.EncodeJson do
def call(env, next, opts) do
opts = opts || []
env
|> Tesla.Middleware.JSON.encode(opts)
|> Tesla.run(next)
end
end
diff --git a/lib/tesla/multipart.ex b/lib/tesla/multipart.ex
new file mode 100644
index 0000000..823c521
--- /dev/null
+++ b/lib/tesla/multipart.ex
@@ -0,0 +1,160 @@
+defmodule Tesla.Multipart do
+ @moduledoc """
+ Multipart functionality.
+
+ Example:
+ ```ex
+ mp =
+ 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_file("test/tesla/multipart_test_file.sh")
+ |> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar")
+
+ response = client.post(url, mp)
+ ```
+ """
+
+ @boundary_chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" |> String.split("")
+
+ @type part_stream :: IO.Stream.t | File.Stream.t
+ @type part_value :: String.t | part_stream
+
+ defstruct [
+ parts: [],
+ boundary: nil,
+ content_type_params: []
+ ]
+
+ @type t :: %__MODULE__ {
+ parts: list(Part.t),
+ boundary: String.t,
+ content_type_params: [String.t]
+ }
+
+ defmodule Part do
+ defstruct [
+ body: nil,
+ dispositions: [],
+ headers: [],
+ ]
+
+ @type t :: %__MODULE__ {
+ body: String.t,
+ headers: Keyword.t,
+ dispositions: Keyword.t,
+ }
+ end
+
+ @doc """
+ Create a new Multipart struct to be used for a request body.
+ """
+ @spec new() :: t
+ def new do
+ %__MODULE__{boundary: unique_string(32)}
+ 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
+ {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.
+ """
+ @spec add_file(t, String.t, Keyword.t) :: t
+ def add_file(%__MODULE__{} = mp, filename, opts \\ []) do
+ {name, opts} = Keyword.pop_first(opts, :name, "file")
+ {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 -> Keyword.put(headers, :"Content-Type", :mimerl.filename(filename))
+ false -> headers
+ end
+
+ basename = Path.basename(filename)
+
+ opts =
+ opts
+ |> Keyword.put(:filename, basename)
+ |> Keyword.put(:headers, headers)
+
+ data = File.stream!(filename, [:read], 2048)
+
+ add_field(mp, name, data, opts)
+ end
+
+ @doc false
+ @spec headers(t) :: Keyword.t
+ 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(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("")
+ end
+end
diff --git a/test/tesla/adapter/test_case.ex b/test/tesla/adapter/test_case.ex
index d6bf402..f14d89c 100644
--- a/test/tesla/adapter/test_case.ex
+++ b/test/tesla/adapter/test_case.ex
@@ -1,134 +1,174 @@
defmodule Tesla.Adapter.TestCase do
@http_url "http://localhost:#{Application.get_env(:httparrot, :http_port)}"
@https_url "https://httpbin.org"
def http_url, do: @http_url
def https_url, do: @https_url
end
defmodule Tesla.Adapter.TestCase.Basic do
defmacro __using__([adapter: adapter]) do
quote do
defmodule B.Client do
use Tesla
adapter unquote(adapter)
end
+ defmodule B.ClientWithHeaders do
+ use Tesla
+
+ plug Tesla.Middleware.Headers, %{"Authorization" => "token xyz"}
+ adapter unquote(adapter)
+ end
+
import Tesla.Adapter.TestCase, only: [http_url: 0]
require Tesla
+ alias Tesla.Multipart
setup do
{adapter, _} = B.Client.__adapter__
{:ok, adapter: adapter}
end
test "basic head request" do
response = B.Client.head("#{http_url()}/ip")
assert response.status == 200
end
test "basic get request" do
response = B.Client.get("#{http_url()}/ip")
assert response.status == 200
end
test "basic post request" do
response = B.Client.post("#{http_url()}/post", "some-post-data", headers: %{"Content-Type" => "text/plain"})
assert response.status == 200
assert response.headers["content-type"] == "application/json"
assert Regex.match?(~r/some-post-data/, response.body)
end
+ test "multipart post request" do
+ mp = 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_file("test/tesla/multipart_test_file.sh")
+ |> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar")
+
+ response = B.Client.post("#{http_url()}/post", mp)
+ resp_body = Poison.decode!(response.body)
+
+ assert response.status == 200
+ assert response.headers["content-type"] == "application/json"
+ assert Regex.match?(~r[multipart/form-data; boundary=#{mp.boundary}; charset=utf-8$], resp_body["headers"]["content-type"])
+ assert resp_body["form"] == %{"field1" => "foo", "field2" => "bar"}
+ assert resp_body["files"] == %{
+ "file" => "#!/usr/bin/env bash\necho \"test multipart file\"\n",
+ "foobar" => "#!/usr/bin/env bash\necho \"test multipart file\"\n"
+ }
+ end
+
+ test "multipart with string headers" do
+ mp = Multipart.new
+ |> Multipart.add_field("field1", "foo")
+
+ response = B.ClientWithHeaders.post("#{http_url()}/post", mp)
+ resp_body = Poison.decode!(response.body)
+
+ assert response.status == 200
+ assert resp_body["form"] == %{"field1" => "foo"}
+ end
+
test "unicode request" do
response = B.Client.post("#{http_url()}/post", "1 ø 2 đ 1 \u00F8 2 \u0111", headers: %{"Content-Type" => "text/plain"})
assert response.status == 200
assert response.headers["content-type"] == "application/json"
assert Regex.match?(~r/1 ø 2 đ 1 ø 2 đ/, response.body)
end
test "passing query params" do
client = Tesla.build_client([{Tesla.Middleware.JSON, nil}])
response = client |> B.Client.get("#{http_url()}/get", query: [
page: 1, sort: "desc",
status: ["a", "b", "c"],
user: [name: "Jon", age: 20]
])
args = response.body["args"]
assert args["page"] == "1"
assert args["sort"] == "desc"
assert args["status[]"] == ["a", "b", "c"]
assert args["user[name]"] == "Jon"
assert args["user[age]"] == "20"
end
test "error: normalized connection refused error", %{adapter: adapter} do
assert {:error, :econnrefused} == adapter.call(%Tesla.Env{url: "http://localhost:1234"}, [])
end
test "error: connection refused" do
assert_raise Tesla.Error, fn ->
response = B.Client.get("http://localhost:1234")
end
end
test "autoredirects disabled by default" do
response = B.Client.get("#{http_url()}/redirect-to?url=#{http_url()}/status/200")
assert response.status == 301
end
end
end
end
defmodule Tesla.Adapter.TestCase.StreamRequestBody do
defmacro __using__([adapter: adapter]) do
quote do
defmodule S.Client do
use Tesla
adapter unquote(adapter)
end
import Tesla.Adapter.TestCase, only: [http_url: 0]
test "stream request body: Stream.map" do
body = (1..5) |> Stream.map(&to_string/1)
response = S.Client.post("#{http_url()}/post", body, headers: %{"Content-Type" => "text/plain"})
assert response.status == 200
assert Regex.match?(~r/12345/, to_string(response.body))
end
test "stream request body: Stream.unfold" do
body = Stream.unfold(5, fn 0 -> nil; n -> {n,n-1} end)
|> Stream.map(&to_string/1)
response = S.Client.post("#{http_url()}/post", body, headers: %{"Content-Type" => "text/plain"})
assert response.status == 200
assert Regex.match?(~r/54321/, to_string(response.body))
end
end
end
end
defmodule Tesla.Adapter.TestCase.SSL do
defmacro __using__([adapter: adapter]) do
quote do
defmodule SSL.Client do
use Tesla
adapter unquote(adapter)
end
import Tesla.Adapter.TestCase, only: [https_url: 0]
describe "SSL" do
test "basic get request" do
response = SSL.Client.get("#{https_url()}/ip")
assert response.status == 200
end
end
end
end
end
diff --git a/test/tesla/middleware/form_urlencoded_test.exs b/test/tesla/middleware/form_urlencoded_test.exs
index 50d0d4d..36cbbd6 100644
--- a/test/tesla/middleware/form_urlencoded_test.exs
+++ b/test/tesla/middleware/form_urlencoded_test.exs
@@ -1,35 +1,58 @@
defmodule FormUrlencodedTest do
use ExUnit.Case
use Tesla.Middleware.TestCase, middleware: Tesla.Middleware.FormUrlencoded
defmodule Client do
use Tesla
plug Tesla.Middleware.FormUrlencoded
adapter fn (env) ->
{status, headers, body} = case env.url do
"/post" ->
{201, %{'Content-Type' => 'text/html'}, env.body}
"/check_incoming_content_type" ->
{201, %{'Content-Type' => 'text/html'}, env.headers["content-type"]}
end
%{env | status: status, headers: headers, body: body}
end
end
test "encode body as application/x-www-form-urlencoded" do
assert URI.decode_query(Client.post("/post", %{"foo" => "%bar "}).body) == %{"foo" => "%bar "}
end
test "leave body alone if binary" do
assert Client.post("/post", "data").body == "data"
end
test "check header is set as application/x-www-form-urlencoded" do
assert Client.post("/check_incoming_content_type", %{"foo" => "%bar "}).body
== "application/x-www-form-urlencoded"
end
+
+ defmodule MultipartClient do
+ use Tesla
+
+ plug Tesla.Middleware.FormUrlencoded
+
+ adapter fn (%{url: url, body: %Tesla.Multipart{}} = env) ->
+ {status, headers, body} = case url do
+ "/upload" ->
+ {200, %{'Content-Type' => 'text/html'}, "ok"}
+ end
+
+ %{env | status: status, headers: headers, body: body}
+ end
+ end
+
+ test "skips encoding multipart bodies" do
+ alias Tesla.Multipart
+ mp = Multipart.new
+ |> Multipart.add_field("param", "foo")
+
+ assert MultipartClient.post("/upload", mp).body == "ok"
+ end
end
diff --git a/test/tesla/middleware/json_test.exs b/test/tesla/middleware/json_test.exs
index 8e0257b..e97a1da 100644
--- a/test/tesla/middleware/json_test.exs
+++ b/test/tesla/middleware/json_test.exs
@@ -1,117 +1,140 @@
defmodule JsonTest do
use ExUnit.Case
use Tesla.Middleware.TestCase, middleware: Tesla.Middleware.JSON
use Tesla.Middleware.TestCase, middleware: Tesla.Middleware.DecodeJson
use Tesla.Middleware.TestCase, middleware: Tesla.Middleware.EncodeJson
defmodule Client do
use Tesla
plug Tesla.Middleware.JSON
adapter fn (env) ->
{status, headers, body} = case env.url do
"/decode" ->
{200, %{'Content-Type' => 'application/json'}, "{\"value\": 123}"}
"/encode" ->
{200, %{'Content-Type' => 'application/json'}, env.body |> String.replace("foo", "baz")}
"/empty" ->
{200, %{'Content-Type' => 'application/json'}, nil}
"/empty-string" ->
{200, %{'Content-Type' => 'application/json'}, ""}
"/invalid-content-type" ->
{200, %{'Content-Type' => 'text/plain'}, "hello"}
"/facebook" ->
{200, %{'Content-Type' => 'text/javascript'}, "{\"friends\": 1000000}"}
end
%{env | status: status, headers: headers, body: body}
end
end
test "decode JSON body" do
assert Client.get("/decode").body == %{"value" => 123}
end
test "do not decode empty body" do
assert Client.get("/empty").body == nil
end
test "do not decode empty string body" do
assert Client.get("/empty-string").body == ""
end
test "decode only if Content-Type is application/json or test/json" do
assert Client.get("/invalid-content-type").body == "hello"
end
test "encode body as JSON" do
assert Client.post("/encode", %{"foo" => "bar"}).body == %{"baz" => "bar"}
end
test "decode if Content-Type is text/javascript" do
assert Client.get("/facebook").body == %{"friends" => 1000000}
end
defmodule CustomClient do
use Tesla
plug Tesla.Middleware.DecodeJson, engine: Poison, engine_opts: [keys: :atoms]
adapter fn (env) ->
{status, headers, body} = case env.url do
"/decode" ->
{200, %{'Content-Type' => 'application/json'}, "{\"value\": 123}"}
end
%{env | status: status, headers: headers, body: body}
end
end
test "decode with custom engine options" do
assert CustomClient.get("/decode").body == %{value: 123}
end
defmodule CustomContentTypeClient do
use Tesla
plug Tesla.Middleware.JSON, decode_content_types: ["application/x-custom-json"]
adapter fn (env) ->
{status, headers, body} = case env.url do
"/decode" ->
{200, %{'Content-Type' => 'application/x-custom-json'}, "{\"value\": 123}"}
end
%{env | status: status, headers: headers, body: body}
end
end
test "decode if Content-Type specified in :decode_content_types" do
alias CustomContentTypeClient, as: CCTClient
assert CCTClient.get("/decode").body == %{"value" => 123}
end
defmodule EncodeDecodeJsonClient do
use Tesla
plug Tesla.Middleware.DecodeJson
plug Tesla.Middleware.EncodeJson
adapter fn (env) ->
{status, headers, body} = case env.url do
"/foo2baz" ->
{200, %{'Content-Type' => 'application/json'}, env.body |> String.replace("foo", "baz")}
end
%{env | status: status, headers: headers, body: body}
end
end
test "EncodeJson / DecodeJson work without options" do
alias EncodeDecodeJsonClient, as: EDJClient
assert EDJClient.post("/foo2baz", %{"foo" => "bar"}).body == %{"baz" => "bar"}
end
+
+ defmodule MultipartClient do
+ use Tesla
+
+ plug Tesla.Middleware.JSON
+
+ adapter fn (%{url: url, body: %Tesla.Multipart{}} = env) ->
+ {status, headers, body} = case url do
+ "/upload" ->
+ {200, %{'Content-Type' => 'application/json'}, "{\"status\": \"ok\"}"}
+ end
+
+ %{env | status: status, headers: headers, body: body}
+ end
+ end
+
+ test "skips encoding multipart bodies" do
+ alias Tesla.Multipart
+ mp = Multipart.new
+ |> Multipart.add_field("param", "foo")
+
+ assert MultipartClient.post("/upload", mp).body == %{"status" => "ok"}
+ end
end
diff --git a/test/tesla/multipart_test.exs b/test/tesla/multipart_test.exs
new file mode 100644
index 0000000..cf0e5ef
--- /dev/null
+++ b/test/tesla/multipart_test.exs
@@ -0,0 +1,162 @@
+defmodule Tesla.MultipartTest do
+ use ExUnit.Case
+
+ alias Tesla.Multipart
+
+ test "headers" do
+ mp =
+ Multipart.new
+
+ headers = Multipart.headers(mp)
+
+ assert headers == ["Content-Type": "multipart/form-data; boundary=#{mp.boundary}"]
+ end
+
+ test "add content-type param" do
+ mp =
+ Multipart.new
+ |> Multipart.add_content_type_param("charset=utf-8")
+
+ headers = Multipart.headers(mp)
+
+ assert headers == ["Content-Type": "multipart/form-data; boundary=#{mp.boundary}; charset=utf-8"]
+ end
+
+ test "add content-type params" do
+ mp =
+ Multipart.new
+ |> Multipart.add_content_type_param("charset=utf-8")
+ |> Multipart.add_content_type_param("foo=bar")
+
+ headers = Multipart.headers(mp)
+
+ assert headers == ["Content-Type": "multipart/form-data; boundary=#{mp.boundary}; charset=utf-8; foo=bar"]
+ end
+
+ test "add_field" do
+ mp =
+ Multipart.new
+ |> Multipart.add_field("foo", "bar")
+
+ body = Multipart.body(mp) |> Enum.join
+
+ assert body == """
+--#{mp.boundary}\r
+Content-Disposition: form-data; name="foo"\r
+\r
+bar\r
+--#{mp.boundary}--\r
+"""
+ end
+
+ test "add_field with extra headers" do
+ mp =
+ Multipart.new
+ |> Multipart.add_field("foo", "bar", headers: [{:"Content-Id", 1}, {:"Content-Type", "text/plain"}])
+
+ body = Multipart.body(mp) |> Enum.join
+
+ assert body == """
+--#{mp.boundary}\r
+Content-Id: 1\r
+Content-Type: text/plain\r
+Content-Disposition: form-data; name="foo"\r
+\r
+bar\r
+--#{mp.boundary}--\r
+"""
+ end
+
+ test "add_file (filename only)" do
+ mp =
+ Multipart.new
+ |> Multipart.add_file("test/tesla/multipart_test_file.sh")
+
+ body = Multipart.body(mp) |> Enum.join
+
+ assert body == """
+--#{mp.boundary}\r
+Content-Disposition: form-data; name="file"; filename="multipart_test_file.sh"\r
+\r
+#!/usr/bin/env bash
+echo "test multipart file"
+\r
+--#{mp.boundary}--\r
+"""
+ end
+
+ test "add_file (filename with name)" do
+ mp =
+ Multipart.new
+ |> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar")
+
+ body = Multipart.body(mp) |> Enum.join
+
+ assert body == """
+--#{mp.boundary}\r
+Content-Disposition: form-data; name="foobar"; filename="multipart_test_file.sh"\r
+\r
+#!/usr/bin/env bash
+echo "test multipart file"
+\r
+--#{mp.boundary}--\r
+"""
+ end
+
+ test "add_file (filename with name, extra headers)" do
+ mp =
+ Multipart.new
+ |> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar", headers: [{:"Content-Id", 1}, {:"Content-Type", "text/plain"}])
+ body = Multipart.body(mp) |> Enum.join
+
+ assert body == """
+--#{mp.boundary}\r
+Content-Id: 1\r
+Content-Type: text/plain\r
+Content-Disposition: form-data; name="foobar"; filename="multipart_test_file.sh"\r
+\r
+#!/usr/bin/env bash
+echo "test multipart file"
+\r
+--#{mp.boundary}--\r
+"""
+ end
+
+ test "add_file (detect content type)" do
+ mp =
+ Multipart.new
+ |> Multipart.add_file("test/tesla/multipart_test_file.sh", detect_content_type: true)
+
+ body = Multipart.body(mp) |> Enum.join
+
+ assert body == """
+--#{mp.boundary}\r
+Content-Type: application/x-sh\r
+Content-Disposition: form-data; name="file"; filename="multipart_test_file.sh"\r
+\r
+#!/usr/bin/env bash
+echo "test multipart file"
+\r
+--#{mp.boundary}--\r
+"""
+ end
+
+ test "add_file (detect content type overrides given header)" do
+ mp =
+ Multipart.new
+ |> Multipart.add_file("test/tesla/multipart_test_file.sh", detect_content_type: true, headers: [{:"Content-Type", "foo/bar"}])
+
+ body = Multipart.body(mp) |> Enum.join
+
+ assert body == """
+--#{mp.boundary}\r
+Content-Type: application/x-sh\r
+Content-Disposition: form-data; name="file"; filename="multipart_test_file.sh"\r
+\r
+#!/usr/bin/env bash
+echo "test multipart file"
+\r
+--#{mp.boundary}--\r
+"""
+ end
+end
diff --git a/test/tesla/multipart_test_file.sh b/test/tesla/multipart_test_file.sh
new file mode 100644
index 0000000..b7162f4
--- /dev/null
+++ b/test/tesla/multipart_test_file.sh
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+echo "test multipart file"

File Metadata

Mime Type
text/x-diff
Expires
Sun, Jan 19, 6:03 PM (1 d, 6 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
55361
Default Alt Text
(40 KB)

Event Timeline