Commit 870477f2 authored by Frank Kumro's avatar Frank Kumro

Adds API for adding weather reports via JSON.

Also updates the landing page to display the latest weather report
by inserted_at date. A database index is added to the inserted_at
field to ensure when records grow, we are nice to the DB.

In order to POST new weather data, both the weather station (Lake Effect)
and this application need to hold the same API Key.

Includes tests for the page controller and auth plug.
parent b486737a
Pipeline #21107995 (#) passed with stage
in 1 minute and 57 seconds
# Used by "mix format"
[
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
......@@ -12,4 +12,6 @@
erl_crash.dump
/.elixir_ls
/.vscode
\ No newline at end of file
/.vscode
source.sh
\ No newline at end of file
......@@ -39,6 +39,18 @@ mix deps.get
exit
```
### Setup env.
The `API_KEY` environment variable must be set on all environments that will
be running the Thunder Snow server. The value must also match the value set
for the Lake Effect weather station.
You can use openssl to generate a random string.
```bash
openssl rand -base64 32
```
### Start application
We want to access the app from
......
......@@ -6,22 +6,22 @@
use Mix.Config
# General application configuration
config :thunder_snow,
ecto_repos: [ThunderSnow.Repo]
config :thunder_snow, ecto_repos: [ThunderSnow.Repo]
# Configures the endpoint
config :thunder_snow, ThunderSnowWeb.Endpoint,
url: [host: "localhost"],
secret_key_base: "6W657y827NeyKPLIzUUVC4LiA5uCPePohOpqE2GQua72+KWhoAxX0u2447Y0z3ZJ",
render_errors: [view: ThunderSnowWeb.ErrorView, accepts: ~w(html json)],
pubsub: [name: ThunderSnow.PubSub,
adapter: Phoenix.PubSub.PG2]
pubsub: [name: ThunderSnow.PubSub, adapter: Phoenix.PubSub.PG2]
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:user_id]
config :thunder_snow, ThunderSnow.Plugs.Auth, api_key: System.get_env("API_KEY")
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env}.exs"
import_config "#{Mix.env()}.exs"
......@@ -58,6 +58,12 @@ config :logger, level: :info
#
# config :thunder_snow, ThunderSnowWeb.Endpoint, server: true
#
config :thunder_snow, ThunderSnowWeb.Endpoint,
http: [port: 8080],
load_from_system_env: true,
url: [host: "weather.frankkumro.com", port: 80],
cache_static_manifest: "priv/static/cache_manifest.json",
force_ssl: [rewrite_on: [:x_forwarded_proto]]
# Finally import the config/prod.secret.exs
# which should be versioned separately.
......
......@@ -8,13 +8,13 @@ use Mix.Config
# file or create a script for recreating it, since it's
# kept out of version control and might be hard to recover
# or recreate for your teammates (or yourself later on).
config :thunder_snow, ThunderSnowWeb.Endpoint,
secret_key_base: "DJPhvP10Mk4OWBpsIoSCZaFF1MDo6TqpuaXrkLqPbJmHRBuvqJ8LG3Dokh6QixQl"
config :thunder_snow, ThunderSnowWeb.Endpoint, secret_key_base: System.get_env("SECRET_KEY_BASE")
# Configure your database
config :thunder_snow, ThunderSnow.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
username: System.get_env("DATA_DB_USER"),
password: System.get_env("DATA_DB_PASS"),
database: "thunder_snow_prod",
hostname: System.get_env("DATA_DB_HOST"),
pool_size: 15
......@@ -27,3 +27,5 @@ else
hostname: System.get_env("DATA_DB_HOST"),
pool: Ecto.Adapters.SQL.Sandbox
end
config :thunder_snow, ThunderSnow.Plugs.Auth, api_key: "test"
defmodule ThunderSnow.Plugs.Auth do
@moduledoc """
Plug to control access to the application based on an HTTP Authorization
token (bearer).
If the HTTP request contains a header with the appropriate content, the
connection will be allowed to progress. Otherwise the execution will halt with
a 403 error status.
The token is configured in the environment specific configuration file, using
the following line:
config :thunder_snow, ThunderSnow.Plugs.Auth, api_key: "API_KEY_HERE"
An acceptable header will follow the bearer token authorization format:
Authorization: Bearer API_KEY_HERE
"""
import Plug.Conn
def init(default), do: default
def call(conn, _default) do
case is_weather_station?(get_auth_header(conn)) do
true -> conn
_ -> conn |> redirect_to_403()
end
end
defp redirect_to_403(conn) do
conn
|> put_resp_content_type("application/json")
|> send_resp(403, build_403_response())
|> halt()
end
defp build_403_response do
"""
{"status_code": 403, "message": "forbidden"}
"""
end
defp is_weather_station?([head | _]) do
String.equivalent?(
head,
"Bearer #{Application.get_env(:thunder_snow, __MODULE__)[:api_key]}"
)
end
defp is_weather_station?(_), do: false
defp get_auth_header(conn) do
Plug.Conn.get_req_header(conn, "authorization")
end
end
......@@ -11,7 +11,7 @@ defmodule ThunderSnow.Application do
# Start the Ecto repository
supervisor(ThunderSnow.Repo, []),
# Start the endpoint when the application starts
supervisor(ThunderSnowWeb.Endpoint, []),
supervisor(ThunderSnowWeb.Endpoint, [])
# Start your own worker by calling: ThunderSnow.Worker.start_link(arg1, arg2, arg3)
# worker(ThunderSnow.Worker, [arg1, arg2, arg3]),
]
......
defmodule ThunderSnow.Weather.Report do
use Ecto.Schema
import Ecto.Changeset
schema "reports" do
field(:temperature, :float)
field(:wind_speed, :float)
timestamps()
end
@doc false
def changeset(report, attrs) do
report
|> cast(attrs, [:wind_speed, :temperature])
|> validate_required([:wind_speed, :temperature])
end
end
defmodule ThunderSnow.Weather do
@moduledoc """
The Weather context.
"""
import Ecto.Query, warn: false
alias ThunderSnow.Repo
alias ThunderSnow.Weather.Report
@doc """
Returns the latest report.
## Examples
iex> get_latest_report()
%ThunderSnow.Weather.Report{}
"""
def get_latest_report do
Report
|> order_by(desc: :inserted_at)
|> limit(1)
|> Repo.one()
end
@doc """
Returns the list of reports.
## Examples
iex> list_reports()
[%Report{}, ...]
"""
def list_reports do
Repo.all(Report)
end
@doc """
Gets a single report.
Raises `Ecto.NoResultsError` if the Report does not exist.
## Examples
iex> get_report!(123)
%Report{}
iex> get_report!(456)
** (Ecto.NoResultsError)
"""
def get_report!(id), do: Repo.get!(Report, id)
@doc """
Creates a report.
## Examples
iex> create_report(%{field: value})
{:ok, %Report{}}
iex> create_report(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_report(attrs \\ %{}) do
%Report{}
|> Report.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a report.
## Examples
iex> update_report(report, %{field: new_value})
{:ok, %Report{}}
iex> update_report(report, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_report(%Report{} = report, attrs) do
report
|> Report.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a Report.
## Examples
iex> delete_report(report)
{:ok, %Report{}}
iex> delete_report(report)
{:error, %Ecto.Changeset{}}
"""
def delete_report(%Report{} = report) do
Repo.delete(report)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking report changes.
## Examples
iex> change_report(report)
%Ecto.Changeset{source: %Report{}}
"""
def change_report(%Report{} = report) do
Report.changeset(report, %{})
end
end
......@@ -28,8 +28,9 @@ defmodule ThunderSnowWeb do
def view do
quote do
use Phoenix.View, root: "lib/thunder_snow_web/templates",
namespace: ThunderSnowWeb
use Phoenix.View,
root: "lib/thunder_snow_web/templates",
namespace: ThunderSnowWeb
# Import convenience functions from controllers
import Phoenix.Controller, only: [get_flash: 2, view_module: 1]
......
......@@ -5,7 +5,7 @@ defmodule ThunderSnowWeb.UserSocket do
# channel "room:*", ThunderSnowWeb.RoomChannel
## Transports
transport :websocket, Phoenix.Transports.WebSocket
transport(:websocket, Phoenix.Transports.WebSocket)
# transport :longpoll, Phoenix.Transports.LongPoll
# Socket params are passed from the client and can
......
defmodule ThunderSnowWeb.FallbackController do
@moduledoc """
Translates controller action results into valid `Plug.Conn` responses.
See `Phoenix.Controller.action_fallback/1` for more details.
"""
use ThunderSnowWeb, :controller
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> render(ThunderSnowWeb.ChangesetView, "error.json", changeset: changeset)
end
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> render(ThunderSnowWeb.ErrorView, :"404")
end
end
......@@ -2,6 +2,12 @@ defmodule ThunderSnowWeb.PageController do
use ThunderSnowWeb, :controller
def index(conn, _params) do
render conn, "index.html"
weather_report =
case ThunderSnow.Weather.get_latest_report() do
report when is_map(report) -> report
nil -> %{wind_speed: "NA", temperature: "NA"}
end
render(conn, "index.html", weather_report: weather_report)
end
end
defmodule ThunderSnowWeb.ReportController do
use ThunderSnowWeb, :controller
alias ThunderSnow.Weather
alias ThunderSnow.Weather.Report
action_fallback(ThunderSnowWeb.FallbackController)
def create(conn, %{"report" => report_params}) do
with {:ok, %Report{} = report} <- Weather.create_report(report_params) do
conn
|> put_status(:created)
|> put_resp_header("location", report_path(conn, :show, report))
|> render("show.json", report: report)
end
end
def show(conn, %{"id" => id}) do
report = Weather.get_report!(id)
render(conn, "show.json", report: report)
end
def update(conn, %{"id" => id, "report" => report_params}) do
report = Weather.get_report!(id)
with {:ok, %Report{} = report} <- Weather.update_report(report, report_params) do
render(conn, "show.json", report: report)
end
end
def delete(conn, %{"id" => id}) do
report = Weather.get_report!(id)
with {:ok, %Report{}} <- Weather.delete_report(report) do
send_resp(conn, :no_content, "")
end
end
def index(conn, _) do
report = Weather.get_latest_report()
render(conn, "show.json", report: report)
end
end
defmodule ThunderSnowWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :thunder_snow
socket "/socket", ThunderSnowWeb.UserSocket
socket("/socket", ThunderSnowWeb.UserSocket)
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phoenix.digest
# when deploying your static files in production.
plug Plug.Static,
at: "/", from: :thunder_snow, gzip: false,
plug(
Plug.Static,
at: "/",
from: :thunder_snow,
gzip: false,
only: ~w(css fonts images js favicon.ico robots.txt)
)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
plug(Phoenix.LiveReloader)
plug(Phoenix.CodeReloader)
end
plug Plug.Logger
plug(Plug.Logger)
plug Plug.Parsers,
plug(
Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Poison
)
plug Plug.MethodOverride
plug Plug.Head
plug(Plug.MethodOverride)
plug(Plug.Head)
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
plug Plug.Session,
plug(
Plug.Session,
store: :cookie,
key: "_thunder_snow_key",
signing_salt: "JNG/MoBo"
)
plug ThunderSnowWeb.Router
plug(ThunderSnowWeb.Router)
@doc """
Callback invoked for dynamically configuring the endpoint.
......
......@@ -2,25 +2,28 @@ defmodule ThunderSnowWeb.Router do
use ThunderSnowWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_flash)
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
end
pipeline :api do
plug :accepts, ["json"]
plug(:accepts, ["json"])
plug(ThunderSnow.Plugs.Auth)
end
scope "/", ThunderSnowWeb do
pipe_through :browser # Use the default browser stack
# Use the default browser stack
pipe_through(:browser)
get "/", PageController, :index
get("/", PageController, :index)
end
# Other scopes may use custom stacks.
# scope "/api", ThunderSnowWeb do
# pipe_through :api
# end
scope "/api", ThunderSnowWeb do
pipe_through(:api)
resources("/reports", ReportController)
end
end
......@@ -2,13 +2,13 @@
<div class="card">
<h5 class="card-header">Wind Speed</h5>
<div class="card-body">
<h1 class="card-title display-1 text-center">12.7 MPH</h1>
<h1 class="card-title display-1 text-center"><%= @weather_report.wind_speed %> MPH</h1>
</div>
</div>
<div class="card">
<h5 class="card-header">Temperature</h5>
<div class="card-body">
<h1 class="card-title display-1 text-center">42.2 ℉</h1>
<h1 class="card-title display-1 text-center"><%= @weather_report.temperature %> ℉</h1>
</div>
</div>
</div>
\ No newline at end of file
defmodule ThunderSnowWeb.ChangesetView do
use ThunderSnowWeb, :view
@doc """
Traverses and translates changeset errors.
See `Ecto.Changeset.traverse_errors/2` and
`ThunderSnowWeb.ErrorHelpers.translate_error/1` for more details.
"""
def translate_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
end
def render("error.json", %{changeset: changeset}) do
# When encoded, the changeset returns its errors
# as a JSON object. So we just pass it forward.
%{errors: translate_errors(changeset)}
end
end
......@@ -9,8 +9,8 @@ defmodule ThunderSnowWeb.ErrorHelpers do
Generates tag for inlined form input errors.
"""
def error_tag(form, field) do
Enum.map(Keyword.get_values(form.errors, field), fn (error) ->
content_tag :span, translate_error(error), class: "help-block"
Enum.map(Keyword.get_values(form.errors, field), fn error ->
content_tag(:span, translate_error(error), class: "help-block")
end)
end
......
defmodule ThunderSnowWeb.ReportView do
use ThunderSnowWeb, :view
alias ThunderSnowWeb.ReportView
def render("index.json", %{reports: reports}) do
%{data: render_many(reports, ReportView, "report.json")}
end
def render("show.json", %{report: report}) do
%{data: render_one(report, ReportView, "report.json")}
end
def render("report.json", %{report: report}) do
%{id: report.id, wind_speed: report.wind_speed, temperature: report.temperature}
end
end
......@@ -6,9 +6,9 @@ defmodule ThunderSnow.Mixfile do
app: :thunder_snow,
version: "0.0.1",
elixir: "~> 1.4",
elixirc_paths: elixirc_paths(Mix.env),
compilers: [:phoenix, :gettext] ++ Mix.compilers,
start_permanent: Mix.env == :prod,
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps()
]
......@@ -26,7 +26,7 @@ defmodule ThunderSnow.Mixfile do
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
......@@ -40,7 +40,8 @@ defmodule ThunderSnow.Mixfile do
{:phoenix_html, "~> 2.10"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.11"},
{:cowboy, "~> 1.0"}
{:cowboy, "~> 1.0"},
{:ex_doc, "~> 0.18", only: :dev, runtime: false}
]
end
......@@ -54,7 +55,7 @@ defmodule ThunderSnow.Mixfile do
[
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
"test": ["ecto.create --quiet", "ecto.migrate", "test"]
test: ["ecto.create --quiet", "ecto.migrate", "test"]
]
end
end
......@@ -4,9 +4,12 @@
"cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"},
"db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"},
"earmark": {:hex, :earmark, "1.2.5", "4d21980d5d2862a2e13ec3c49ad9ad783ffc7ca5769cf6ff891a4553fbaae761", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "2.2.9", "031d55df9bb430cb118e6f3026a87408d9ce9638737bda3871e5d727a3594aae", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"},
"file_system": {:hex, :file_system, "0.2.4", "f0bdda195c0e46e987333e986452ec523aed21d784189144f647c43eaf307064", [:mix], [], "hexpm"},
"gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"},
"jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"},
"phoenix": {:hex, :phoenix, "1.3.2", "2a00d751f51670ea6bc3f2ba4e6eb27ecb8a2c71e7978d9cd3e5de5ccf7378bd", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_ecto": {:hex, :phoenix_ecto, "3.3.0", "702f6e164512853d29f9d20763493f2b3bcfcb44f118af2bc37bb95d0801b480", [:mix], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
......
defmodule ThunderSnow.Repo.Migrations.CreateReports do
use Ecto.Migration
def change do
create table(:reports) do
add(:wind_speed, :float)
add(:temperature, :float)
timestamps()
end
create(index(:reports, ["inserted_at DESC NULLS LAST"]))
end
end
......@@ -25,13 +25,13 @@ defmodule ThunderSnowWeb.ChannelCase do
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(ThunderSnow.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(ThunderSnow.Repo, {:shared, self()})
end
:ok
end
end
......@@ -26,13 +26,13 @@ defmodule ThunderSnowWeb.ConnCase do
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(ThunderSnow.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(ThunderSnow.Repo, {:shared, self()})
end
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(ThunderSnow.Repo, :manual)
defmodule ThunderSnow.Plugs.AuthTest do
use ThunderSnowWeb.ConnCase
use ExUnit.Case, async: true
test "403 response when auth key is incorrect" do
conn = build_conn()
response =
conn
|> put_req_header("authorization", "Bearer QWERTY")
|> ThunderSnow.Plugs.Auth.call(%{})
assert response.status == 403
end
test "403 response when auth key is missing from request" do
conn = build_conn()
response =
conn
|> ThunderSnow.Plugs.Auth.call(%{})
assert response.status == 403
end
test "conn is returned when auth key is valid" do
conn = build_conn()
response =
conn
|> put_req_header("authorization",