Packages

Sofa, an idiomatic relaxing CouchDB client 0.2.0

Current section

Files

Jump to
sofa lib sofa.ex
Raw

lib/sofa.ex

defmodule Sofa do
@moduledoc """
Documentation for `Sofa`, a test-driven idiomatic Apache CouchDB client.
> If the only tool you have is CouchDB, then
> everything looks like `{:ok, :relax}`
## Examples
iex> Sofa.init() |> Sofa.client() |> Sofa.connect!()
%{
"couchdb" => "Welcome",
"features" => ["access-ready", "partitioned", "pluggable-storage-engines",
"reshard", "scheduler"],
"git_sha" => "ce596c65d",
"uuid" => "59c032d3a6adcd5b44315137a124bf69",
"vendor" => %{"name" => "FreeBSD"},
"version" => "3.1.1"
}
"""
@derive {Inspect, except: [:auth]}
defstruct [
# auth specific headers such as Bearer, Basic
:auth,
# re-usable tesla HTTP client
:client,
# optional database field
:database,
# feature response as returned from CouchDB `GET /`
:features,
# optional timeout for CouchDB-specific responses
:timeout,
# %URI parsed
:uri,
# uuid as reported from CouchDB `GET /`
:uuid,
# vendor-specific info as reported from CouchDB `GET /`
:vendor,
# CouchDB's API version
:version
]
@type t :: %__MODULE__{
auth: any,
client: nil | Tesla.Client.t(),
database: nil | binary,
features: nil | list,
timeout: nil | integer,
uri: nil | URI.t(),
uuid: nil | binary,
vendor: nil | map,
version: nil | binary
}
require Logger
# these default credentials are also used in CouchDB integration tests
# because CouchDB3+ no longer accepts "admin party" blank credentials
@default_uri "http://admin:passwd@localhost:5984/"
# a long timeout allows transient issues to be concealed by haproxy
@default_timeout 15_000
@doc """
Takes an optional parameter, the CouchDB uri, and returns a struct
containing the usual CouchDB server properties. The URI may be given
as a string or as a %URI struct.
This should be piped into `Sofa.client/1` to create the HTTP client,
which is stored inside the struct with correct authentication information.
## Examples
iex> Sofa.init("https://very:Secure@foreignho.st:6984/")
%Sofa{
auth: "very:Secure",
features: nil,
uri: %URI{
authority: "very:Secure@foreignho.st:6984",
fragment: nil,
host: "foreignho.st",
path: "/",
port: 6984,
query: nil,
scheme: "https",
userinfo: "very:Secure"
},
uuid: nil,
vendor: nil,
version: nil
}
"""
@spec init(uri :: String.t() | URI.t()) :: Sofa.t()
def init(uri \\ @default_uri) do
uri = URI.parse(uri)
%Sofa{
auth: uri.userinfo,
uri: uri
}
end
@doc """
Builds Telsa runtime client, with appropriate middleware header credentials,
from supplied %Sofa{} struct.
"""
@spec client(Sofa.t()) :: Sofa.t()
def client(couch = %Sofa{uri: uri}) do
couch_url = uri.scheme <> "://" <> uri.host <> ":#{uri.port}/"
middleware = [
{Tesla.Middleware.BaseUrl, couch_url},
Tesla.Middleware.JSON,
{Tesla.Middleware.BasicAuth, auth_info(uri.userinfo)}
]
client = Tesla.client(middleware)
%Sofa{couch | client: client, timeout: @default_timeout}
end
@doc """
Returns user & password credentials extracted from a typical %URI{} userinfo
field, as a Tesla-compatible authorization header. Currently only supports
BasicAuth user:password combination.
## Examples
iex> Sofa.auth_info("admin:password")
%{username: "admin", password: "password"}
iex> Sofa.auth_info("blank:")
%{username: "blank", password: ""}
iex> Sofa.auth_info("garbage")
%{}
"""
@spec auth_info(nil | String.t()) :: %{} | %{user: String.t(), password: String.t()}
def auth_info(nil), do: %{}
def auth_info(info) when is_binary(info) do
case String.split(info, ":", parts: 2) do
[""] -> %{}
["", _] -> %{}
[user, password] -> %{username: user, password: password}
_ -> %{}
end
end
@doc """
Given an existing %Sofa{} struct, or a prepared URI, attempts to connect
to the CouchDB instance, and returns an updated %Sofa{} to use in future
connections to this server, using the same HTTP credentials.
Returns an updated `{:ok, %Sofa{}}` on success, or `{:error, reason}`, if
for example, the URL is unreachable, times out, supplied credentials are
rejected by CouchDB, or returns unexpected HTTP status codes.
"""
@spec connect(String.t() | Sofa.t()) :: {:ok, Sofa.t()} | {:error, any()}
def connect(sofa) when is_binary(sofa) do
init(sofa) |> client() |> connect()
end
def connect(couch = %Sofa{}) do
case result = Tesla.get(couch.client, "/") do
{:error, _} ->
result
{:ok, resp = %{body: %{"error" => _error, "reason" => _reason}}} ->
{:error, resp}
{:ok, resp} ->
{:ok,
%Sofa{
couch
| features: resp.body["features"],
uuid: resp.body["uuid"],
vendor: resp.body["vendor"],
version: resp.body["version"]
}}
end
end
@doc """
Bang! wrapper around Sofa.connect/1; raises exceptions on error.
"""
@spec connect!(String.t() | Sofa.t()) :: Sofa.t()
def connect!(sofa) when is_binary(sofa) do
init(sofa) |> client() |> connect!()
end
def connect!(sofa = %Sofa{}) when is_struct(sofa, Sofa) do
url = sofa.uri.host <> ":" <> to_string(sofa.uri.port)
case connect(sofa) do
{:error, :econnrefused} ->
raise Sofa.Error, "connection refused to " <> url
{:ok, resp} ->
resp
_ ->
raise Sofa.Error, "unhandled error from " <> url
end
end
@doc """
List all databases. Only available to admin users.
"""
@spec all_dbs(Sofa.t()) :: {:error, any()} | {:ok, Sofa.t(), [String.t()]}
def all_dbs(sofa = %Sofa{}) do
case raw(sofa, "_all_dbs") do
{:error, reason} -> {:error, reason}
{:ok, _sofa, resp} -> {:ok, resp.body}
end
end
@doc """
Get _active_tasks. Only available to admin users.
"""
@spec active_tasks(Sofa.t()) :: {:error, any()} | {:ok, Sofa.t(), [String.t()]}
def active_tasks(sofa = %Sofa{}) do
case raw(sofa, "active_tasks") do
{:error, reason} -> {:error, reason}
{:ok, _sofa, resp} -> {:ok, resp.body}
end
end
@doc """
Minimal wrapper around native CouchDB HTTP API, allowing an escape hatch
for raw functionality, and as the core abstraction layer for Sofa itself.
"""
@spec raw(
Sofa.t(),
Tesla.Env.url(),
Tesla.Env.method(),
Tesla.Env.opts(),
Tesla.Env.body(),
Tesla.Env.headers()
) ::
{:error, any()} | {:ok, Sofa.t(), Sofa.Response.t()}
def raw(
sofa = %Sofa{timeout: timeout},
path \\ "",
method \\ :get,
query \\ [],
body \\ "",
headers \\ []
) do
# each Tesla adapter handles "empty" options differently - some
# expect nil, others "", and some expect the key:value to be missing
case Tesla.request(sofa.client,
url: path,
method: method,
query: query,
headers: headers,
body: body,
opts: [adapter: [timeout: timeout]]
) do
{:ok, resp = %{body: %{"error" => _error, "reason" => _reason}}} ->
{:error,
%Sofa.Response{
body: resp.body,
url: resp.url,
query: resp.query,
method: resp.method,
headers: Sofa.Cushion.untaint_headers(resp.headers),
status: resp.status
}}
{:ok, %{} = resp} ->
{:ok, sofa,
%Sofa.Response{
body: resp.body,
url: resp.url,
query: resp.query,
method: resp.method,
headers: Sofa.Cushion.untaint_headers(resp.headers),
status: resp.status
}}
error ->
Logger.debug("unhandled error in #{method} #{path} #{inspect(error)}")
raise Sofa.Error, "unhandled error in #{method} #{path}"
end
end
@doc """
Bang! wrapper around Sofa.raw/1; raises exceptions on error.
"""
@spec raw!(
Sofa.t(),
Tesla.Env.url(),
Tesla.Env.method(),
Tesla.Env.opts(),
Tesla.Env.body()
) :: Sofa.Response.t()
def raw!(sofa = %Sofa{}, path \\ "", method \\ :get, query \\ [], body \\ %{}) do
case raw(sofa, path, method, query, body) do
{:ok, %Sofa{}, response = %Sofa.Response{}} ->
response
{:error, _reason} = error ->
raise(Sofa.Error, "unhandled error in #{method} #{path}")
Logger.debug("unhandled error in #{method} #{path} #{inspect(error)}")
end
end
end