commit 7ff70d452abaa9b62a80bd4df6042cabe3d5e14b Author: mitchell Date: Sat Dec 7 21:34:58 2019 -0500 Project architecture boilerplate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36ad98f --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +service-*.tar + +# Ignore elixir-ls artifacts +/.elixir_ls/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed17cfc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM elixir:1.9-slim as build + +WORKDIR /root/shortnr/service +COPY . . + +RUN mix local.hex --force +RUN mix local.rebar --force + +RUN env MIX_ENV=prod mix release + +FROM debian:buster-20191014-slim + +WORKDIR /home/shortnr +COPY --from=build /root/shortnr/service/_build/prod/rel/service/ . + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libtinfo5=6.1+20181013-2+deb10u2 \ + openssl=1.1.1d-0+deb10u2 \ + locales=2.28-10 \ + && rm -rf /var/lib/apt/lists/* + +RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ + locale-gen +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 + +RUN groupadd -r shortnr && useradd --no-log-init -r -g shortnr shortnr +RUN chown -R shortnr:shortnr /home/shortnr +USER shortnr + +ENTRYPOINT ["bin/service", "start"] +EXPOSE 8080 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..91ef65e --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: all build clean install start test + +build: install test clean + docker build -t shortnr:latest . + +clean: + mix clean --deps + +install: + mix deps.get + +install-prod: + mix deps.get --only prod + +start: + iex -S mix run --no-halt + +test: + mix test diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..984e20c --- /dev/null +++ b/config/config.exs @@ -0,0 +1,8 @@ +import Config + +config :service, + port: 8080 + +config :logger, :console, + format: "date=$date time=$time level=$level$levelpad message=\"$message\" $metadata\n", + metadata: [:port, :file, :line, :crash_reason, :stack] diff --git a/lib/router.ex b/lib/router.ex new file mode 100644 index 0000000..9cd7024 --- /dev/null +++ b/lib/router.ex @@ -0,0 +1,35 @@ +defmodule Shortnr.Router do + use Plug.ErrorHandler + use Plug.Router + + require Logger + + alias Shortnr.Transport.{Json, Http} + alias Shortnr.Url + + plug(Plug.Logger, log: :debug) + plug(:match) + plug(:dispatch) + + post "/urls" do + conn + |> Json.decode_request(Url.CreateRequest) + |> Http.handle(&Url.create(&1, Url.Repo.DETS)) + |> Json.encode_response() + |> Http.send(:created, conn) + end + + match _ do + {:error, {:not_found, "route not found"}, conn} + |> Json.encode_response() + |> Http.send(:ignore, conn) + end + + def handle_errors(conn, %{kind: _kind, reason: reason, stack: stack}) do + Logger.error(inspect(reason), stack: stack) + + {:error, {:internal_server_error, "internal server error"}, conn} + |> Json.encode_response() + |> Http.send(:ignore, conn) + end +end diff --git a/lib/shortnr.ex b/lib/shortnr.ex new file mode 100644 index 0000000..54404a7 --- /dev/null +++ b/lib/shortnr.ex @@ -0,0 +1,28 @@ +defmodule Shortnr do + @moduledoc """ + Documentation for Shortnr. + """ + + use Application + require Logger + + @impl true + def start(_type, _args) do + port = port() + + children = [ + {Plug.Cowboy, scheme: :http, plug: Shortnr.Router, options: [port: port]} + ] + + Logger.info("server starting", port: port) + Supervisor.start_link(children, strategy: :one_for_one) + end + + @spec port() :: integer() + defp port do + case Application.fetch_env(:service, :port) do + {:ok, port} -> port + _ -> 4000 + end + end +end diff --git a/lib/transport/http.ex b/lib/transport/http.ex new file mode 100644 index 0000000..383259e --- /dev/null +++ b/lib/transport/http.ex @@ -0,0 +1,36 @@ +defmodule Shortnr.Transport.Http do + import Plug.Conn + + @type ok_error :: {:ok, term(), Plug.Conn.t()} | error() + @type error :: {:error, {atom(), String.t()}, Plug.Conn.t()} + + @spec handle(ok_error(), (term() -> ok_error())) :: ok_error() + def handle(error = {:error, _sub_error, _conn}, _func), do: error + + def handle({:ok, request, conn}, func) do + case func.(request) do + {:ok, response} -> {:ok, response, conn} + {:error, error_value} -> convert_error(error_value, conn) + end + end + + defp convert_error({:invalid_argument, message}, conn), + do: {:error, {:bad_request, message}, conn} + + defp convert_error(_, conn), do: {:error, {:internal_server_error, "unknown error"}, conn} + + @spec send(ok_error(), atom(), Plug.Conn.t()) :: Plug.Conn.t() + def send({:error, {status, body}, _conn}, _success_status, original_conn) do + send_resp(original_conn, status_to_code(status), body) + end + + def send({:ok, body, conn}, success_status, _original_conn) do + send_resp(conn, status_to_code(success_status), body) + end + + defp status_to_code(:ok), do: 200 + defp status_to_code(:created), do: 201 + defp status_to_code(:bad_request), do: 400 + defp status_to_code(:not_found), do: 404 + defp status_to_code(:internal_server_error), do: 500 +end diff --git a/lib/transport/json.ex b/lib/transport/json.ex new file mode 100644 index 0000000..e8aeed0 --- /dev/null +++ b/lib/transport/json.ex @@ -0,0 +1,28 @@ +defmodule Shortnr.Transport.Json do + import Plug.Conn + alias Shortnr.Transport.Http + + @spec decode_request(Plug.Conn.t(), module()) :: Http.ok_error() + def decode_request(conn, struct_module) do + {:ok, body, conn} = read_body(conn) + {:ok, params} = Jason.decode(body) + + params_list = + params + |> Map.to_list() + |> Enum.map(fn {key, value} -> {String.to_atom(key), value} end) + + {:ok, struct(struct_module, params_list), conn} + end + + @spec encode_response(Http.ok_error()) :: Http.ok_error() + def encode_response({:error, {status, response}, conn}) do + {:ok, json_body} = Jason.encode(%{data: response}) + {:error, {status, json_body}, conn} + end + + def encode_response({:ok, response, conn}) do + {:ok, json_body} = Jason.encode(%{data: response}) + {:ok, json_body, conn} + end +end diff --git a/lib/transport/transport.ex b/lib/transport/transport.ex new file mode 100644 index 0000000..c890166 --- /dev/null +++ b/lib/transport/transport.ex @@ -0,0 +1,4 @@ +defmodule Shortnr.Transport do + @type error :: {:error, {atom(), String.t()}} + @type ok_error :: {:ok, term()} | error() +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..1e55f95 --- /dev/null +++ b/mix.exs @@ -0,0 +1,32 @@ +defmodule Shortnr.MixProject do + use Mix.Project + + def project do + [ + app: :service, + version: "0.1.0", + elixir: "~> 1.9", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + mod: {Shortnr, []}, + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:plug_cowboy, "~> 2.0"}, + {:elixir_uuid, "~> 1.2"}, + {:jason, "~> 1.1"} + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..5daf03f --- /dev/null +++ b/mix.lock @@ -0,0 +1,11 @@ +%{ + "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, + "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm"}, + "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm"}, + "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, + "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, + "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, + "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, +} diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()