Current section
Files
Jump to
Current section
Files
lib/minter/minter.ex
defmodule Minter do
@moduledoc """
Minter is the http(1/2) connection owner process
- Can request many resources on the same client by req/5!
## Links
- https://hexdocs.pm/mint/architecture.html#wrapping-a-mint-connection-in-a-genserver
"""
use GenServer
require Logger
defstruct [:conn, requests: %{}]
@default_http_port 4000
@default_https_port 4040
## User API - request with client pid
@doc """
iex> Minter.get("/", client: Minter.http2_client())
"""
def get(path, opts \\ []) do
opts
|> Keyword.put(:path, path)
|> Keyword.put(:method, "GET")
|> req()
end
def req(opts \\ []) do
{client_pid, opts} = Keyword.pop(opts, :client, client())
defaults = [
method: "GET",
path: "/",
headers: [],
body: nil
]
%{
method: method,
path: path,
headers: headers,
body: body
} =
defaults |> Keyword.merge(opts) |> Map.new()
req(client_pid, method, path, headers, body)
end
def req(pid, method, path, headers, body) do
GenServer.call(pid, {:request, method, path, headers, body})
end
@setting_names [
# :header_table_size,
# :enable_connect_protocol,
:enable_push,
:max_concurrent_streams,
:initial_window_size,
:max_frame_size,
:max_header_list_size
]
def get_settings(conn) do
cs =
Enum.map(@setting_names, fn s ->
{s, Mint.HTTP2.get_client_setting(conn, s)}
end)
ss =
Enum.map(@setting_names, fn s ->
{s, Mint.HTTP2.get_server_setting(conn, s)}
end)
%{client_settings: cs, server_settings: ss}
end
## User API - Get client process
def client(httpv \\ :http, opts \\ []) do
case httpv do
v when v in [:h2, :http2] -> http2_client(opts)
_ -> http_client(opts)
end
end
# https://hexdocs.pm/mint/Mint.HTTP.html#connect/4-options
@default_http1_opts [
log: true
]
def http_client(opts \\ @default_http1_opts) do
{name, opts} = Keyword.pop(opts, :genserver_name, Minter.HTTP)
Process.whereis(name)
|> case do
nil ->
{:ok, pid} = start_link(connect_opts: opts, name: name)
pid
pid ->
pid
end
end
@doc """
Get http2 client
"""
def http2_client(opts \\ []) do
[
log: true,
protocols: [:http2],
client_settings: [
enable_push: true
],
genserver_name: Minter.HTTP2
]
|> Keyword.merge(opts)
|> http_client()
end
def start_link(opts) when is_list(opts) do
{mint_opts, genserver_opts} = Keyword.split(opts, [:scheme, :host, :port, :connect_opts])
scheme = Keyword.get(mint_opts, :scheme, :http)
scheme = if is_binary(scheme), do: String.to_atom(scheme), else: scheme
host = Keyword.get(mint_opts, :host, "localhost")
port = Keyword.get(mint_opts, :port, default_port(scheme))
connect_opts = Keyword.get(mint_opts, :connect_opts, @default_http1_opts)
start_link({scheme, host, port, connect_opts}, genserver_opts)
end
def start_link({scheme, host, port, connet_opts}, opts \\ []) do
# App.ensure_all_started!(:mint)
GenServer.start_link(__MODULE__, {scheme, host, port, connet_opts}, opts)
end
defp default_port(:https), do: @default_https_port
defp default_port(_), do: @default_http_port
## Callbacks
@impl true
def init({scheme, host, port, opts}) do
case Mint.HTTP.connect(scheme, host, port, opts) do
{:ok, conn} ->
state = %__MODULE__{conn: conn}
{:ok, state}
{:error, reason} ->
{:stop, reason}
end
end
@impl true
def handle_call({:request, method, path, headers, body}, from, state) do
# In both the successful case and the error case, we make sure to update the connection
# struct in the state since the connection is an immutable data structure.
case Mint.HTTP.request(state.conn, method, path, headers, body) do
{:ok, conn, request_ref} ->
state = put_in(state.conn, conn)
# We store the caller this request belongs to and an empty map as the response.
# The map will be filled with status code, headers, and so on.
state = put_in(state.requests[request_ref], %{from: from, response: %{}})
{:noreply, state}
{:error, conn, reason} ->
state = put_in(state.conn, conn)
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_info({:tcp, _port, _frame} = message, state) do
# We should handle the error case here as well, but we're omitting it for brevity.
case Mint.HTTP.stream(state.conn, message) do
:unknown ->
_ = Logger.error(fn -> "Received unknown message: " <> inspect(message) end)
{:noreply, state}
{:ok, conn, responses} ->
state = put_in(state.conn, conn)
state =
Enum.reduce(responses, state, fn resp, st ->
Logger.debug("got response frame #{resp |> inspect}")
process_response(resp, st)
end)
{:noreply, state}
{:error, conn, reason, [responses]} ->
# todo
Logger.error("#{reason |> inspect}")
state = put_in(state.conn, conn)
state =
Enum.reduce(responses, state, fn resp, st ->
Logger.debug("got response frame #{resp |> inspect}")
process_response(resp, st)
end)
{:reply, reason, state}
end
end
def handle_info({:tcp_closed, _port}, state) do
IO.puts("#{self() |> inspect} tcp_closed")
{:stop, :normal, state}
end
def handle_info(_message, state) do
{:noreply, state}
end
defp process_response({:status, request_ref, status}, state) do
put_in(state.requests[request_ref].response[:status], status)
end
defp process_response({:headers, request_ref, headers}, state) do
put_in(state.requests[request_ref].response[:headers], headers)
end
defp process_response({:data, request_ref, new_data}, state) do
update_in(state.requests[request_ref].response[:data], fn data -> (data || "") <> new_data end)
end
# When the request is done, we use GenServer.reply/2 to reply to the caller that was
# blocked waiting on this request.
defp process_response({:done, request_ref}, state) do
{%{response: response, from: from}, state} = pop_in(state.requests[request_ref])
GenServer.reply(from, {:ok, response})
state
end
# A request can also error, but we're not handling the erroneous responses for
# brevity.
defp process_response({:error, request_ref, reason}, state) do
{%{response: response, from: from}, state} = pop_in(state.requests[request_ref])
Logger.error({:error, reason, response, from} |> inspect)
# todo check this
GenServer.reply(from, {:error, reason})
state
end
# http2 specific
defp process_response({:pong, _request_ref}, state) do
Logger.info("pong recevied!")
state
end
# {:push_promise, request_ref, promised_request_ref, headers}
end