defmodule Maxwell.Conn do
@moduledoc """
The Maxwell connection.
This module defines a `Maxwell.Conn` struct and the main functions
for working with Maxwell connections.
### Request fields
These fields contain request information:
* `url` - the requested url as a binary, example: `"www.example.com:8080/path/?foo=bar"`.
* `method` - the request method as a atom, example: `GET`.
* `req_headers` - the request headers as a map, example: `%{"content-type" => "text/plain"}`.
* `req_body` - the request body, by default is an empty string. It is set
to nil after the request is set.
### Response fields
These fields contain response information:
* `status` - the response status
* `resp_headers` - the response headers as a map.
* `resp_body` - the response body (todo desc).
### Connection fields
* `state` - the connection state
The connection state is used to track the connection lifecycle. It starts
as `:unsent` but is changed to `:sending`, Its final result is `:sent` or `:error`.
### Protocols
`Maxwell.Conn` implements Inspect protocols out of the box.
The inspect protocol provides a nice representation of the connection.
"""
@type file_body_t :: {:file, Path.t()}
@type t :: %__MODULE__{
state: :unsent | :sending | :sent | :error,
method: atom,
url: String.t(),
path: String.t(),
query_string: map,
opts: Keyword.t(),
req_headers: %{binary => binary},
req_body: iodata | map | Maxwell.Multipart.t() | file_body_t | Enumerable.t(),
status: non_neg_integer | nil,
resp_headers: %{binary => binary},
resp_body: iodata | map,
private: map
}
defstruct state: :unsent,
method: nil,
url: "",
path: "",
query_string: %{},
req_headers: %{},
req_body: nil,
opts: [],
status: nil,
resp_headers: %{},
resp_body: "",
private: %{}
alias Maxwell.{Conn, Query}
defmodule AlreadySentError do
@moduledoc """
Error raised when trying to modify or send an already sent request
"""
defexception message: "the request was already sent"
end
defmodule NotSentError do
@moduledoc """
Error raised when no request is sent in a connection
"""
defexception message: "the request was not sent yet"
end
@doc """
Create a new connection.
The url provided will be parsed by `URI.parse/1`, and the relevant connection fields will
be set accordingly.
### Examples
iex> new()
%Maxwell.Conn{}
iex> new("http://example.com/foo")
%Maxwell.Conn{url: "http://example.com", path: "/foo"}
iex> new("http://example.com/foo?bar=qux")
%Maxwell.Conn{url: "http://example.com", path: "/foo", query_string: %{"bar" => "qux"}}
"""
@spec new() :: t
def new(), do: %Conn{}
@spec new(binary) :: t
def new(url) when is_binary(url) do
%URI{scheme: scheme, path: path, query: query} = uri = URI.parse(url)
scheme = scheme || "http"
path = path || ""
conn =
case uri do
%URI{host: nil} ->
# This is a badly formed URI, so we'll do best effort:
cond do
# example.com:8080
scheme != nil and Integer.parse(path) != :error ->
%Conn{url: "http://#{scheme}:#{path}"}
# example.com
String.contains?(path, ".") ->
%Conn{url: "#{scheme}://#{path}"}
# special case for localhost
path == "localhost" ->
%Conn{url: "#{scheme}://localhost"}
# /example - not a valid hostname, assume it's a path
String.starts_with?(path, "/") ->
%Conn{path: path}
# example - not a valid hostname, assume it's a path
true ->
%Conn{path: "/" <> path}
end
%URI{userinfo: nil, scheme: "http", port: 80, host: host} ->
%Conn{url: "http://#{host}", path: path}
%URI{userinfo: nil, scheme: "https", port: 443, host: host} ->
%Conn{url: "https://#{host}", path: path}
%URI{userinfo: nil, port: port, host: host} ->
%Conn{url: "#{scheme}://#{host}:#{port}", path: path}
%URI{userinfo: userinfo, port: port, host: host} ->
%Conn{url: "#{scheme}://#{userinfo}@#{host}:#{port}", path: path}
end
case is_nil(query) do
true -> conn
false -> put_query_string(conn, Query.decode(query))
end
end
@doc """
Set the path of the request.
### Examples
iex> put_path(new(), "delete")
%Maxwell.Conn{path: "delete"}
"""
@spec put_path(t, String.t()) :: t | no_return
def put_path(%Conn{state: :unsent} = conn, path), do: %{conn | path: path}
def put_path(_conn, _path), do: raise(AlreadySentError)
@doc false
def put_path(path) when is_binary(path) do
IO.warn("put_path/1 is deprecated, use new/1 or new/2 followed by put_path/2 instead")
put_path(new(), path)
end
@doc """
Add query string to `conn.query_string`.
* `conn` - `%Conn{}`
* `query_map` - as map, for example `%{foo => bar}`
### Examples
# %Conn{query_string: %{name: "zhong wen"}}
put_query_string(%Conn{}, %{name: "zhong wen"})
"""
@spec put_query_string(t, map()) :: t | no_return
def put_query_string(%Conn{state: :unsent, query_string: qs} = conn, query) do
%{conn | query_string: Map.merge(qs, query)}
end
def put_query_string(_conn, _query_map), do: raise(AlreadySentError)
@doc false
def put_query_string(query) when is_map(query) do
IO.warn(
"put_query_string/1 is deprecated, use new/1 or new/2 followed by put_query_string/2 instead"
)
put_query_string(new(), query)
end
@doc """
Set a query string value for the request.
### Examples
iex> put_query_string(new(), :name, "zhong wen")
%Maxwell.Conn{query_string: %{:name => "zhong wen"}}
"""
def put_query_string(%Conn{state: :unsent, query_string: qs} = conn, key, value) do
%{conn | query_string: Map.put(qs, key, value)}
end
def put_query_string(_conn, _key, _value), do: raise(AlreadySentError)
@doc """
Merge a map of headers into the existing headers of the connection.
### Examples
iex> %Maxwell.Conn{headers: %{"content-type" => "text/javascript"}
|> put_req_headers(%{"Accept" => "application/json"})
%Maxwell.Conn{req_headers: %{"accept" => "application/json", "content-type" => "text/javascript"}}
"""
@spec put_req_headers(t, map()) :: t | no_return
def put_req_headers(%Conn{state: :unsent, req_headers: headers} = conn, extra_headers)
when is_map(extra_headers) do
new_headers =
extra_headers
|> Enum.reduce(headers, fn {header_name, header_value}, acc ->
Map.put(acc, String.downcase(header_name), header_value)
end)
%{conn | req_headers: new_headers}
end
def put_req_headers(_conn, _headers), do: raise(AlreadySentError)
# TODO: Remove
@doc false
def put_req_header(headers) do
IO.warn(
"put_req_header/1 is deprecated, use new/1 or new/2 followed by put_req_headers/2 instead"
)
put_req_headers(new(), headers)
end
# TODO: Remove
@doc false
def put_req_header(conn, headers) when is_map(headers) do
IO.warn("put_req_header/2 is deprecated, use put_req_headers/1 instead")
put_req_headers(conn, headers)
end
@doc """
Set a request header. If it already exists, it is updated.
### Examples
iex> %Maxwell.Conn{req_headers: %{"content-type" => "text/javascript"}}
|> put_req_header("Content-Type", "application/json")
|> put_req_header("User-Agent", "zhongwencool")
%Maxwell.Conn{req_headers: %{"content-type" => "application/json", "user-agent" => "zhongwenool"}
"""
def put_req_header(%Conn{state: :unsent, req_headers: headers} = conn, key, value) do
new_headers = Map.put(headers, String.downcase(key), value)
%{conn | req_headers: new_headers}
end
def put_req_header(_conn, _key, _value), do: raise(AlreadySentError)
@doc """
Get all request headers as a map
### Examples
iex> %Maxwell.Conn{req_headers: %{"cookie" => "xyz"} |> get_req_header
%{"cookie" => "xyz"}
"""
@spec get_req_header(t) :: %{String.t() => String.t()}
def get_req_headers(%Conn{req_headers: headers}), do: headers
# TODO: Remove
@doc false
def get_req_header(conn) do
IO.warn("get_req_header/1 is deprecated, use get_req_headers/1 instead")
get_req_headers(conn)
end
@doc """
Get a request header by key. The key lookup is case-insensitive.
Returns the value as a string, or nil if it doesn't exist.
### Examples
iex> %Maxwell.Conn{req_headers: %{"cookie" => "xyz"} |> get_req_header("cookie")
"xyz"
"""
@spec get_req_header(t, String.t()) :: String.t() | nil
def get_req_header(conn, nil) do
IO.warn("get_req_header/2 with a nil key is deprecated, use get_req_headers/2 instead")
get_req_headers(conn)
end
def get_req_header(%Conn{req_headers: headers}, key), do: Map.get(headers, String.downcase(key))
@doc """
Set adapter options for the request.
### Examples
iex> put_options(new(), connect_timeout: 4000)
%Maxwell.Conn{opts: [connect_timeout: 4000]}
"""
@spec put_options(t, Keyword.t()) :: t | no_return
def put_options(%Conn{state: :unsent, opts: opts} = conn, extra_opts)
when is_list(extra_opts) do
%{conn | opts: Keyword.merge(opts, extra_opts)}
end
def put_options(_conn, extra_opts) when is_list(extra_opts), do: raise(AlreadySentError)
@doc """
Set an adapter option for the request.
### Examples
iex> put_option(new(), :connect_timeout, 5000)
%Maxwell.Conn{opts: [connect_timeout: 5000]}
"""
@spec put_option(t, atom(), term()) :: t | no_return
def put_option(%Conn{state: :unsent, opts: opts} = conn, key, value) when is_atom(key) do
%{conn | opts: [{key, value} | opts]}
end
def put_option(%Conn{}, key, _value) when is_atom(key), do: raise(AlreadySentError)
# TODO: remove
@doc false
def put_option(opts) when is_list(opts) do
IO.warn("put_option/1 is deprecated, use new/1 or new/2 followed by put_options/2 instead")
put_options(new(), opts)
end
# TODO: remove
@doc false
def put_option(conn, opts) when is_list(opts) do
IO.warn("put_option/2 is deprecated, use put_options/2 instead")
put_options(conn, opts)
end
@doc """
Set the request body.
### Examples
iex> put_req_body(new(), "new body")
%Maxwell.Conn{req_body: "new_body"}
"""
@spec put_req_body(t, Enumerable.t() | binary()) :: t | no_return
def put_req_body(%Conn{state: :unsent} = conn, req_body) do
%{conn | req_body: req_body}
end
def put_req_body(_conn, _req_body), do: raise(AlreadySentError)
# TODO: remove
@doc false
def put_req_body(body) do
IO.warn("put_req_body/1 is deprecated, use new/1 or new/2 followed by put_req_body/2 instead")
put_req_body(new(), body)
end
@doc """
Get response status.
Raises `Maxwell.Conn.NotSentError` when the request is unsent.
### Examples
iex> get_status(%Maxwell.Conn{status: 200})
200
"""
@spec get_status(t) :: pos_integer | no_return
def get_status(%Conn{status: status, state: state}) when state !== :unsent, do: status
def get_status(_conn), do: raise(NotSentError)
@doc """
Get all response headers as a map.
### Examples
iex> %Maxwell.Conn{resp_headers: %{"cookie" => "xyz"} |> get_resp_header
%{"cookie" => "xyz"}
"""
@spec get_resp_headers(t) :: %{String.t() => String.t()} | no_return
def get_resp_headers(%Conn{state: :unsent}), do: raise(NotSentError)
def get_resp_headers(%Conn{resp_headers: headers}), do: headers
# TODO: remove
@doc false
def get_resp_header(conn) do
IO.warn("get_resp_header/1 is deprecated, use get_resp_headers/1 instead")
get_resp_headers(conn)
end
@doc """
Get a response header by key.
The value is returned as a string, or nil if the header is not set.
### Examples
iex> %Maxwell.Conn{resp_headers: %{"cookie" => "xyz"}} |> get_resp_header("cookie")
"xyz"
"""
@spec get_resp_header(t, String.t()) :: String.t() | nil | no_return
def get_resp_header(%Conn{state: :unsent}, _key), do: raise(NotSentError)
# TODO: remove
def get_resp_header(conn, nil) do
IO.warn("get_resp_header/2 with a nil key is deprecated, use get_resp_headers/1 instead")
get_resp_headers(conn)
end
def get_resp_header(%Conn{resp_headers: headers}, key),
do: Map.get(headers, String.downcase(key))
@doc """
Return the response body.
### Examples
iex> get_resp_body(%Maxwell.Conn{state: :sent, resp_body: "best http client"})
"best http client"
"""
@spec get_resp_body(t) :: binary() | map() | no_return
def get_resp_body(%Conn{state: :sent, resp_body: body}), do: body
def get_resp_body(_conn), do: raise(NotSentError)
@doc """
Return a value from the response body by key or with a parsing function.
### Examples
iex> get_resp_body(%Maxwell.Conn{state: :sent, resp_body: %{"name" => "xyz"}}, "name")
"xyz"
iex> func = fn(x) ->
...> [key, value] = String.split(x, ":")
...> value
...> end
...> get_resp_body(%Maxwell.Conn{state: :sent, resp_body: "name:xyz"}, func)
"xyz"
"""
def get_resp_body(%Conn{state: state}, _) when state != :sent, do: raise(NotSentError)
def get_resp_body(%Conn{resp_body: body}, func) when is_function(func, 1), do: func.(body)
def get_resp_body(%Conn{resp_body: body}, keys) when is_list(keys), do: get_in(body, keys)
def get_resp_body(%Conn{resp_body: body}, key), do: body[key]
@doc """
Set a private value. If it already exists, it is updated.
### Examples
iex> %Maxwell.Conn{private: %{}}
|> put_private(:user_id, "zhongwencool")
%Maxwell.Conn{private: %{user_id: "zhongwencool"}}
"""
@spec put_private(t, atom, term()) :: t
def put_private(%Conn{private: private} = conn, key, value) do
new_private = Map.put(private, key, value)
%{conn | private: new_private}
end
@doc """
Get a private value
### Examples
iex> %Maxwell.Conn{private: %{user_id: "zhongwencool"}}
|> get_private(:user_id)
"zhongwencool"
"""
@spec get_private(t, atom) :: term()
def get_private(%Conn{private: private}, key) do
Map.get(private, key)
end
defimpl Inspect, for: Conn do
def inspect(conn, opts) do
Inspect.Any.inspect(conn, opts)
end
end
end