Page MenuHomePhorge

No OneTemporary

Size
13 KB
Referenced Files
None
Subscribers
None
diff --git a/README.md b/README.md
index 444075c..8818bb4 100644
--- a/README.md
+++ b/README.md
@@ -1,248 +1,252 @@
# 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 losely based on [Faraday](https://github.com/lostisland/faraday).
It embraces the concept of middleware when processing the request/response cycle.
## Direct usage
```ex
# 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`
```ex
defp deps do
[{:tesla, "~> 0.5.0"},
{:poison, ">= 1.0.0"}] # for JSON middleware
end
```
When using `ibrowse` or `hackney` adapters remember to alter applications list in `mix.exs`
```ex
def application do
[applications: [:ibrowse, ...], ...] # or :hackney
end
```
## Creating API clients
Use `Tesla` module to create API wrappers.
For example
```ex
defmodule GitHub do
use Tesla
plug Tesla.Middleware.BaseUrl, "https://api.github.com"
plug Tesla.Middleware.Headers, %{'Authorization' => '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:
```ex
GitHub.get("/user/teamon/repos")
GitHub.user_repos("teamon")
```
## 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:
```ex
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
### 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:
```ex
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 methods 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:
```ex
defmodule GitHub do
# same as above
def client(token) do
Tesla.build_client [
{Tesla.Middleware.Headers, %{"Authorization" => "token: " <> token }}
]
end
end
```
and then:
```ex
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
```ex
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:
```ex
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:
```ex
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.:
```ex
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)
diff --git a/lib/tesla/middleware/digest_auth.ex b/lib/tesla/middleware/digest_auth.ex
new file mode 100644
index 0000000..17fe731
--- /dev/null
+++ b/lib/tesla/middleware/digest_auth.ex
@@ -0,0 +1,111 @@
+defmodule Tesla.Middleware.DigestAuth do
+ @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:
+ defmodule MyClient do
+ use Tesla
+
+ def client(username, password, opts \\ %{}) do
+ Tesla.build_client [
+ {Tesla.Middleware.DigestAuth, Map.merge(%{username: username, password: password}, opts)}
+ ]
+ end
+ end
+
+ 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"`)
+ """
+
+ def call(env, next, opts) do
+ if env.opts && Keyword.get(env.opts, :digest_auth_handshake) do
+ Tesla.run(env, next)
+ else
+ opts = opts || %{}
+
+ env
+ |> Map.update!(:headers, &Map.merge(&1, authorization_header(env, opts)))
+ |> Tesla.run(next)
+ end
+ end
+
+ defp authorization_header(env, opts) do
+ env
+ |> authorization_vars(opts)
+ |> calculated_authorization_values
+ |> create_header
+ end
+
+ defp authorization_vars(env, opts) do
+ unauthorized_response = env.__module__.get(env.__client__, env.url, [
+ opts: Keyword.put((env.opts || []), :digest_auth_handshake, true),
+ ])
+
+ %{
+ username: opts[:username] || "",
+ password: opts[:password] || "",
+ path: URI.parse(env.url).path,
+ auth: unauthorized_response.headers["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
+
+ 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)},
+ {"algorithm", "MD5"}, # hard-coded, will not work for MD5-sess
+ {"qop", "auth"}, # hard-coded, will not work for auth-int or unspecified
+ ]
+ 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/test/tesla/middleware/digest_auth_test.exs b/test/tesla/middleware/digest_auth_test.exs
new file mode 100644
index 0000000..9821705
--- /dev/null
+++ b/test/tesla/middleware/digest_auth_test.exs
@@ -0,0 +1,88 @@
+defmodule DigestAuthTest do
+ use ExUnit.Case, async: false
+
+ use Tesla.Middleware.TestCase, middleware: Tesla.Middleware.DigestAuth
+
+ defmodule DigestClient do
+ use Tesla
+
+ adapter fn env ->
+ case env do
+ %{url: "/no-digest-auth"} -> env
+ %{headers: %{"authorization" => _}} -> env
+ _ ->
+ %{env | headers: %{
+ "WWW-Authenticate" => """
+ Digest realm="testrealm@host.com",
+ qop="auth,auth-int",
+ nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
+ opaque="5ccc069c403ebaf9f0171e9517f40e41"
+ """
+ }}
+ end
+ end
+
+ def client(username, password, opts \\ %{}) do
+ Tesla.build_client [
+ {Tesla.Middleware.DigestAuth, Map.merge(%{
+ username: username,
+ password: password,
+ cnonce_fn: fn -> "0a4f113b" end,
+ nc: "00000001",
+ }, opts)}
+ ]
+ end
+ end
+
+ defmodule DigestClientWithDefaults do
+ use Tesla
+
+ def client do
+ Tesla.build_client([
+ {Tesla.Middleware.DigestAuth, nil}
+ ])
+ end
+ end
+
+ test "sends request with proper authorization header" do
+ request = DigestClient.client("Mufasa", "Circle Of Life") |> DigestClient.get("/dir/index.html")
+ auth_header = request.headers["authorization"]
+
+ assert auth_header =~ ~r/^Digest /
+ assert auth_header =~ "username=\"Mufasa\""
+ assert auth_header =~ "realm=\"testrealm@host.com\""
+ assert auth_header =~ "algorithm=MD5"
+ assert auth_header =~ "qop=auth"
+ assert auth_header =~ "uri=\"/dir/index.html\""
+ assert auth_header =~ "nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""
+ assert auth_header =~ "nc=00000001"
+ assert auth_header =~ "cnonce=\"0a4f113b\""
+ assert auth_header =~ "response=\"6629fae49393a05397450978507c4ef1\""
+ end
+
+ test "has default values for username and cn" do
+ response = DigestClientWithDefaults.client |> DigestClient.get("/")
+ auth_header = response.headers["authorization"]
+
+ assert auth_header =~ "username=\"\""
+ assert auth_header =~ "nc=00000000"
+ end
+
+ test "generates different cnonce with each request by default" do
+ request = fn -> DigestClientWithDefaults.client |> DigestClient.get("/") end
+ cnonce_1 = Regex.run(~r/cnonce="(.*?)"/, request.().headers["authorization"]) |> Enum.at(1)
+ cnonce_2 = Regex.run(~r/cnonce="(.*?)"/, request.().headers["authorization"]) |> Enum.at(1)
+
+ assert cnonce_1 != cnonce_2
+ end
+
+ test "works when passing custom opts" do
+ request = DigestClientWithDefaults.client |> DigestClient.get("/", opts: [hodor: "hodor"])
+ assert request.opts == [hodor: "hodor"]
+ end
+
+ test "ignores digest auth when server doesn't respond with www-authenticate header" do
+ response = DigestClientWithDefaults.client |> DigestClient.get("/no-digest-auth")
+ refute response.headers["authorization"]
+ end
+end

File Metadata

Mime Type
text/x-diff
Expires
Sun, Jan 19, 11:08 AM (1 h, 23 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
55060
Default Alt Text
(13 KB)

Event Timeline