Current section
Files
Jump to
Current section
Files
lib/rackla.ex
defmodule Rackla do
@moduledoc Regex.replace(~r/```(elixir|json)(\n|.*)```/Us, File.read!("README.md"),
fn(_, _, code) -> Regex.replace(~r/^/m, code, " ") end)
import Plug.Conn
require Logger
@type t :: %__MODULE__{producers: [pid]}
defstruct producers: []
@doc """
Takes a single string (URL) or a `Rackla.Request` struct and executes a HTTP
request to the defined server. You can, by using the `Rackla.Request` struct,
specify more advanced options for your request such as which HTTP verb to use
but also individual connection timeout limits etc. You can also call this
function with a list of strings or `Rackla.Request` structs in order to
perform multiple requests concurrently.
This function will return a `Rackla` type which will contain the results
from the request(s) once available or an `:error` tuple in case of failures
such non-responding servers or DNS lookup failures. Per default, on success, it
will only contain the response payload but the entire response can be used by
setting the option `:full` to true.
Options:
* `:full` - If set to true, the `Rackla` type will contain a `Rackla.Response`
struct with the status code, headers and body (payload), default: false.
* `:connect_timeout` - Connection timeout limit in milliseconds, default:
`5_000`.
* `:receive_timeout` - Receive timeout limit in milliseconds, default:
`5_000`.
* `:insecure` - If set to true, SSL certificates will not be checked,
default: `false`.
* `:follow_redirect` - If set to true, Rackla will follow redirects,
default: `false`.
* `:max_redirect` - Maximum number of redirects, default: `5`.
* `:force_redirect` - Force follow redirect (e.g. POST), default: `false`.
* `:proxy` - Proxy to use, see `Rackla.Proxy`, default: `nil`.
If you specify any options in a `Rackla.Request` struct, these will overwrite
the options passed to the `request` function for that specific request.
"""
@spec request(String.t | Rackla.Request.t | [String.t] | [Rackla.Request.t], Keyword.t) :: t
def request(requests, options \\ [])
def request(requests, options) when is_list(requests) do
producers =
Enum.map(requests, fn(request) ->
request =
if is_binary(request) do
%Rackla.Request{url: request}
else
request
end
{:ok, producer} =
Task.start_link(fn ->
request_options = Map.get(request, :options, %{})
global_insecure = Keyword.get(options, :insecure, false)
global_connect_timeout = Keyword.get(options, :connect_timeout, 5_000)
global_receive_timeout = Keyword.get(options, :receive_timeout, 5_000)
global_follow_redirect = Keyword.get(options, :follow_redirect, false)
global_max_redirect = Keyword.get(options, :max_redirect, 5)
global_force_redirect = Keyword.get(options, :force_redirect, false)
global_proxy = Keyword.get(options, :proxy)
request_proxy = Map.get(request_options, :proxy)
rackla_proxy =
cond do
request_proxy -> request_proxy
global_proxy -> global_proxy
true -> nil
end
proxy_options =
case rackla_proxy do
%Rackla.Proxy{type: type, host: host, port: port, username: username, password: password, pool: pool} ->
proxy_basic_setting = [proxy: {type, String.to_char_list(host), port}]
auth_settings =
case type do
:socks5 ->
socks5_user = if username, do: [socks5_user: username], else: []
socks5_pass = if password, do: [socks5_pass: password], else: []
socks5_user ++ socks5_pass
:connect ->
if username && password do
[proxy_auth: {username, password}]
else
[]
end
end
pool_setting = if pool, do: [pool: pool], else: []
proxy_basic_setting ++ auth_settings ++ pool_setting
nil -> []
end
hackney_request =
:hackney.request(
Map.get(request, :method, :get),
Map.get(request, :url, ""),
Map.get(request, :headers, %{}) |> Enum.into([]),
Map.get(request, :body, ""),
[
insecure: Map.get(request_options, :insecure, global_insecure),
connect_timeout: Map.get(request_options, :connect_timeout, global_connect_timeout),
recv_timeout: Map.get(request_options, :receive_timeout, global_receive_timeout),
follow_redirect: Map.get(request_options, :follow_redirect, global_follow_redirect),
max_redirect: Map.get(request_options, :max_redirect, global_max_redirect),
force_redirect: Map.get(request_options, :force_redirect, global_force_redirect)
] ++ proxy_options
)
case hackney_request do
{:ok, {:maybe_redirect, _, _, _}} ->
warn_request(:force_redirect_disabled)
{:ok, status, headers, body_ref} ->
case :hackney.body(body_ref) do
{:ok, body} ->
consumer = receive do
{pid, :ready} -> pid
end
global_full = Keyword.get(options, :full, false)
response =
if Map.get(request_options, :full, global_full) do
%Rackla.Response{status: status, headers: headers |> Enum.into(%{}), body: body}
else
body
end
send(consumer, {self, {:ok, response}})
{:error, reason} ->
warn_request(reason)
end
{:error, {reason, _partial_body}} ->
warn_request(reason)
{:error, reason} ->
warn_request(reason)
end
end)
producer
end)
%Rackla{producers: producers}
end
def request(request, options) do
request([request], options)
end
@doc """
Takes any type an encapsulates it in a `Rackla` type.
Example:
Rackla.just([1,2,3]) |> Rackla.map(&IO.inspect/1)
[1, 2, 3]
"""
@spec just(any | [any]) :: t
def just(thing) do
{:ok, producers} =
Task.start_link(fn ->
consumer = receive do
{pid, :ready} -> pid
end
send(consumer, {self, {:ok, thing}})
end)
%Rackla{producers: [producers]}
end
@doc """
Takes a list of and encapsulates each of the containing elements separately
in a `Rackla` type.
Example:
Rackla.just_list([1,2,3]) |> Rackla.map(&IO.inspect/1)
3
2
1
"""
@spec just_list([any]) :: t
def just_list(things) when is_list(things) do
things
|> Enum.map(&just/1)
|> Enum.reduce(&(join &2, &1))
end
@doc """
Returns a new `Rackla` type, where each encapsulated item is the result of
invoking `fun` on each corresponding encapsulated item.
Example:
Rackla.just_list([1,2,3]) |> Rackla.map(fn(x) -> x * 2 end) |> Rackla.collect
[2, 4, 6]
"""
@spec map(t, (any -> any)) :: t
def map(%Rackla{producers: producers}, fun) when is_function(fun, 1) do
new_producers =
Enum.map(producers, fn(producer) ->
{:ok, new_producer} =
Task.start_link(fn ->
send(producer, {self, :ready})
response =
receive do
{^producer, {:rackla, nested_producers}} ->
{:rackla, map(nested_producers, fun)}
{^producer, {:ok, thing}} ->
{:ok, fun.(thing)}
{^producer, error} ->
{:ok, fun.(error)}
end
consumer = receive do
{pid, :ready} -> pid
end
send(consumer, {self, response})
end)
new_producer
end)
%Rackla{producers: new_producers}
end
@doc """
Takes a `Rackla` type, applies the specified function to each of the
elements encapsulated in it and returns a new `Rackla` type with the
results. The given function must return a `Rackla` type.
This function is useful when you want to create a new request pipeline based
on the results of a previous request. In those cases, you can use
`Rackla.flat_map` to access the response from a request and call
`Rackla.request` inside the function since `Rackla.request` returns a
`Rackla` type.
Example:
Rackla.just_list([1,2,3]) |> Rackla.flat_map(fn(x) -> Rackla.just(x * 2) end) |> Rackla.collect
[2, 4, 6]
"""
@spec flat_map(t, (any -> t)) :: t
def flat_map(%Rackla{producers: producers}, fun) do
new_producers =
Enum.map(producers, fn(producer) ->
{:ok, new_producer} =
Task.start_link(fn ->
send(producer, {self, :ready})
%Rackla{} = new_rackla =
receive do
{^producer, {:rackla, nested_rackla}} ->
flat_map(nested_rackla, fun)
{^producer, {:ok, thing}} ->
fun.(thing)
{^producer, error} ->
fun.(error)
end
receive do
{consumer, :ready} ->
send(consumer, {self, {:rackla, new_rackla}})
end
end)
new_producer
end)
%Rackla{producers: new_producers}
end
@doc """
Invokes `fun` for each element in the `Rackla` type passing that element and
the accumulator `acc` as arguments. `fun`s return value is stored in `acc`. The
first element of the collection is used as the initial value of `acc`. Returns
the accumulated value inside a `Rackla` type.
Example:
Rackla.just_list([1,2,3]) |> Rackla.reduce(fn (x, acc) -> x + acc end) |> Rackla.collect
6
"""
@spec reduce(t, (any, any -> any)) :: t
def reduce(%Rackla{} = rackla, fun) when is_function(fun, 2) do
{:ok, new_producer} =
Task.start_link(fn ->
thing = reduce_recursive(rackla, fun)
receive do
{consumer, :ready} -> send(consumer, {self, {:ok, thing}})
end
end)
%Rackla{producers: [new_producer]}
end
@doc """
Invokes `fun` for each element in the `Rackla` type passing that element and
the accumulator `acc` as arguments. fun's return value is stored in `acc`.
Returns the accumulated value inside a `Rackla` type.
Example:
Rackla.just_list([1,2,3]) |> Rackla.reduce(10, fn (x, acc) -> x + acc end) |> Rackla.collect
16
"""
def reduce(%Rackla{} = rackla, acc, fun) when is_function(fun, 2) do
{:ok, new_producer} =
Task.start_link(fn ->
thing = reduce_recursive(rackla, acc, fun)
receive do
{consumer, :ready} -> send(consumer, {self, {:ok, thing}})
end
end)
%Rackla{producers: [new_producer]}
end
@spec reduce_recursive(t, (any, any -> any)) :: any
defp reduce_recursive(%Rackla{producers: producers}, fun) do
[producer | tail_producers] = producers
send(producer, {self, :ready})
acc =
receive do
{^producer, {:rackla, nested_producers}} ->
reduce_recursive(nested_producers, fun)
{^producer, {:ok, thing}} ->
thing
{^producer, error} ->
error
end
reduce_recursive(%Rackla{producers: tail_producers}, acc, fun)
end
@spec reduce_recursive(t, any, (any, any -> any)) :: any
defp reduce_recursive(%Rackla{producers: producers}, acc, fun) do
Enum.reduce(producers, acc, fn(producer, acc) ->
send(producer, {self, :ready})
receive do
{^producer, {:rackla, nested_producers}} ->
reduce_recursive(nested_producers, acc, fun)
{^producer, {:ok, thing}} ->
fun.(thing, acc)
{^producer, error} ->
fun.(error, acc)
end
end)
end
@doc """
Returns the element encapsulated inside a `Rackla` type, or a list of
elements in case the `Rackla` type contains many elements.
Example:
Rackla.just_list([1,2,3]) |> Rackla.collect
[1,2,3]
"""
@spec collect(t) :: [any] | any
def collect(%Rackla{} = rackla) do
[single_response | rest] = list_responses = collect_recursive(rackla)
if rest == [], do: single_response, else: list_responses
end
@spec collect_recursive(t) :: [any]
defp collect_recursive(%Rackla{producers: producers}) do
Enum.flat_map(producers, fn(producer) ->
send(producer, {self, :ready})
receive do
{^producer, {:rackla, nested_rackla}} ->
collect_recursive(nested_rackla)
{^producer, {:ok, thing}} ->
[thing]
{^producer, error} ->
[error]
end
end)
end
@doc """
Returns a new `Rackla` type by joining the encapsulated elements from two
`Rackla` types.
Example:
Rackla.join(Rackla.just(1), Rackla.just(2)) |> Rackla.collect
[1, 2]
"""
@spec join(t, t) :: t
def join(%Rackla{producers: p1}, %Rackla{producers: p2}) do
%Rackla{producers: p1 ++ p2}
end
@doc """
Converts a `Rackla` type to a HTTP response and send it to the client by
using `Plug.Conn`. The `Plug.Conn` will be taken implicitly by looking for a
variable named `conn`. If you want to specify which `Plug.Conn` to use, you
can use `Rackla.response_conn`.
Strings will be sent as is to the client. If the `Rackla` type contains any
other type such as a list, it will be converted into a string by using `inspect`
on it. You can also convert Elixir data types to JSON format by setting the
option `:json` to true.
Using this macro is the same as writing:
conn = response_conn(rackla, conn, options)
Options:
* `:compress` - Compresses the response by applying a gzip compression to it.
When this option is used, the entire response has to be sent in one chunk.
You can't reuse the `conn` to send any more data after `Rackla.response` with
`:compress` set to `true` has been invoked. When set to `true`, Rackla will
check the request header `content-encoding` to make sure the client accepts
gzip responses. If you want to respond with gzip without checking the
request headers, you can set `:compress` to `:force`.
* `:json` - If set to true, the encapsulated elements will be converted into
a JSON encoded string before they are sent to the client. This will also set
the header "content-type" to the appropriate "application/json; charset=utf-8".
"""
defmacro response(rackla, options \\ []) do
quote do
var!(conn) = response_conn(unquote(rackla), var!(conn), unquote(options))
_ = var!(conn) # hack to get rid of "unused variable" compiler warning
end
end
@doc """
See documentation for `Rackla.response`.
"""
@spec response_conn(t, Plug.Conn.t, Keyword.t) :: Plug.Conn.t
def response_conn(%Rackla{} = rackla, conn, options \\ []) do
cond do
Keyword.get(options, :compress, false) || Keyword.get(options, :json, false) ->
response_sync(rackla, conn, options)
Keyword.get(options, :sync, false) ->
response_sync_chunk(rackla, conn, options)
true ->
response_async(rackla, conn, options)
end
end
@doc """
Convert an incoming request (from `Plug`) to a `Rackla.Request`.
If `options` is specified, it will be added to the `Rackla.Request`.
For valid options, see documentation for `Rackla.Request`.
Returns either `{:ok, Rackla.Request}` or `{:error, reason}` as per
`:gen_tcp.recv/2`.
The `Plug.Conn` will be taken implicitly by looking for a variable named
`conn`. If you want to specify which `Plug.Conn` to use, you can use
`Rackla.incoming_request_conn`.
Using this macro is the same as writing:
`conn = incoming_request_conn(conn, options)`
From `Plug.Conn` documentation:
Because the request body can be of any size, reading the body will only work
once, as Plug will not cache the result of these operations. If you need to
access the body multiple times, it is your responsibility to store it. Finally
keep in mind some plugs like Plug.Parsers may read the body, so the body may
be unavailable after being accessed by such plugs.
"""
@spec incoming_request(%{}) :: {:ok, Rackla.Request.t} | {:error, atom}
defmacro incoming_request(options) do
quote do
{var!(conn), rackla_request} = incoming_request_conn(var!(conn), unquote(options))
_ = var!(conn) # hack to get rid of "unused variable" compiler warning
rackla_request
end
end
@doc """
See see incoming_request with options.
"""
@spec incoming_request() :: {:ok, Rackla.Request.t} | {:error, atom}
defmacro incoming_request() do
quote do
{var!(conn), rackla_request} = incoming_request_conn(var!(conn))
_ = var!(conn) # hack to get rid of "unused variable" compiler warning
rackla_request
end
end
@doc """
See documentation for `Rackla.incoming_request`.
"""
@spec incoming_request_conn(Plug.Conn.t, %{}) :: {Plug.Conn.t, {:ok, Rackla.Request.t}} | {Plug.Conn.t, {:error, atom}}
def incoming_request_conn(conn, options \\ %{}) do
response_body =
Stream.unfold(Plug.Conn.read_body(conn), fn
:done ->
nil;
{:ok, body, new_conn} ->
{{new_conn, body}, :done};
{:more, partial_body, new_conn} ->
{partial_body, Plug.Conn.read_body(new_conn)};
{:error, term} ->
{{:error, term}, :done}
end)
|> Enum.reduce({"", conn}, fn
({:error, term}, {_body_acc, conn_acc}) -> {{:error, term}, conn_acc};
({new_conn, body}, {body_acc, _conn_acc}) -> {{:ok, body_acc <> body}, new_conn};
(partial_body, {body_acc, conn_acc}) -> {body_acc <> partial_body, conn_acc}
end)
case response_body do
{{:error, term}, final_conn} -> {final_conn, {:error, term}}
{{:ok, body}, final_conn} ->
method = conn.method |> String.downcase |> String.to_atom
query_string = if conn.query_string != "", do: "?#{conn.query_string}", else: ""
url = "#{Atom.to_string(conn.scheme)}://#{conn.host}:#{conn.port}#{conn.request_path}#{query_string}"
headers = Enum.into(conn.req_headers, %{})
rackla_request =
%Rackla.Request{
method: method,
url: url,
headers: headers,
body: body,
options: options
}
{final_conn, {:ok, rackla_request}}
end
end
@spec response_async(t, Plug.Conn.t, Keyword.t) :: Plug.Conn.t
defp response_async(%Rackla{} = rackla, conn, options) do
conn = prepare_conn(conn, Keyword.get(options, :status, 200), Keyword.get(options, :headers, %{}))
prepare_chunks(rackla)
|> send_chunks(conn)
end
@spec prepare_chunks(t) :: [pid]
defp prepare_chunks(%Rackla{producers: producers}) do
Enum.map(producers, fn(pid) ->
send(pid, {self, :ready})
pid
end)
end
@spec send_chunks([pid], Plug.Conn.t) :: Plug.Conn.t
defp send_chunks([], conn), do: conn
defp send_chunks(producers, conn) when is_list(producers) do
send_thing =
fn(thing, remaining_producers, conn) ->
thing = if is_binary(thing), do: thing, else: inspect(thing)
case chunk(conn, thing) do
{:ok, new_conn} ->
send_chunks(remaining_producers, new_conn)
{:error, reason} ->
warn_response(reason)
conn
end
end
receive do
{message_producer, thing} ->
{remaining_producers, current_producer} =
Enum.partition(producers, &(&1 != message_producer))
if (current_producer == []) do
send_chunks(remaining_producers, conn)
else
case thing do
{:rackla, nested_rackla} ->
send_chunks(remaining_producers ++ prepare_chunks(nested_rackla), conn)
{:ok, thing} ->
send_thing.(thing, remaining_producers, conn)
error ->
send_thing.(error, remaining_producers, conn)
end
end
end
end
@spec prepare_conn(Plug.Conn.t, integer, %{}) :: Plug.Conn.t
defp prepare_conn(conn, status, headers) do
if (conn.state == :chunked) do
conn
else
conn
|> set_headers(headers)
|> send_chunked(status)
end
end
@spec response_sync_chunk(t, Plug.Conn.t, Keyword.t) :: Plug.Conn.t
defp response_sync_chunk(%Rackla{} = rackla, conn, options) do
conn = prepare_conn(conn, Keyword.get(options, :status, 200), Keyword.get(options, :headers, %{}))
Enum.reduce(prepare_chunks(rackla), conn, fn(pid, conn) ->
receive do
{^pid, {:rackla, nested_rackla}} ->
response_sync_chunk(nested_rackla, conn, options)
{^pid, thing} ->
thing = if elem(thing, 0) == :ok, do: elem(thing, 1), else: thing
thing = if is_binary(thing), do: thing, else: inspect(thing)
case chunk(conn, thing) do
{:ok, new_conn} ->
new_conn
{:error, reason} ->
warn_response(reason)
conn
end
end
end)
end
@spec response_sync(t, Plug.Conn.t, Keyword.t) :: Plug.Conn.t
defp response_sync(%Rackla{} = rackla, conn, options) do
response_encoded =
if Keyword.get(options, :json, false) do
response = collect(rackla)
if is_list(response) do
Enum.map(response, fn(thing) ->
if is_binary(thing) do
case Poison.decode(thing) do
{:ok, decoded} -> decoded
{:error, _reason} -> thing
end
else
thing
end
end)
|> Poison.encode
else
if is_binary(response) do
case Poison.decode(response) do
{:ok, _decoded} -> {:ok, response}
{:error, _reason} -> Poison.encode(response)
end
else
Poison.encode(response)
end
end
else
binary =
Enum.map(collect_recursive(rackla), &(if is_binary(&1), do: &1, else: inspect(&1)))
|> Enum.join
{:ok, binary}
end
case response_encoded do
{:ok, response_binary} ->
headers = Keyword.get(options, :headers, %{})
compress = Keyword.get(options, :compress, false)
{response_binary, headers} =
if compress do
allow_gzip =
Plug.Conn.get_req_header(conn, "accept-encoding")
|> Enum.flat_map(fn(encoding) ->
String.split(encoding, ",", trim: true)
|> Enum.map(&String.strip/1)
end)
|> Enum.any?(&(Regex.match?(~r/(^(\*|gzip)(;q=(1$|1\.0{1,3}$|0\.[1-9]{1,3}$)|$))/, &1)))
if allow_gzip || compress == :force do
{:zlib.gzip(response_binary), Map.merge(headers, %{"content-encoding" => "gzip"})}
else
{response_binary, headers}
end
else
{response_binary, headers}
end
conn =
if Keyword.get(options, :json, false) do
put_resp_content_type(conn, "application/json")
else
conn
end
chunk_status =
prepare_conn(conn, Keyword.get(options, :status, 200), headers)
|> chunk(response_binary)
case chunk_status do
{:ok, new_conn} ->
new_conn
{:error, reason} ->
warn_response(reason)
conn
end
{:error, reason} ->
case Logger.error("Response decoding error: #{inspect(reason)}") do
{:error, logger_reason} ->
IO.puts(:std_err, "Unable to log \"Response decoding error: #{inspect(reason)}\", reason: #{inspect(logger_reason)}")
:ok -> :ok
end
conn
end
end
@spec set_headers(Plug.Conn.t, %{}) :: Plug.Conn.t
defp set_headers(conn, headers) do
Enum.reduce(headers, conn, fn({key, value}, conn) ->
put_resp_header(conn, key, value)
end)
end
@spec warn_response(any) :: :ok
defp warn_response(reason) do
case Logger.error("HTTP response error: #{inspect(reason)}") do
{:error, logger_reason} ->
IO.puts(:std_err, "Unable to log \"HTTP response error: #{inspect(reason)}\", reason: #{inspect(logger_reason)}")
:ok -> :ok
end
:ok
end
@spec warn_request(any) :: :ok
defp warn_request(reason) do
case Logger.warn("HTTP request error: #{inspect(reason)}") do
{:error, logger_reason} ->
IO.puts(:std_err, "Unable to log \"HTTP request error: #{inspect(reason)}\", reason: #{inspect(logger_reason)}")
:ok -> :ok
end
consumer = receive do
{pid, :ready} -> pid
end
send(consumer, {self, {:error, reason}})
:ok
end
end
defimpl Inspect, for: Rackla do
import Inspect.Algebra
def inspect(_rackla, _opts) do
concat ["#Rackla<>"]
end
end