this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+255 -17
config
lib
test
esl_hn
esl_hn_web
support
+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 """
+2 -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
+29 -4
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
meta = %{key: key, tid: tid}
···
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
meta = %{key: key, tid: tid}
···
end)
end
+
@doc """
+
Bulk write to cache
+
"""
def write_all(tid, data) do
meta = %{tid: tid}
···
end)
end
-
def prune(tid, id) do
-
:ets.delete(tid, id)
+
@doc """
+
Remove value under given key from cache
+
"""
+
def prune(tid, key) do
+
:ets.delete(tid, key)
:ok
end
-
def prune_all(tid, ids) do
-
for id <- ids, 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)
:ok
end
+
@doc """
+
Remove all cached data
+
"""
def flush(tid) do
:ets.delete_all_objects(tid)
+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
+25 -1
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", [id: id], opts) do
Story.changeset(body)
···
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}
+4
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
+4
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
+4
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
+10 -5
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
···
end
@impl true
-
def handle_in({message, opts}, state) do
-
dbg({message, opts})
-
+
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 = EslHnWeb.API.JSON.index(%{items: stories})
+
data = View.index(%{items: stories})
{:push, {:text, JSON.encode_to_iodata!(data)}, state}
end
def handle_info({:new_stories, stories}, state) do
-
data = EslHnWeb.API.JSON.index(%{items: stories})
+
data = View.index(%{items: stories})
{:push, {:text, JSON.encode_to_iodata!(data)}, state}
end
+5 -1
mix.exs
···
# 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
+4
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"},
+64 -1
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 error on non 200 response" do
+
test "returns story on 200 response" do
data = %{
id: 2137,
title: "Foo"
···
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
+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
+4
test/support/data.ex
···
defmodule EslHn.Test.Data do
+
@moduledoc """
+
Additional `StreamData` generators used during testing
+
"""
+
use ExUnitProperties
alias EslHn.Hn.Story