this repo has no description

Compare changes

Choose any two refs to compare.

+4
config/config.exs
···
import Config
+
config :mime, :types, %{
+
"text/event-stream" => ["sse"]
+
}
+
config :esl_hn,
generators: [timestamp_type: :utc_datetime]
+2
config/dev.exs
···
"TQ20YLfpm8CWUZ0wvMVvXwKLjOdxb6//anr3iafpvW15LKlsoMez2OFUTifz0gxs",
watchers: []
+
config :esl_hn, refresh: :timer.seconds(5)
+
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
+3 -3
config/runtime.exs
···
config :esl_hn, EslHnWeb.Endpoint, server: true
end
-
config :esl_hn,
-
refresh: CR.duration("ESL_HN_REFRESH", {:minutes, 5})
-
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],
+
http: [ip: {127, 0, 0, 1}, port: 0],
secret_key_base:
"6iR9shI35kN7Xr5bOLgBVMHXTZQS49Gwu82WW4rsr0uhaia7D+NjfNrhhvcOp4rr",
-
server: false
+
server: true
+
+
# Disable main refresher in tests
+
config :esl_hn, refresh: 0
# Print only warnings and errors during test
config :logger, level: :warning
+3
lib/esl_hn/application.ex
···
@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
]
+57 -10
lib/esl_hn/cache.ex
···
defmodule EslHn.Cache do
+
@moduledoc """
+
Simple cache implementation using ETS tables
+
"""
+
use GenServer
def start_link(opts) do
···
end
end
+
@doc """
+
Fetch data from cache
+
"""
def fetch(tid, key) do
-
case :ets.lookup(tid, key) do
-
[{^key, data}] -> {:ok, data}
-
_ -> :error
-
end
+
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
···
end
end
+
@doc """
+
Write `data` to cache under `key`
+
"""
def write(tid, key, data) do
-
true = :ets.insert(tid, {key, data})
+
meta = %{key: key, tid: tid}
-
:ok
+
: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
-
true = :ets.insert(tid, data)
+
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
-
def prune(tid, id), do: :ets.delete(tid, id)
+
@doc """
+
Bulk removal of values from cache
+
"""
+
def prune_all(tid, keys) do
+
for key <- keys, do: :ets.delete(tid, key)
-
def prune_all(tid, ids) do
-
for id <- ids, do: :ets.delete(tid, id)
+
:ok
+
end
+
+
@doc """
+
Remove all cached data
+
"""
+
def flush(tid) do
+
:ets.delete_all_objects(tid)
:ok
end
+15
lib/esl_hn/config_reader.ex
···
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
+15
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
+37 -5
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
···
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", 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}
-
other -> {:halt, other}
+
{:ok, error} -> {:halt, error}
+
{:error, reason} -> {:halt, {:error, {:process_error, reason}}}
end)
|> case do
list when is_list(list) -> {:ok, list}
···
@base_url URI.new!("https://hacker-news.firebaseio.com/v0/")
-
defp get(path, opts) do
-
base_url = opts[:base_url] || @base_url
+
defp get(path, params \\ [], opts) do
+
opts =
+
Keyword.merge(
+
[
+
base_url: @base_url,
+
path_params: params
+
],
+
opts
+
)
-
Req.new(base_url: base_url)
+
Req.new(opts)
+
|> ReqTelemetry.attach()
|> Req.get(url: path)
|> case do
{:ok, %Req.Response{status: 200, body: body}} ->
+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
+5 -3
lib/esl_hn.ex
···
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 = EslHn.Cache.get(EslHn, :index, [])
+
ids = Cache.get(EslHn, :index, [])
ids
|> Enum.drop(skip)
|> Enum.take(per_page)
-
|> Enum.map(&story/1)
+
|> Enum.map(&Cache.get(EslHn, &1))
end
def story(id) do
-
EslHn.Cache.get(EslHn, id)
+
Cache.fetch(EslHn, id)
end
def broadcast_new(stories) do
+22 -2
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
···
render(conn, items: EslHn.all(page))
end
-
def show(conn, %{"id" => _id}) do
-
render(conn, item: %EslHn.Hn.Story{})
+
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
+5
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
···
defp one(%EslHn.Hn.Story{} = story) do
%{
+
id: story.id,
title: story.title,
url: story.url,
score: story.score
+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
+2
lib/esl_hn_web/endpoint.ex
···
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
+
socket "/", EslHnWeb.API.Socket
+
plug Plug.Static,
at: "/",
from: :esl_hn,
+1
lib/esl_hn_web/router.ex
···
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}
),
+
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}),
+8 -2
mix.exs
···
def application do
[
mod: {EslHn.Application, []},
-
extra_applications: [:logger, :runtime_tools]
+
extra_applications: [:logger, :runtime_tools, :os_mon]
]
end
···
# HackerNews client
{:req, "~> 0.5.15"},
+
{:req_telemetry,
+
github: "hauleth/req_telemetry", ref: "template-paths-as-metadata"},
{:ecto, "~> 3.13"},
# Monitoring
···
# 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]}
+
{: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
+5
mix.lock
···
"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"},
···
"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"},
+45
test/esl_hn/cache_test.exs
···
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
+78
test/esl_hn/hn_test.exs
···
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
+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