this repo has no description

Compare changes

Choose any two refs to compare.

+1 -1
.formatter.exs
···
[
line_length: 80,
-
import_deps: [:phoenix],
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"]
]
···
[
line_length: 80,
+
import_deps: [:phoenix, :stream_data, :ecto],
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"]
]
+5 -1
config/config.exs
···
import Config
config :esl_hn,
generators: [timestamp_type: :utc_datetime]
···
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
-
formats: [json: EslHnWeb.ErrorJSON],
layout: false
],
pubsub_server: EslHn.PubSub,
···
import Config
+
config :mime, :types, %{
+
"text/event-stream" => ["sse"]
+
}
+
config :esl_hn,
generators: [timestamp_type: :utc_datetime]
···
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
+
formats: [json: EslHnWeb.Error.JSON],
layout: false
],
pubsub_server: EslHn.PubSub,
+2
config/dev.exs
···
"TQ20YLfpm8CWUZ0wvMVvXwKLjOdxb6//anr3iafpvW15LKlsoMez2OFUTifz0gxs",
watchers: []
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
···
"TQ20YLfpm8CWUZ0wvMVvXwKLjOdxb6//anr3iafpvW15LKlsoMez2OFUTifz0gxs",
watchers: []
+
config :esl_hn, refresh: :timer.seconds(5)
+
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
+5
config/runtime.exs
···
import Config
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
···
end
if config_env() == :prod do
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
···
import Config
+
alias EslHn.ConfigReader, as: CR
+
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
···
end
if config_env() == :prod do
+
config :esl_hn,
+
refresh: CR.duration("ESL_HN_REFRESH", {:minutes, 5})
+
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
+5 -2
config/test.exs
···
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :esl_hn, EslHnWeb.Endpoint,
-
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base:
"6iR9shI35kN7Xr5bOLgBVMHXTZQS49Gwu82WW4rsr0uhaia7D+NjfNrhhvcOp4rr",
-
server: false
# Print only warnings and errors during test
config :logger, level: :warning
···
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :esl_hn, EslHnWeb.Endpoint,
+
http: [ip: {127, 0, 0, 1}, port: 0],
secret_key_base:
"6iR9shI35kN7Xr5bOLgBVMHXTZQS49Gwu82WW4rsr0uhaia7D+NjfNrhhvcOp4rr",
+
server: true
+
+
# Disable main refresher in tests
+
config :esl_hn, refresh: 0
# Print only warnings and errors during test
config :logger, level: :warning
+4
lib/esl_hn/application.ex
···
@impl true
def start(_type, _args) do
children = [
EslHnWeb.Telemetry,
{Phoenix.PubSub, name: EslHn.PubSub},
EslHnWeb.Endpoint
]
···
@impl true
def start(_type, _args) do
+
refresh = Application.fetch_env!(:esl_hn, :refresh)
+
children = [
EslHnWeb.Telemetry,
{Phoenix.PubSub, name: EslHn.PubSub},
+
{EslHn.Cache, name: EslHn},
+
{EslHn.Refresher, refresh: refresh, cache: {EslHn.Cache, EslHn}},
EslHnWeb.Endpoint
]
+120
lib/esl_hn/cache.ex
···
···
+
defmodule EslHn.Cache do
+
@moduledoc """
+
Simple cache implementation using ETS tables
+
"""
+
+
use GenServer
+
+
def start_link(opts) do
+
with {:ok, pid} <- GenServer.start_link(__MODULE__, opts) do
+
tid = GenServer.call(pid, :get_tid)
+
+
{:ok, pid, tid}
+
end
+
end
+
+
@doc """
+
Fetch data from cache
+
"""
+
def fetch(tid, key) do
+
meta = %{key: key, tid: tid}
+
+
:telemetry.span([:esl_hn, :cache, :fetch], meta, fn ->
+
case :ets.lookup(tid, key) do
+
[{^key, data}] -> {{:ok, data}, %{hit: 1}, meta}
+
_ -> {:error, %{miss: 1}, meta}
+
end
+
end)
+
end
+
+
@doc """
+
Get data from cache and returns `default` when value isn't present
+
"""
+
def get(tid, key, default \\ nil) do
+
case fetch(tid, key) do
+
{:ok, val} -> val
+
:error -> default
+
end
+
end
+
+
@doc """
+
Write `data` to cache under `key`
+
"""
+
def write(tid, key, data) do
+
meta = %{key: key, tid: tid}
+
+
:telemetry.span([:esl_hn, :cache, :write], meta, fn ->
+
true = :ets.insert(tid, {key, data})
+
+
{:ok, %{count: 1}, meta}
+
end)
+
end
+
+
@doc """
+
Bulk write to cache
+
"""
+
def write_all(tid, data) do
+
meta = %{tid: tid}
+
+
:telemetry.span([:esl_hn, :cache, :write], meta, fn ->
+
true = :ets.insert(tid, data)
+
+
{:ok, %{count: length(data)}, meta}
+
end)
+
end
+
+
@doc """
+
Remove value under given key from cache
+
"""
+
def prune(tid, key) do
+
:ets.delete(tid, key)
+
+
:ok
+
end
+
+
@doc """
+
Bulk removal of values from cache
+
"""
+
def prune_all(tid, keys) do
+
for key <- keys, do: :ets.delete(tid, key)
+
+
:ok
+
end
+
+
@doc """
+
Remove all cached data
+
"""
+
def flush(tid) do
+
:ets.delete_all_objects(tid)
+
+
:ok
+
end
+
+
@impl true
+
def init(opts) do
+
{name, opts} =
+
case Access.fetch(opts, :name) do
+
{:ok, name} -> {name, [:named_table]}
+
:error -> {nil, []}
+
end
+
+
tid =
+
:ets.new(
+
name,
+
opts ++
+
[
+
:set,
+
:public,
+
write_concurrency: :auto,
+
read_concurrency: true
+
]
+
)
+
+
{:ok, %{tid: tid}}
+
end
+
+
@impl true
+
def handle_call(:get_tid, _ref, state) do
+
{:reply, state.tid, state}
+
end
+
end
+51
lib/esl_hn/config_reader.ex
···
···
+
defmodule EslHn.ConfigReader do
+
@moduledoc """
+
Configuration helpers for reading different data from system environment
+
+
TODO: Extract that as a library as it is yet another project where I need
+
something like that
+
"""
+
+
@doc """
+
Fetch duration from environment variable `name`
+
+
The return value will be in milliseconds.
+
+
Supported formats are:
+
- `{integer}` - number of milliseconds in duration
+
- `{integer}(s|m|h)` - number of seconds|minutes|hours in duration
+
+
If environment variable is not set, then it will use `default` value.
+
"""
+
@spec duration(name :: String.t(), default :: duration) :: non_neg_integer()
+
when duration:
+
non_neg_integer()
+
| {:seconds | :minutes | :hours, non_neg_integer()}
+
def duration(name, default) do
+
case System.fetch_env(name) do
+
:error -> default
+
{:ok, ""} -> default
+
{:ok, value} -> parse_duration(value)
+
end
+
|> normalise_duration()
+
end
+
+
defp parse_duration(value) do
+
case Integer.parse(value) do
+
{value, ""} -> value
+
{value, "s"} -> {:seconds, value}
+
{value, "m"} -> {:minutes, value}
+
{value, "h"} -> {:hours, value}
+
end
+
end
+
+
defp normalise_duration(milli) when is_integer(milli), do: milli
+
+
defp normalise_duration({:seconds, s}) when is_integer(s),
+
do: :timer.seconds(s)
+
+
defp normalise_duration({:minutes, m}) when is_integer(m),
+
do: :timer.minutes(m)
+
+
defp normalise_duration({:hours, h}) when is_integer(h), do: :timer.hours(h)
+
end
+69
lib/esl_hn/hn/story.ex
···
···
+
defmodule EslHn.Hn.Story do
+
@moduledoc """
+
Schema for describing Hacker News stories
+
+
Not all fields for it are currently used.
+
"""
+
+
use Ecto.Schema
+
+
import Ecto.Changeset
+
+
@primary_key {:id, :integer, []}
+
+
@type id() :: non_neg_integer()
+
+
@type t() :: %__MODULE__{
+
id: id(),
+
url: String.t() | nil,
+
type: atom(),
+
text: String.t()
+
}
+
+
embedded_schema do
+
# TODO: Make it `URL` structure
+
field :url, :string
+
field :title, :string
+
field :by, :string
+
field :type, Ecto.Enum, values: [:job, :story, :comment, :poll, :pollopt]
+
+
# TODO: Transform it to DateTime
+
field :time, :integer
+
+
field :deleted, :boolean
+
field :text, :string
+
field :dead, :boolean
+
field :parent, :integer
+
field :kids, {:array, :integer}
+
field :score, :integer
+
+
field :descendants, :integer
+
+
# TODO: Check exact structure of this field
+
# field :poll, map()
+
# field :parts, map()
+
end
+
+
def changeset(model \\ %__MODULE__{}, attrs) do
+
model
+
|> cast(
+
attrs,
+
[
+
:id,
+
:url,
+
:title,
+
:by,
+
:type,
+
:time,
+
:deleted,
+
:text,
+
:dead,
+
:parent,
+
:kids,
+
:score,
+
:descendants
+
],
+
empty_values: []
+
)
+
end
+
end
+97
lib/esl_hn/hn.ex
···
···
+
defmodule EslHn.Hn do
+
@moduledoc """
+
Simple HTTP client for Hacker News API
+
"""
+
+
alias EslHn.Hn.Story
+
+
@type opts() :: keyword()
+
+
@doc """
+
Fetch list of top stories
+
+
## Options
+
+
- `:limit` - amount of stories to fetch (defaults to `50`)
+
"""
+
# TODO: Is there a way to limit amount of returned entries in query?
+
@spec top_stories_ids(opts()) :: {:ok, [Story.id()]} | {:error, term()}
+
def top_stories_ids(opts \\ []) do
+
limit = opts[:limit] || 50
+
+
with {:ok, list} <- get("topstories.json", opts) do
+
{:ok, Enum.take(list, limit)}
+
end
+
end
+
+
@doc """
+
Fetch single story for given ID
+
"""
+
@spec story(Story.id(), opts()) :: {:ok, Story.t()} | {:error, term()}
+
def story(id, opts \\ []) do
+
with {:ok, body} <- get("item/:id.json", [id: id], opts) do
+
Story.changeset(body)
+
|> Ecto.Changeset.apply_action(:create)
+
end
+
end
+
+
@doc """
+
Fetch stories for IDs in enumerable
+
"""
+
@spec stories(Enumerable.t(Story.id()), opts()) ::
+
{:ok, [Story.t()]} | {:error, term()}
+
def stories(ids, opts \\ []) do
+
ids
+
|> Task.async_stream(&story(&1, opts))
+
|> map_while(fn
+
{:ok, {:ok, data}} -> {:cont, data}
+
{:ok, error} -> {:halt, error}
+
{:error, reason} -> {:halt, {:error, {:process_error, reason}}}
+
end)
+
|> case do
+
list when is_list(list) -> {:ok, list}
+
error -> error
+
end
+
end
+
+
@base_url URI.new!("https://hacker-news.firebaseio.com/v0/")
+
+
defp get(path, params \\ [], opts) do
+
opts =
+
Keyword.merge(
+
[
+
base_url: @base_url,
+
path_params: params
+
],
+
opts
+
)
+
+
Req.new(opts)
+
|> ReqTelemetry.attach()
+
|> Req.get(url: path)
+
|> case do
+
{:ok, %Req.Response{status: 200, body: body}} ->
+
{:ok, body}
+
+
{:ok, %Req.Response{status: other, body: body}} ->
+
{:error, {:http_response, other, body}}
+
+
{:error, _} = error ->
+
error
+
end
+
end
+
+
defp map_while(enumerable, func) do
+
f = fn x, acc ->
+
case func.(x) do
+
{:cont, y} -> {:cont, [y | acc]}
+
{:halt, ret} -> {:halt, {:halted, ret}}
+
end
+
end
+
+
case Enum.reduce_while(enumerable, [], f) do
+
{:halted, ret} -> ret
+
list when is_list(list) -> Enum.reverse(list)
+
end
+
end
+
end
+72
lib/esl_hn/refresher.ex
···
···
+
defmodule EslHn.Refresher do
+
@moduledoc """
+
Module defining process that is responsible for refreshing cached data
+
"""
+
+
use GenServer
+
+
alias EslHn.Hn
+
+
def start_link(opts),
+
do: GenServer.start_link(__MODULE__, opts)
+
+
@impl true
+
def init(opts) do
+
refresh = Access.fetch!(opts, :refresh)
+
{mod, tid} = Access.fetch!(opts, :cache)
+
req_opts = Access.get(opts, :req_opts, [])
+
+
if refresh > 0 do
+
{:ok,
+
%{
+
refresh: refresh,
+
ids: MapSet.new(),
+
cache: {mod, tid},
+
req_opts: req_opts
+
}, {:continue, :refresh}}
+
else
+
:ignore
+
end
+
end
+
+
@impl true
+
def handle_info(:refresh, state) do
+
{:noreply, state, {:continue, :refresh}}
+
end
+
+
@impl true
+
def handle_continue(:refresh, state) do
+
ids = fetch(state)
+
+
Process.send_after(self(), :refresh, state.refresh)
+
+
{:noreply, %{state | ids: ids}}
+
end
+
+
defp fetch(state) do
+
{mod, tid} = state.cache
+
+
:telemetry.span([:esl_hn, :refresh], %{cache: mod}, fn ->
+
with {:ok, ids} <- Hn.top_stories_ids(state.req_opts),
+
new_set = MapSet.new(ids),
+
new = MapSet.difference(new_set, state.ids),
+
old = MapSet.difference(state.ids, new_set),
+
{:ok, stories} <- Hn.stories(new, state.req_opts) do
+
rows =
+
for story <- stories, do: {story.id, story}
+
+
:ok = mod.write(tid, :index, ids)
+
:ok = mod.write_all(tid, rows)
+
:ok = mod.prune_all(tid, old)
+
+
EslHn.broadcast_new(for story <- stories, story.id in new, do: story)
+
+
{new_set, %{new: MapSet.size(new), old: MapSet.size(old)},
+
%{cache: mod}}
+
else
+
_ ->
+
{state.ids, %{failed: 1}, %{cache: mod}}
+
end
+
end)
+
end
+
end
+25
lib/esl_hn.ex
···
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end
···
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
+
+
alias EslHn.Cache
+
+
def all(page \\ 1, per_page \\ 10) do
+
skip = per_page * (page - 1)
+
+
ids = Cache.get(EslHn, :index, [])
+
+
ids
+
|> Enum.drop(skip)
+
|> Enum.take(per_page)
+
|> Enum.map(&Cache.get(EslHn, &1))
+
end
+
+
def story(id) do
+
Cache.fetch(EslHn, id)
+
end
+
+
def broadcast_new(stories) do
+
Phoenix.PubSub.broadcast(EslHn.PubSub, "hn:new", {:new_stories, stories})
+
end
+
+
def subscribe_new do
+
Phoenix.PubSub.subscribe(EslHn.PubSub, "hn:new")
+
end
end
+39
lib/esl_hn_web/api/controller.ex
···
···
+
defmodule EslHnWeb.API.Controller do
+
@moduledoc """
+
Controller for serving HN top stories cache
+
"""
+
+
use EslHnWeb, :controller
+
+
def index(conn, params) do
+
page = get_page(params["page"])
+
+
render(conn, items: EslHn.all(page))
+
end
+
+
def show(conn, %{"id" => id}) do
+
with {:ok, id} <- try_int(id),
+
{:ok, story} <- EslHn.story(id) do
+
render(conn, item: story)
+
else
+
_ ->
+
conn
+
|> put_status(:not_found)
+
|> put_view(EslHnWeb.Error.JSON)
+
|> render(:"404", %{})
+
end
+
end
+
+
defp get_page(nil), do: 1
+
+
defp get_page(input) do
+
String.to_integer(input)
+
end
+
+
defp try_int(value) do
+
case Integer.parse(value) do
+
{int, ""} -> {:ok, int}
+
_ -> :error
+
end
+
end
+
end
+22
lib/esl_hn_web/api/json.ex
···
···
+
defmodule EslHnWeb.API.JSON do
+
@moduledoc """
+
Simple JSON view into Hacker New stories
+
"""
+
+
def index(attrs) do
+
Enum.map(attrs.items, &one/1)
+
end
+
+
def show(attrs) do
+
one(attrs.item)
+
end
+
+
defp one(%EslHn.Hn.Story{} = story) do
+
%{
+
id: story.id,
+
title: story.title,
+
url: story.url,
+
score: story.score
+
}
+
end
+
end
+49
lib/esl_hn_web/api/socket.ex
···
···
+
defmodule EslHnWeb.API.Socket do
+
@moduledoc """
+
WebSocket API for serving top stories news
+
"""
+
+
@behaviour Phoenix.Socket.Transport
+
+
alias EslHnWeb.API.JSON, as: View
+
+
@impl true
+
def child_spec(_opts), do: :ignore
+
+
@impl true
+
def connect(_connect_info) do
+
{:ok, []}
+
end
+
+
@impl true
+
def init(state) do
+
# No support for `handle_continue/2` so we need to hack it around
+
send(self(), :do_init)
+
EslHn.subscribe_new()
+
+
{:ok, state}
+
end
+
+
@impl true
+
def handle_in({_message, _opts}, state) do
+
# Ignore all incoming messages
+
{:ok, state}
+
end
+
+
@impl true
+
def handle_info(:do_init, state) do
+
stories = EslHn.all(1, 50)
+
data = View.index(%{items: stories})
+
+
{:push, {:text, JSON.encode_to_iodata!(data)}, state}
+
end
+
+
def handle_info({:new_stories, stories}, state) do
+
data = View.index(%{items: stories})
+
+
{:push, {:text, JSON.encode_to_iodata!(data)}, state}
+
end
+
+
@impl true
+
def terminate(_reason, _state), do: :ok
+
end
-25
lib/esl_hn_web/controllers/error_json.ex
···
-
defmodule EslHnWeb.ErrorJSON do
-
@moduledoc """
-
This module is invoked by your endpoint in case of errors on JSON requests.
-
-
See config/config.exs.
-
"""
-
-
# If you want to customize a particular status code,
-
# you may add your own clauses, such as:
-
#
-
# def render("500.json", _assigns) do
-
# %{errors: %{detail: "Internal Server Error"}}
-
# end
-
-
# By default, Phoenix returns the status message from
-
# the template name. For example, "404.json" becomes
-
# "Not Found".
-
def render(template, _assigns) do
-
%{
-
errors: %{
-
detail: Phoenix.Controller.status_message_from_template(template)
-
}
-
}
-
end
-
end
···
+2
lib/esl_hn_web/endpoint.ex
···
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
plug Plug.Static,
at: "/",
from: :esl_hn,
···
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
+
socket "/", EslHnWeb.API.Socket
+
plug Plug.Static,
at: "/",
from: :esl_hn,
+25
lib/esl_hn_web/error/json.ex
···
···
+
defmodule EslHnWeb.Error.JSON do
+
@moduledoc """
+
This module is invoked by your endpoint in case of errors on JSON requests.
+
+
See config/config.exs.
+
"""
+
+
# If you want to customize a particular status code,
+
# you may add your own clauses, such as:
+
#
+
# def render("500.json", _assigns) do
+
# %{errors: %{detail: "Internal Server Error"}}
+
# end
+
+
# By default, Phoenix returns the status message from
+
# the template name. For example, "404.json" becomes
+
# "Not Found".
+
def render(template, _assigns) do
+
%{
+
errors: %{
+
detail: Phoenix.Controller.status_message_from_template(template)
+
}
+
}
+
end
+
end
+4 -1
lib/esl_hn_web/router.ex
···
plug :accepts, ["json"]
end
-
scope "/api", EslHnWeb do
pipe_through :api
end
if Application.compile_env(:esl_hn, :dev_routes) do
···
plug :accepts, ["json"]
end
+
scope "/", EslHnWeb do
pipe_through :api
+
+
get "/", API.Controller, :index
+
get "/:id", API.Controller, :show
end
if Application.compile_env(:esl_hn, :dev_routes) do
+13
lib/esl_hn_web/telemetry.ex
···
tags: [:event],
unit: {:native, :millisecond}
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
···
tags: [:event],
unit: {:native, :millisecond}
),
+
summary("esl_hn.refresh.stop.duration",
+
unit: {:native, :millisecond}
+
),
+
sum("esl_hn.refresh.stop.new"),
+
sum("esl_hn.refresh.stop.old"),
+
counter("esl_hn.refresh.stop.failed"),
+
sum("esl_hn.cache.fetch.stop.hit"),
+
sum("esl_hn.cache.fetch.stop.miss"),
+
sum("esl_hn.cache.write.stop.count"),
+
summary("req.request.pipeline.stop.duration",
+
tags: [:url],
+
unit: {:native, :millisecond}
+
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
+21 -2
mix.exs
···
def application do
[
mod: {EslHn.Application, []},
-
extra_applications: [:logger, :runtime_tools]
]
end
···
defp deps do
[
{:phoenix, "~> 1.7.21"},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
-
{:bandit, "~> 1.5"}
]
end
···
def application do
[
mod: {EslHn.Application, []},
+
extra_applications: [:logger, :runtime_tools, :os_mon]
]
end
···
defp deps do
[
{:phoenix, "~> 1.7.21"},
+
{:bandit, "~> 1.5"},
+
+
# HackerNews client
+
{:req, "~> 0.5.15"},
+
{:req_telemetry,
+
github: "hauleth/req_telemetry", ref: "template-paths-as-metadata"},
+
{:ecto, "~> 3.13"},
+
+
# Monitoring
{:phoenix_live_dashboard, "~> 0.8.3"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
+
+
# Linting
+
{:credo, ">= 0.0.0", only: [:dev]},
+
{:dialyxir, ">= 0.0.0", only: [:dev]},
+
+
# Testing
+
{:test_server, "~> 0.1.21", only: [:test]},
+
{:stream_data, "~> 1.0", only: [:dev, :test]},
+
# Websocket client for testing custom socket transport, I have no idea how
+
# ti can test it better
+
{:fresh, "~> 0.4.4", only: [:test]}
]
end
+18 -1
mix.lock
···
%{
"bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"},
"castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
-
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"},
"phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
···
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
}
···
%{
"bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"},
+
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
+
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
+
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
+
"dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"},
+
"ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"},
+
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
+
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
+
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
+
"fresh": {:hex, :fresh, "0.4.4", "9d67a1d97112e70f4dfabd63b40e4b182ef64dfa84a2d9ee175eb4e34591e9f7", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mint, "~> 1.5", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}], "hexpm", "ba21d3fa0aa77bf18ca397e4c851de7432bb3f9c170a1645a16e09e4bba54315"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
+
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
+
"mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"},
+
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
+
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"},
"phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
···
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
+
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
+
"req_telemetry": {:git, "https://github.com/hauleth/req_telemetry.git", "449ddbc2f409fb2ea9efe4ad2a2b229cd01aa153", [ref: "template-paths-as-metadata"]},
+
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
+
"test_server": {:hex, :test_server, "0.1.21", "a22e3f5bc30278c4b9bf53f6721dc951e18e6e10ce5505481dc36ba774fc944c", [:mix], [{:bandit, ">= 1.4.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 2.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:x509, "~> 0.6", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "3b704bd0bbba389106ca3331a4a75d5e6f6142f4dc4cb20d91f960b932bf0523"},
"thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
+
"x509": {:hex, :x509, "0.9.2", "a75aa605348abd905990f3d2dc1b155fcde4e030fa2f90c4a91534405dce0f6e", [:mix], [], "hexpm", "4c5ede75697e565d4b0f5be04c3b71bb1fd3a090ea243af4bd7dae144e48cfc7"},
}
+82
test/esl_hn/cache_test.exs
···
···
+
defmodule EslHn.CacheTest do
+
use ExUnit.Case, async: true
+
use ExUnitProperties
+
+
@subject EslHn.Cache
+
+
doctest @subject
+
+
describe "unnamed cache" do
+
property "we can read written data" do
+
assert {:ok, _pid, tid} = start_supervised(@subject)
+
+
check all(key <- term(), data <- term()) do
+
assert :ok == @subject.write(tid, key, data)
+
assert {:ok, data} == @subject.fetch(tid, key)
+
end
+
end
+
+
test "fetching non existent key result in error" do
+
assert {:ok, _pid, tid} = start_supervised(@subject)
+
+
assert :error == @subject.fetch(tid, :non_existent)
+
end
+
end
+
+
describe "named cache" do
+
property "we can read written data", ctx do
+
name = ctx.test
+
assert {:ok, _pid, _tid} = start_supervised({@subject, name: name})
+
+
check all(key <- term(), data <- term()) do
+
assert :ok == @subject.write(name, key, data)
+
assert {:ok, data} == @subject.fetch(name, key)
+
end
+
end
+
end
+
+
describe "get/3" do
+
test "default value for non-existent keys is `nil`" do
+
assert {:ok, _pid, tid} = start_supervised(@subject)
+
+
assert nil == @subject.get(tid, :non_existent)
+
end
+
+
property "fetching non-existent data returns default value" do
+
assert {:ok, _pid, tid} = start_supervised(@subject)
+
+
check all(key <- term(), data <- term()) do
+
assert data == @subject.get(tid, key, data)
+
end
+
end
+
+
property "fetching existing data returns that data" do
+
assert {:ok, _pid, tid} = start_supervised(@subject)
+
+
check all(key <- term(), data <- term()) do
+
assert :ok == @subject.write(tid, key, data)
+
+
assert data == @subject.get(tid, key)
+
end
+
end
+
end
+
+
describe "prune/2" do
+
test "removing non-existent data is no-op" do
+
assert {:ok, _pid, tid} = start_supervised(@subject)
+
+
assert :ok == @subject.prune(tid, :non_existent)
+
end
+
+
property "we can read written data" do
+
assert {:ok, _pid, tid} = start_supervised(@subject)
+
+
check all(key <- term(), data <- term()) do
+
assert :ok == @subject.write(tid, key, data)
+
assert {:ok, data} == @subject.fetch(tid, key)
+
assert :ok == @subject.prune(tid, key)
+
assert :error == @subject.fetch(tid, key)
+
end
+
end
+
end
+
end
+49
test/esl_hn/config_reader_test.exs
···
···
+
defmodule EslHn.ConfigReaderTest do
+
# Theoretically as we use system environment this shouldn't be async-safe,
+
# but due to fact that all these functions use unique environment variable
+
# name, and that name has little chance to being used I thing we can live with
+
# that and use that code in given way.
+
use ExUnit.Case, async: true
+
+
@subject EslHn.ConfigReader
+
+
doctest @subject
+
+
setup ctx do
+
env = to_string(ctx.test)
+
+
on_exit(fn ->
+
System.delete_env(env)
+
end)
+
+
{:ok, env: env}
+
end
+
+
describe "duration/2" do
+
test "if environment variable is not set, then return default", ctx do
+
assert 2137 == @subject.duration(ctx.env, 2137)
+
end
+
+
test "if environment variable is empty, then default is returned", ctx do
+
System.put_env(ctx.env, "")
+
+
assert 2137 == @subject.duration(ctx.env, 2137)
+
end
+
+
test "if environment variable contains integer it will be parsed", ctx do
+
System.put_env(ctx.env, "420")
+
+
assert 420 == @subject.duration(ctx.env, 2137)
+
end
+
+
for {suffix, name} <- [s: :seconds, m: :minutes, h: :hours] do
+
test "if environment variable contains integer with `#{suffix}` suffix it will be treated as #{name}",
+
ctx do
+
System.put_env(ctx.env, unquote("420#{suffix}"))
+
+
assert apply(:timer, unquote(name), [420]) ==
+
@subject.duration(ctx.env, 2137)
+
end
+
end
+
end
+
end
+117
test/esl_hn/hn_test.exs
···
···
+
defmodule EslHn.HnTest do
+
use ExUnit.Case, async: true
+
+
alias Plug.Conn
+
+
@subject EslHn.Hn
+
+
doctest @subject
+
+
describe "top_stories_ids/1" do
+
test "on success returns list of IDs" do
+
list = [1, 2, 3, 4]
+
+
TestServer.add("/topstories.json",
+
to: fn conn ->
+
Conn.send_resp(conn, 200, JSON.encode!(list))
+
end
+
)
+
+
assert {:ok, list} == @subject.top_stories_ids(base_url: TestServer.url())
+
end
+
+
test "returns error on non 200 response" do
+
list = [1, 2, 3, 4]
+
+
TestServer.add("/topstories.json",
+
to: fn conn ->
+
Conn.send_resp(conn, :im_a_teapot, JSON.encode!(list))
+
end
+
)
+
+
assert {:error, {:http_response, 418, _}} =
+
@subject.top_stories_ids(base_url: TestServer.url())
+
end
+
+
test "returns error in case of network error" do
+
assert {:error, _} =
+
@subject.top_stories_ids(
+
adapter: fn request ->
+
{request, %RuntimeError{message: "oops"}}
+
end
+
)
+
end
+
end
+
+
describe "story/2" do
+
test "returns story on 200 response" do
+
data = %{
+
id: 2137,
+
title: "Foo"
+
}
+
+
TestServer.add("/item/2137.json",
+
to: fn conn ->
+
Phoenix.Controller.json(conn, data)
+
end
+
)
+
+
assert {:ok, %{title: "Foo"}} =
+
@subject.story(2137, base_url: TestServer.url())
+
end
+
+
test "returns error in case of network error" do
+
assert {:error, _} =
+
@subject.story(2137,
+
adapter: fn request ->
+
{request, %RuntimeError{message: "oops"}}
+
end
+
)
+
end
+
end
+
+
describe "stories/2" do
+
test "returns stories on 200 response" do
+
TestServer.add("/item/2137.json",
+
to: fn conn ->
+
Phoenix.Controller.json(conn, %{
+
id: 2137,
+
title: "Foo"
+
})
+
end
+
)
+
+
TestServer.add("/item/666.json",
+
to: fn conn ->
+
Phoenix.Controller.json(conn, %{
+
id: 666,
+
title: "Bar"
+
})
+
end
+
)
+
+
assert {:ok, [_, _]} =
+
@subject.stories([2137, 666], base_url: TestServer.url())
+
end
+
+
test "returns error if at least one story returns error" do
+
TestServer.add("/item/2137.json",
+
to: fn conn ->
+
Phoenix.Controller.json(conn, %{
+
id: 2137,
+
title: "Foo"
+
})
+
end
+
)
+
+
TestServer.add("/item/666.json",
+
to: fn conn ->
+
Conn.send_resp(conn, 404, "")
+
end
+
)
+
+
assert {:error, _} =
+
@subject.stories([2137, 666], base_url: TestServer.url())
+
end
+
end
+
end
+233
test/esl_hn/refresher_test.exs
···
···
+
defmodule EslHn.RefresherTest do
+
use ExUnit.Case, async: true
+
+
@subject EslHn.Refresher
+
+
doctest @subject
+
+
defmodule CacheMock do
+
def write(pid, key, data) do
+
send(pid, {:write, key, data})
+
:ok
+
end
+
+
def write_all(pid, data) do
+
send(pid, {:write_all, data})
+
+
:ok
+
end
+
+
def prune_all(pid, keys) do
+
send(pid, {:prune_all, Enum.to_list(keys)})
+
+
:ok
+
end
+
end
+
+
test "sends request to fetch top stories on startup" do
+
this = self()
+
+
TestServer.add("/topstories.json",
+
to: fn conn ->
+
send(this, {:http, :topstories})
+
Phoenix.Controller.json(conn, [])
+
end
+
)
+
+
start_subject()
+
+
assert_receive {:http, :topstories}
+
end
+
+
test "sends requests in periodic manner" do
+
this = self()
+
+
TestServer.add("/topstories.json",
+
to: fn conn ->
+
send(this, {:http, :topstories, 1})
+
Phoenix.Controller.json(conn, [])
+
end
+
)
+
+
TestServer.add("/topstories.json",
+
to: fn conn ->
+
send(this, {:http, :topstories, 2})
+
Phoenix.Controller.json(conn, [])
+
end
+
)
+
+
start_subject(refresh: 10)
+
+
assert_receive {:http, :topstories, 1}
+
assert_receive {:http, :topstories, 2}
+
end
+
+
test "top stories list is stored in `:index` key" do
+
TestServer.add("/topstories.json",
+
to: fn conn -> Phoenix.Controller.json(conn, []) end
+
)
+
+
start_subject()
+
+
assert_receive {:write, :index, []}
+
end
+
+
test "stories in response are fetched" do
+
this = self()
+
+
TestServer.add("/topstories.json",
+
to: fn conn -> Phoenix.Controller.json(conn, [1]) end
+
)
+
+
TestServer.add("/item/1.json",
+
to: fn conn ->
+
send(this, {:http, :item, 1})
+
Phoenix.Controller.json(conn, %{title: "Foo"})
+
end
+
)
+
+
start_subject()
+
+
assert_receive {:write, :index, [1]}
+
assert_receive {:http, :item, 1}
+
assert_receive {:write_all, [{_, story}]}
+
assert story.title == "Foo"
+
end
+
+
test "new stories are fetched" do
+
this = self()
+
+
TestServer.add("/topstories.json",
+
to: fn conn -> Phoenix.Controller.json(conn, [1]) end
+
)
+
+
TestServer.add("/item/1.json",
+
to: fn conn ->
+
send(this, {:http, :item, 1})
+
Phoenix.Controller.json(conn, %{id: 1, title: "Foo"})
+
end
+
)
+
+
TestServer.add("/topstories.json",
+
to: fn conn -> Phoenix.Controller.json(conn, [2]) end
+
)
+
+
TestServer.add("/item/2.json",
+
to: fn conn ->
+
send(this, {:http, :item, 2})
+
Phoenix.Controller.json(conn, %{id: 2, title: "Bar"})
+
end
+
)
+
+
start_subject(refresh: 10)
+
+
assert_receive {:http, :item, 2}
+
assert_receive {:write_all, [{2, _}]}
+
end
+
+
test "broadcast new stories" do
+
this = self()
+
+
TestServer.add("/topstories.json",
+
to: fn conn -> Phoenix.Controller.json(conn, [1]) end
+
)
+
+
TestServer.add("/item/1.json",
+
to: fn conn ->
+
send(this, {:http, :item, 1})
+
Phoenix.Controller.json(conn, %{id: 1, title: "Foo"})
+
end
+
)
+
+
TestServer.add("/topstories.json",
+
to: fn conn -> Phoenix.Controller.json(conn, [2]) end
+
)
+
+
TestServer.add("/item/2.json",
+
to: fn conn ->
+
send(this, {:http, :item, 2})
+
Phoenix.Controller.json(conn, %{id: 2, title: "Bar"})
+
end
+
)
+
+
start_subject(refresh: 10)
+
+
assert_receive {:http, :item, 1}
+
+
EslHn.subscribe_new()
+
+
assert_receive {:http, :item, 2}
+
assert_receive {:new_stories, [%{id: 2}]}
+
end
+
+
test "old stories are pruned" do
+
TestServer.add("/topstories.json",
+
to: fn conn -> Phoenix.Controller.json(conn, [1]) end
+
)
+
+
TestServer.add("/item/1.json",
+
to: fn conn ->
+
Phoenix.Controller.json(conn, %{id: 1, title: "Foo"})
+
end
+
)
+
+
TestServer.add("/topstories.json",
+
to: fn conn -> Phoenix.Controller.json(conn, [2]) end
+
)
+
+
TestServer.add("/item/2.json",
+
to: fn conn ->
+
Phoenix.Controller.json(conn, %{id: 2, title: "Bar"})
+
end
+
)
+
+
start_subject(refresh: 10)
+
+
assert_receive {:prune_all, [1]}
+
end
+
+
test "on error response, process is still alive" do
+
TestServer.add("/topstories.json",
+
to: fn conn ->
+
Plug.Conn.send_resp(conn, 404, "")
+
end
+
)
+
+
pid = start_subject()
+
+
ref = Process.monitor(pid)
+
+
refute_receive {:DOWN, ^ref, :process, ^pid, _}
+
end
+
+
test "on error process is still alive" do
+
TestServer.start()
+
+
pid =
+
start_subject(
+
req_opts: [
+
base_url: "http://example.invalid"
+
]
+
)
+
+
ref = Process.monitor(pid)
+
+
refute_receive {:DOWN, ^ref, :process, ^pid, _}
+
end
+
+
defp start_subject(opts \\ []) do
+
this = self()
+
+
opts =
+
Keyword.merge(
+
[
+
refresh: 5000,
+
cache: {CacheMock, this},
+
req_opts: [base_url: TestServer.url()]
+
],
+
opts
+
)
+
+
start_supervised!({@subject, opts})
+
end
+
end
+120
test/esl_hn_web/api/controller_test.exs
···
···
+
defmodule EslHnWeb.Api.ControllerTest do
+
use EslHnWeb.ConnCase, async: false
+
use ExUnitProperties
+
+
import EslHn.Test.Data
+
+
alias EslHn.Cache
+
+
setup do
+
on_exit(fn -> Cache.flush(EslHn) end)
+
end
+
+
describe "GET /" do
+
test "with empty cache returns empty list", %{conn: conn} do
+
resp =
+
conn
+
|> get(~p"/")
+
|> json_response(200)
+
+
assert resp == []
+
end
+
+
property "with some stories in index these are fetched", %{conn: conn} do
+
check all(stories <- list_of(story(), max_length: 10)) do
+
ids = Enum.map(stories, & &1.id)
+
Cache.write(EslHn, :index, ids)
+
+
Cache.write_all(EslHn, Enum.map(stories, &{&1.id, &1}))
+
+
resp_ids =
+
conn
+
|> get(~p"/")
+
|> json_response(200)
+
|> Enum.map(& &1["id"])
+
+
assert Enum.all?(ids, &(&1 in resp_ids))
+
end
+
end
+
+
property "return at most 10 elements at once", %{conn: conn} do
+
check all(stories <- list_of(story())) do
+
ids = Enum.map(stories, & &1.id)
+
Cache.write(EslHn, :index, ids)
+
+
Cache.write_all(EslHn, Enum.map(stories, &{&1.id, &1}))
+
+
resp =
+
conn
+
|> get(~p"/")
+
|> json_response(200)
+
+
assert length(resp) <= 10
+
end
+
end
+
+
property "if more than 10 elements there are stories on second page", %{
+
conn: conn
+
} do
+
check all(stories <- list_of(story(), min_length: 11)) do
+
ids = Enum.map(stories, & &1.id)
+
Cache.write(EslHn, :index, ids)
+
+
Cache.write_all(EslHn, Enum.map(stories, &{&1.id, &1}))
+
+
resp =
+
conn
+
|> get(~p"/?page=2")
+
|> json_response(200)
+
+
assert length(resp) in 1..10
+
end
+
end
+
+
property "if requested page is outside of the possible range, returns empty list",
+
%{
+
conn: conn
+
} do
+
check all(stories <- list_of(story(), max_length: 50)) do
+
ids = Enum.map(stories, & &1.id)
+
Cache.write(EslHn, :index, ids)
+
+
Cache.write_all(EslHn, Enum.map(stories, &{&1.id, &1}))
+
+
resp =
+
conn
+
|> get(~p"/?page=2137")
+
|> json_response(200)
+
+
assert resp == []
+
end
+
end
+
end
+
+
describe "GET /:id" do
+
test "for non-existent story returns 404", %{conn: conn} do
+
conn
+
|> get(~p"/2137")
+
|> json_response(404)
+
end
+
+
test "for non-integer story ID returns 404", %{conn: conn} do
+
conn
+
|> get(~p"/foo-bar")
+
|> json_response(404)
+
end
+
+
test "return data for existing story", %{conn: conn} do
+
Cache.write_all(EslHn, [
+
{2137, %EslHn.Hn.Story{id: 2137, title: "Foo"}}
+
])
+
+
resp =
+
conn
+
|> get(~p"/2137")
+
|> json_response(200)
+
+
assert "Foo" == resp["title"]
+
end
+
end
+
end
+48
test/esl_hn_web/api/json_test.exs
···
···
+
defmodule EslHnWeb.API.JSONTest do
+
use ExUnit.Case, async: true
+
use ExUnitProperties
+
+
import EslHn.Test.Data
+
+
@subject EslHnWeb.API.JSON
+
+
doctest @subject
+
+
describe "index/1" do
+
test "for empty list renders empty list" do
+
assert [] == @subject.index(%{items: []})
+
end
+
+
property "length of encoded data is equal to items count" do
+
check all(stories <- list_of(story())) do
+
count = length(stories)
+
+
assert count == length(@subject.index(%{items: stories}))
+
end
+
end
+
+
property "all IDs are present in result" do
+
check all(stories <- list_of(story())) do
+
ids =
+
stories
+
|> Enum.map(& &1.id)
+
|> Enum.sort()
+
+
result =
+
@subject.index(%{items: stories})
+
|> Enum.map(& &1.id)
+
|> Enum.sort()
+
+
assert ids == result
+
end
+
end
+
end
+
+
describe "show/1" do
+
property "encoded title is the same as input title" do
+
check all(story <- story()) do
+
assert story.title == @subject.show(%{item: story}).title
+
end
+
end
+
end
+
end
+65
test/esl_hn_web/api/socket_test.exs
···
···
+
defmodule EslHnWeb.Api.SocketTest do
+
use ExUnit.Case, async: false
+
+
import EslHn.Test.Data
+
+
alias EslHn.Cache
+
+
setup do
+
assert {:ok, {host, port}} = EslHnWeb.Endpoint.server_info(:http)
+
+
on_exit(fn -> Cache.flush(EslHn) end)
+
+
{:ok, host: host, port: port}
+
end
+
+
defmodule Client do
+
use Fresh
+
+
def handle_in({:text, data}, {parent, counter}) do
+
send(parent, {:ws, counter, JSON.decode!(data)})
+
{:ok, {parent, counter + 1}}
+
end
+
end
+
+
test "receive initial list of stories", ctx do
+
stories = Enum.take(story(), 50)
+
+
ids = Enum.map(stories, & &1.id)
+
Cache.write(EslHn, :index, ids)
+
+
Cache.write_all(EslHn, Enum.map(stories, &{&1.id, &1}))
+
+
start_supervised!(
+
{Client, uri: "ws://localhost:#{ctx.port}/websocket", state: {self(), 0}}
+
)
+
+
assert_receive {:ws, 0, init}
+
+
recv_ids = Enum.map(init, & &1["id"])
+
+
assert Enum.all?(ids, &(&1 in recv_ids))
+
assert length(recv_ids) == length(ids)
+
end
+
+
test "broadcasted events are sent over socket", ctx do
+
start_supervised!(
+
{Client, uri: "ws://localhost:#{ctx.port}/websocket", state: {self(), 0}}
+
)
+
+
assert_receive {:ws, 0, []}
+
+
for stories <- Enum.take(StreamData.list_of(story(), min_length: 1), 10) do
+
ids = Enum.map(stories, & &1.id)
+
+
EslHn.broadcast_new(stories)
+
+
assert_receive {:ws, _, msg}
+
+
recv_ids = Enum.map(msg, & &1["id"])
+
+
assert Enum.all?(ids, &(&1 in recv_ids))
+
assert length(recv_ids) == length(ids)
+
end
+
end
+
end
-14
test/esl_hn_web/controllers/error_json_test.exs
···
-
defmodule EslHnWeb.ErrorJSONTest do
-
use EslHnWeb.ConnCase, async: true
-
-
test "renders 404" do
-
assert EslHnWeb.ErrorJSON.render("404.json", %{}) == %{
-
errors: %{detail: "Not Found"}
-
}
-
end
-
-
test "renders 500" do
-
assert EslHnWeb.ErrorJSON.render("500.json", %{}) ==
-
%{errors: %{detail: "Internal Server Error"}}
-
end
-
end
···
+14
test/esl_hn_web/error/json_test.exs
···
···
+
defmodule EslHnWeb.Error.JSONTest do
+
use EslHnWeb.ConnCase, async: true
+
+
@subject EslHnWeb.Error.JSON
+
+
test "renders 404" do
+
assert @subject.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
+
end
+
+
test "renders 500" do
+
assert @subject.render("500.json", %{}) ==
+
%{errors: %{detail: "Internal Server Error"}}
+
end
+
end
+23
test/support/data.ex
···
···
+
defmodule EslHn.Test.Data do
+
@moduledoc """
+
Additional `StreamData` generators used during testing
+
"""
+
+
use ExUnitProperties
+
+
alias EslHn.Hn.Story
+
+
def story do
+
gen all(
+
title <- string(:utf8, min_length: 1),
+
score <- positive_integer()
+
) do
+
%Story{
+
id: System.unique_integer([:positive]),
+
title: title,
+
score: score,
+
url: "https://example.com/#{URI.encode(title)}"
+
}
+
end
+
end
+
end