Page MenuHomePhorge

No OneTemporary

Size
25 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/tesla.ex b/lib/tesla.ex
index d5ce859..f3c4e65 100644
--- a/lib/tesla.ex
+++ b/lib/tesla.ex
@@ -1,512 +1,512 @@
defmodule Tesla.Error do
defexception env: nil, stack: [], reason: nil
def message(%Tesla.Error{env: %{url: url, method: method}, reason: reason}) do
"#{inspect(reason)} (#{method |> to_string |> String.upcase()} #{url})"
end
end
defmodule Tesla.Env do
@moduledoc """
This module defines a `t:Tesla.Env.t/0` struct that stores all data related to request/response.
## Fields
* `:method` - method of request. Example: `:get`
* `:url` - request url. Example: `"https://www.google.com"`
* `:query` - list of query params.
Example: `[{"param", "value"}]` will be translated to `?params=value`.
Note: query params passed in url (e.g. `"/get?param=value"`) are not parsed to `query` field.
* `:headers` - list of request/response headers.
Example: `[{"content-type", "application/json"}]`.
Note: request headers are overriden by response headers when adapter is called.
* `:body` - request/response body.
Note: request body is overriden by response body when adapter is called.
* `:status` - response status. Example: `200`
* `:opts` - list of options. Example: `[adapter: [recv_timeout: 30_000]]`
"""
@type client :: Tesla.Client.t()
@type method :: :head | :get | :delete | :trace | :options | :post | :put | :patch
@type url :: binary
@type param :: binary | [{binary | atom, param}]
@type query :: [{binary | atom, param}]
@type headers :: [{binary, binary}]
@type body :: any
@type status :: integer
@type opts :: [any]
@type stack :: [{atom, atom, any} | {atom, atom} | {:fn, (t -> t)} | {:fn, (t, stack -> t)}]
@type result :: {:ok, t()} | {:error, any}
@type t :: %__MODULE__{
method: method,
query: query,
url: url,
headers: headers,
body: body,
status: status,
opts: opts,
__module__: atom,
__client__: client
}
defstruct method: nil,
url: "",
query: [],
headers: [],
body: nil,
status: nil,
opts: [],
__module__: nil,
__client__: nil
end
defmodule Tesla.Client do
- @type adapter :: atom | {atom, Tesla.Env.opts()}
- @type middleware :: atom | {atom, Tesla.Env.opts()}
+ @type adapter :: module | {atom, Tesla.Env.opts()}
+ @type middleware :: module | {atom, Tesla.Env.opts()}
@type t :: %__MODULE__{
pre: Tesla.Env.stack(),
post: Tesla.Env.stack(),
adapter: adapter | nil
}
defstruct fun: nil,
pre: [],
post: [],
adapter: nil
end
defmodule Tesla.Middleware do
@moduledoc """
The middleware specification
Middleware is an extension of basic `Tesla` functionality. It is a module that must
export `call/3` function.
## Middleware options
Options can be passed to middleware in second param of `Tesla.Builder.plug/2` macro:
plug Tesla.Middleware.BaseUrl, "https://example.com"
or inside tuple in case of dynamic middleware (`Tesla.client/1`)
Tesla.client([{Tesla.Middleware.BaseUrl, "https://example.com"}])
## Writing custom middleware
Writing custom middleware is as simple as creating a module with `call/3` function that:
* (optionally) read and/or writes request data
* calls `Tesla.run/2`
* (optionally) read and/or writes response data
`call/3` params:
* env - `Tesla.Env` struct that stores request/response data
* stack - middlewares that should be called after current one
* options - middleware options provided by user
#### Example
defmodule MyProject.InspectHeadersMiddleware do
@behaviour Tesla.Middleware
@impl true
def call(env, next, options) do
env
|> inspect_headers(options)
|> Tesla.run(next)
|> inspect_headers(options)
end
defp inspect_headers(env, options) do
IO.inspect(env.headers, options)
end
end
"""
@callback call(env :: Tesla.Env.t(), next :: Tesla.Env.stack(), options :: any) ::
Tesla.Env.result()
end
defmodule Tesla.Adapter do
@moduledoc """
The adapter specification
Adapter is a module that denormalize request data stored in `Tesla.Env` in order to make
request with lower level http client (e.g. `:httpc` or `:hackney`) and normalize response data
in order to store it back to `Tesla.Env`. It has to export `call/2` function.
## Writing custom adapter
`call/2` params:
* env - `Tesla.Env` struct that stores request/response data
* options - middleware options provided by user
#### Example
defmodule MyProject.CustomAdapter do
alias Tesla.Multipart
@behaviour Tesla.Adapter
@override_defaults [follow_redirect: false]
@impl true
def call(env, opts) do
opts = Tesla.Adapter.opts(@override_defaults, env, opts)
with {:ok, {status, headers, body}} <- request(env.method, env.body, env.headers, opts) do
{:ok, normalize_response(env, status, headers, body)}
end
end
defp request(_method, %Stream{}, _headers, _opts) do
{:error, "stream not supported by adapter"}
end
defp request(_method, %Multipart{}, _headers, _opts) do
{:error, "multipart not supported by adapter"}
end
defp request(method, body, headers, opts) do
:lower_level_http.request(method, body, denormalize_headers(headers), opts)
end
defp denormalize_headers(headers), do: ...
defp normalize_response(env, status, headers, body), do: %Tesla.Env{env | ...}
end
"""
@callback call(env :: Tesla.Env.t(), options :: any) :: Tesla.Env.result()
@doc """
Helper function that merges all adapter options
## Params:
* `defaults` (optional) - useful to override lower level http client default configuration
* `env` - `Tesla.Env` struct
* `opts` - options provided to `Tesla.Builder.adapter/2` macro
## Precedence rules:
* config from `opts` overrides config from `defaults` when same key is encountered
* config from `env` overrides config from both `defaults` and `opts` when same key is encountered
"""
@spec opts(Keyword.t(), Tesla.Env.t(), Keyword.t()) :: Keyword.t()
def opts(defaults \\ [], env, opts) do
defaults
|> Keyword.merge(opts || [])
|> Keyword.merge(env.opts[:adapter] || [])
end
end
defmodule Tesla do
use Tesla.Builder
alias Tesla.Env
require Tesla.Adapter.Httpc
@default_adapter Tesla.Adapter.Httpc
@moduledoc """
A HTTP toolkit for building API clients using middlewares
## Building API client
`use Tesla` macro will generate basic http functions (e.g. get, post) inside your module.
It supports following options:
* `:only` - builder will generate only functions included in list given in this option
* `:except` - builder won't generate functions included in list given in this option
* `:docs` - when set to false builder will won't add documentation to generated functions
#### Example
defmodule ExampleApi do
use Tesla, only: [:get], docs: false
plug Tesla.Middleware.BaseUrl, "http://api.example.com"
plug Tesla.Middleware.JSON
def fetch_data do
get("/data")
end
end
In example above `ExampleApi.fetch_data/0` is equivalent of `ExampleApi.get("/data")`
## Direct usage
It is also possible to do request directly with `Tesla` module.
Tesla.get("https://example.com")
#### Common pitfalls
Direct usage won't include any middlewares.
In following example:
defmodule ExampleApi do
use Tesla, only: [:get], docs: false
plug Tesla.Middleware.BaseUrl, "http://api.example.com"
plug Tesla.Middleware.JSON
def fetch_data do
Tesla.get("/data")
end
end
call to `ExampleApi.fetch_data/0` will fail, because request will be missing base url.
## Default adapter
By default `Tesla` is using `Tesla.Adapter.Httpc`, because `:httpc` is included in Erlang/OTP and
doen not require installation of any additional dependency. It can be changed globally with config
config :tesla, :adapter, Tesla.Adapter.Hackney
or by `Tesla.Builder.adapter/2` macro for given API client module
defmodule ExampleApi do
use Tesla
adapter Tesla.Adapter.Hackney
...
end
"""
defmacro __using__(opts \\ []) do
quote do
use Tesla.Builder, unquote(opts)
end
end
@doc false
def execute(module, client, options) do
{env, stack} = prepare(module, client, options)
run(env, stack)
end
@doc false
def execute!(module, client, options) do
{env, stack} = prepare(module, client, options)
case run(env, stack) do
{:ok, env} -> env
{:error, error} -> raise Tesla.Error, env: env, stack: stack, reason: error
end
end
defp prepare(module, %{pre: pre, post: post} = client, options) do
env = struct(Env, options ++ [__module__: module, __client__: client])
stack = pre ++ module.__middleware__ ++ post ++ [effective_adapter(module, client)]
{env, stack}
end
@doc false
def effective_adapter(module, client \\ %Tesla.Client{}) do
with nil <- client.adapter,
nil <- adapter_per_module_from_config(module),
nil <- adapter_per_module(module),
nil <- adapter_from_config() do
adapter_default()
end
end
defp adapter_per_module_from_config(module) do
case Application.get_env(:tesla, module, [])[:adapter] do
nil -> nil
{adapter, opts} -> {adapter, :call, [opts]}
adapter -> {adapter, :call, [[]]}
end
end
defp adapter_per_module(module) do
module.__adapter__
end
defp adapter_from_config do
case Application.get_env(:tesla, :adapter) do
nil -> nil
{adapter, opts} -> {adapter, :call, [opts]}
adapter -> {adapter, :call, [[]]}
end
end
defp adapter_default do
{@default_adapter, :call, [[]]}
end
def run_default_adapter(env, opts \\ []) do
apply(@default_adapter, :call, [env, opts])
end
# empty stack case is useful for reusing/testing middlewares (just pass [] as next)
def run(env, []), do: {:ok, env}
# last item in stack is adapter - skip passing rest of stack
def run(env, [{:fn, f}]), do: apply(f, [env])
def run(env, [{m, f, a}]), do: apply(m, f, [env | a])
# for all other elements pass (env, next, opts)
def run(env, [{:fn, f} | rest]), do: apply(f, [env, rest])
def run(env, [{m, f, a} | rest]), do: apply(m, f, [env, rest | a])
@doc """
Adds given key/value pair to `:opts` field in `Tesla.Env`
Useful when there's need to store additional middleware data in `Tesla.Env`
## Example
iex> %Tesla.Env{opts: []} |> Tesla.put_opt(:option, "value")
%Tesla.Env{opts: [option: "value"]}
"""
@spec put_opt(Tesla.Env.t(), atom, any) :: Tesla.Env.t()
def put_opt(env, key, value) do
Map.update!(env, :opts, &Keyword.put(&1, key, value))
end
@doc """
Returns value of header specified by `key` from `:headers` field in `Tesla.Env`
## Examples
# non existing header
iex> env = %Tesla.Env{headers: [{"server", "Cowboy"}]}
iex> Tesla.get_header(env, "some-key")
nil
# existing header
iex> env = %Tesla.Env{headers: [{"server", "Cowboy"}]}
iex> Tesla.get_header(env, "server")
"Cowboy"
# first of multiple headers with the same name
iex> env = %Tesla.Env{headers: [{"cookie", "chocolate"}, {"cookie", "biscuits"}]}
iex> Tesla.get_header(env, "cookie")
"chocolate"
"""
@spec get_header(Env.t(), binary) :: binary | nil
def get_header(%Env{headers: headers}, key) do
case List.keyfind(headers, key, 0) do
{_, value} -> value
_ -> nil
end
end
@spec get_headers(Env.t(), binary) :: [binary]
def get_headers(%Env{headers: headers}, key) do
for {k, v} <- headers, k == key, do: v
end
@spec put_header(Env.t(), binary, binary) :: Env.t()
def put_header(%Env{} = env, key, value) do
headers = List.keystore(env.headers, key, 0, {key, value})
%{env | headers: headers}
end
@spec put_headers(Env.t(), [{binary, binary}]) :: Env.t()
def put_headers(%Env{} = env, list) when is_list(list) do
%{env | headers: env.headers ++ list}
end
@spec delete_header(Env.t(), binary) :: Env.t()
def delete_header(%Env{} = env, key) do
headers = for {k, v} <- env.headers, k != key, do: {k, v}
%{env | headers: headers}
end
@spec put_body(Env.t(), Env.body()) :: Env.t()
def put_body(%Env{} = env, body), do: %{env | body: body}
@doc """
Dynamically build client from list of middlewares and/or adapter.
```
# add dynamic middleware
client = Tesla.client([{Tesla.Middleware.Headers, [{"authorization", token}]}])
Tesla.get(client, "/path")
# configure adapter in runtime
client = Tesla.client([], Tesla.Adapter.Hackney)
client = Tesla.client([], {Tesla.Adapter.Hackney, pool: :my_pool)
Tesla.get(client, "/path")
# complete module example
defmodule MyApi do
# note there is no need for `use Tesla`
@middleware [
{Tesla.Middleware.BaseUrl, "https://example.com"},
Tesla.Middleware.JSON,
Tesla.Middleware.Logger
]
@adapter Tesla.Adapter.Hackney
def new(opts) do
# do any middleware manipulation you need
middleware = [
{Tesla.Middleware.BasicAuth, username: opts[:username], password: opts[:password]}
] ++ @middleware
# allow configuring adapter in runtime
adapter = opts[:adapter] || @adapter
# use Tesla.client/2 to put it all together
Tesla.client(middleware, adapter)
end
def get_something(client, id) do
# pass client directly to Tesla.get/2
Tesla.get(client, "/something/\#{id}")
# ...
end
end
client = MyApi.new(username: "admin", password: "secret")
MyApi.get_something(client, 42)
```
"""
@since "1.2.0"
@spec client([Tesla.Client.middleware()], Tesla.Client.adapter()) :: Tesla.Client.t()
def client(middleware, adapter \\ nil), do: Tesla.Builder.client(middleware, [], adapter)
@deprecated "Use client/1 or client/2 instead"
def build_client(pre, post \\ []), do: Tesla.Builder.client(pre, post)
@deprecated "Use client/1 or client/2 instead"
def build_adapter(fun), do: Tesla.Builder.client([], [], fun)
def build_url(url, []), do: url
def build_url(url, query) do
join = if String.contains?(url, "?"), do: "&", else: "?"
url <> join <> encode_query(query)
end
defp encode_query(query) do
query
|> Enum.flat_map(&encode_pair/1)
|> URI.encode_query()
end
@doc false
def encode_pair({key, value}) when is_list(value) do
if Keyword.keyword?(value) do
Enum.flat_map(value, fn {k, v} -> encode_pair({"#{key}[#{k}]", v}) end)
else
Enum.map(value, fn e -> {"#{key}[]", e} end)
end
end
@doc false
def encode_pair({key, value}), do: [{key, value}]
end
diff --git a/lib/tesla/builder.ex b/lib/tesla/builder.ex
index 2da13dc..8a658e4 100644
--- a/lib/tesla/builder.ex
+++ b/lib/tesla/builder.ex
@@ -1,335 +1,340 @@
defmodule Tesla.Builder do
@http_verbs ~w(head get delete trace options post put patch)a
@body ~w(post put patch)a
defmacro __using__(opts \\ []) do
opts = Macro.prewalk(opts, &Macro.expand(&1, __CALLER__))
docs = Keyword.get(opts, :docs, true)
quote do
Module.register_attribute(__MODULE__, :__middleware__, accumulate: true)
Module.register_attribute(__MODULE__, :__adapter__, [])
@type option ::
{:method, Tesla.Env.method()}
| {:url, Tesla.Env.url()}
| {:query, Tesla.Env.query()}
| {:headers, Tesla.Env.headers()}
| {:body, Tesla.Env.body()}
| {:opts, Tesla.Env.opts()}
if unquote(docs) do
@doc """
Perform a request
Options:
- `:method` - the request method, one of [:head, :get, :delete, :trace, :options, :post, :put, :patch]
- `:url` - either full url e.g. "http://example.com/some/path" or just "/some/path" if using `Tesla.Middleware.BaseUrl`
- `:query` - a keyword list of query params, e.g. `[page: 1, per_page: 100]`
- `:headers` - a keyworld list of headers, e.g. `[{"content-type", "text/plain"}]`
- `:body` - depends on used middleware:
- by default it can be a binary
- if using e.g. JSON encoding middleware it can be a nested map
- if adapter supports it it can be a Stream with any of the above
- `:opts` - custom, per-request middleware or adapter options
Examples:
ExampleApi.request(method: :get, url: "/users/path")
You can also use shortcut methods like:
ExampleApi.get("/users/1")
or
ExampleApi.post(client, "/users", %{name: "Jon"})
"""
else
@doc false
end
@spec request(Tesla.Env.client(), [option]) :: Tesla.Env.result()
def request(%Tesla.Client{} = client \\ %Tesla.Client{}, options) do
Tesla.execute(__MODULE__, client, options)
end
if unquote(docs) do
@doc """
Perform request and raise in case of error.
This is similar to `request/2` behaviour from Tesla 0.x
See `request/2` for list of available options.
"""
else
@doc false
end
@spec request!(Tesla.Env.client(), [option]) :: Tesla.Env.t() | no_return
def request!(%Tesla.Client{} = client \\ %Tesla.Client{}, options) do
Tesla.execute!(__MODULE__, client, options)
end
unquote(generate_http_verbs(opts))
import Tesla.Builder, only: [plug: 1, plug: 2, adapter: 1, adapter: 2]
@before_compile Tesla.Builder
end
end
@doc """
Attach middleware to your API client
```
defmodule ExampleApi do
use Tesla
# plug middleware module with options
plug Tesla.Middleware.BaseUrl, "http://api.example.com"
# or without options
plug Tesla.Middleware.JSON
# or a custom middleware
plug MyProject.CustomMiddleware
end
"""
defmacro plug(middleware, opts) do
quote do
@__middleware__ {
{unquote(Macro.escape(middleware)), unquote(Macro.escape(opts))},
{:middleware, unquote(Macro.escape(__CALLER__))}
}
end
end
defmacro plug(middleware) do
quote do
@__middleware__ {
unquote(Macro.escape(middleware)),
{:middleware, unquote(Macro.escape(__CALLER__))}
}
end
end
@doc """
Choose adapter for your API client
```
defmodule ExampleApi do
use Tesla
# set adapter as module
adapter Tesla.Adapter.Hackney
# set adapter as anonymous function
adapter fn env ->
...
env
end
end
"""
defmacro adapter(name, opts) do
quote do
@__adapter__ {
{unquote(Macro.escape(name)), unquote(Macro.escape(opts))},
{:adapter, unquote(Macro.escape(__CALLER__))}
}
end
end
defmacro adapter(name) do
quote do
@__adapter__ {
unquote(Macro.escape(name)),
{:adapter, unquote(Macro.escape(__CALLER__))}
}
end
end
defmacro __before_compile__(env) do
Tesla.Migration.breaking_alias_in_config!(env.module)
adapter =
env.module
|> Module.get_attribute(:__adapter__)
|> compile()
middleware =
env.module
|> Module.get_attribute(:__middleware__)
|> Enum.reverse()
|> compile()
quote location: :keep do
def __middleware__, do: unquote(middleware)
def __adapter__, do: unquote(adapter)
end
end
- def client(pre, post, adapter \\ nil) do
+ def client(pre, post, adapter \\ nil)
+
+ def client(pre, post, nil) do
+ %Tesla.Client{pre: runtime(pre), post: runtime(post)}
+ end
+
+ def client(pre, post, adapter) do
%Tesla.Client{pre: runtime(pre), post: runtime(post), adapter: runtime(adapter)}
end
@default_opts []
defp compile(nil), do: nil
defp compile(list) when is_list(list), do: Enum.map(list, &compile/1)
# {Tesla.Middleware.Something, opts}
defp compile({{{:__aliases__, _, _} = ast_mod, ast_opts}, {_kind, caller}}) do
Tesla.Migration.breaking_headers_map!(ast_mod, ast_opts, caller)
quote do: {unquote(ast_mod), :call, [unquote(ast_opts)]}
end
# :local_middleware, opts
defp compile({{name, _opts}, {kind, caller}}) when is_atom(name) do
Tesla.Migration.breaking_alias!(kind, name, caller)
end
# Tesla.Middleware.Something
defp compile({{:__aliases__, _, _} = ast_mod, {_kind, _caller}}) do
quote do: {unquote(ast_mod), :call, [unquote(@default_opts)]}
end
# fn env -> ... end
defp compile({{:fn, _, _} = ast_fun, {_kind, _caller}}) do
quote do: {:fn, unquote(ast_fun)}
end
# :local_middleware
defp compile({name, {kind, caller}}) when is_atom(name) do
Tesla.Migration.breaking_alias!(kind, name, caller)
end
- defp runtime(nil), do: nil
defp runtime(list) when is_list(list), do: Enum.map(list, &runtime/1)
defp runtime({module, opts}) when is_atom(module), do: {module, :call, [opts]}
defp runtime(fun) when is_function(fun), do: {:fn, fun}
defp runtime(module) when is_atom(module), do: {module, :call, [@default_opts]}
defp generate_http_verbs(opts) do
only = Keyword.get(opts, :only, @http_verbs)
except = Keyword.get(opts, :except, [])
docs = Keyword.get(opts, :docs, true)
for method <- @http_verbs do
for bang <- [:safe, :bang],
client <- [:client, :noclient],
opts <- [:opts, :noopts],
method in only && not (method in except) do
gen(method, bang, client, opts, docs)
end
end
end
defp gen(method, safe, client, opts, docs) do
quote location: :keep do
unquote(gen_doc(method, safe, client, opts, docs))
unquote(gen_spec(method, safe, client, opts))
unquote(gen_fun(method, safe, client, opts))
unquote(gen_deprecated(method, safe, client, opts))
end
end
defp gen_doc(method, safe, :client, :opts, true) do
request = to_string(req(safe))
name = name(method, safe)
body = if method in @body, do: ~s|, %{name: "Jon"}|, else: ""
quote location: :keep do
@doc """
Perform a #{unquote(method |> to_string |> String.upcase())} request.
See `#{unquote(request)}/1` or `#{unquote(request)}/2` for options definition.
#{unquote(name)}("/users"#{unquote(body)})
#{unquote(name)}("/users"#{unquote(body)}, query: [scope: "admin"])
#{unquote(name)}(client, "/users"#{unquote(body)})
#{unquote(name)}(client, "/users"#{unquote(body)}, query: [scope: "admin"])
"""
end
end
defp gen_doc(_method, _bang, _client, _opts, _) do
quote location: :keep do
@doc false
end
end
defp gen_spec(method, safe, client, opts) do
quote location: :keep do
@spec unquote(name(method, safe))(unquote_splicing(types(method, client, opts))) ::
unquote(type(safe))
end
end
defp gen_fun(method, safe, client, opts) do
quote location: :keep do
def unquote(name(method, safe))(unquote_splicing(inputs(method, client, opts))) do
unquote(req(safe))(unquote_splicing(outputs(method, client, opts)))
end
end
|> gen_guards(opts)
end
defp gen_guards({:def, _, [head, [do: body]]}, :opts) do
quote do
def unquote(head) when is_list(opts), do: unquote(body)
end
end
defp gen_guards(def, _opts), do: def
defp gen_deprecated(method, safe, :client, opts) do
inputs = [quote(do: client) | inputs(method, :noclient, opts)]
quote location: :keep do
def unquote(name(method, safe))(unquote_splicing(inputs)) when is_function(client) do
Tesla.Migration.client_function!()
end
end
end
defp gen_deprecated(_method, _safe, _, _opts), do: nil
defp name(method, :safe), do: method
defp name(method, :bang), do: String.to_atom("#{method}!")
defp req(:safe), do: :request
defp req(:bang), do: :request!
defp types(method, client, opts), do: type(client) ++ type(:url) ++ type(method) ++ type(opts)
defp type(:safe), do: quote(do: Tesla.Env.result())
defp type(:bang), do: quote(do: Tesla.Env.t() | no_return)
defp type(:client), do: [quote(do: Tesla.Env.client())]
defp type(:noclient), do: []
defp type(:opts), do: [quote(do: [option])]
defp type(:noopts), do: []
defp type(:url), do: [quote(do: Tesla.Env.url())]
defp type(method) when method in @body, do: [quote(do: Tesla.Env.body())]
defp type(_method), do: []
defp inputs(method, client, opts),
do: input(client) ++ input(:url) ++ input(method) ++ input(opts)
defp input(:client), do: [quote(do: %Tesla.Client{} = client)]
defp input(:noclient), do: []
defp input(:opts), do: [quote(do: opts)]
defp input(:noopts), do: []
defp input(:url), do: [quote(do: url)]
defp input(method) when method in @body, do: [quote(do: body)]
defp input(_method), do: []
defp outputs(method, client, opts), do: output(client) ++ [output(output(method), opts)]
defp output(:client), do: [quote(do: client)]
defp output(:noclient), do: []
defp output(m) when m in @body, do: quote(do: [method: unquote(m), url: url, body: body])
defp output(m), do: quote(do: [method: unquote(m), url: url])
defp output(prev, :opts), do: quote(do: unquote(prev) ++ opts)
defp output(prev, :noopts), do: prev
end

File Metadata

Mime Type
text/x-diff
Expires
Fri, Jul 18, 8:01 AM (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
261456
Default Alt Text
(25 KB)

Event Timeline