├── lib ├── .DS_Store ├── real_deal_api │ ├── repo.ex │ ├── users │ │ └── user.ex │ ├── accounts │ │ └── account.ex │ ├── application.ex │ ├── users.ex │ └── accounts.ex ├── real_deal_api_web │ ├── controllers │ │ ├── default_controller.ex │ │ ├── fallback_controller.ex │ │ ├── user_controller.ex │ │ └── account_controller.ex │ ├── auth │ │ ├── guardian_error_handler.ex │ │ ├── pipeline.ex │ │ ├── error_response.ex │ │ ├── authorized_plug.ex │ │ ├── set_account.ex │ │ └── guardian.ex │ ├── views │ │ ├── user_view.ex │ │ ├── error_view.ex │ │ ├── changeset_view.ex │ │ ├── account_view.ex │ │ └── error_helpers.ex │ ├── gettext.ex │ ├── router.ex │ ├── endpoint.ex │ └── telemetry.ex ├── real_deal_api.ex └── real_deal_api_web.ex ├── priv ├── .DS_Store ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20221129192316_create_accounts.exs │ │ ├── 20230131180245_guardiandb.exs │ │ └── 20221129192803_create_users.exs │ └── seeds.exs └── gettext │ ├── errors.pot │ └── en │ └── LC_MESSAGES │ └── errors.po ├── test ├── test_helper.exs ├── support │ ├── factory.ex │ ├── data_case.ex │ └── schema_case.ex └── real_deal_api │ ├── schema │ ├── user_test.exs │ └── account_test.exs │ └── accounts_test.exs ├── .formatter.exs ├── .gitignore ├── LICENSE ├── config ├── test.exs ├── config.exs ├── prod.exs ├── dev.exs └── runtime.exs ├── README.md ├── mix.exs └── mix.lock /lib/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElixirMentor/real_deal_api/HEAD/lib/.DS_Store -------------------------------------------------------------------------------- /priv/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElixirMentor/real_deal_api/HEAD/priv/.DS_Store -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(RealDealApi.Repo, :manual) 3 | -------------------------------------------------------------------------------- /lib/real_deal_api/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Repo do 2 | use Ecto.Repo, 3 | otp_app: :real_deal_api, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/controllers/default_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.DefaultController do 2 | use RealDealApiWeb, :controller 3 | 4 | def index(conn, _params) do 5 | text conn, "The Real Deal API is LIVE - #{Mix.env()}" 6 | end 7 | 8 | end 9 | -------------------------------------------------------------------------------- /lib/real_deal_api.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi do 2 | @moduledoc """ 3 | RealDealApi keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Support.Factory do 2 | use ExMachina.Ecto, repo: RealDealApi.Repo 3 | alias RealDealApi.Accounts.Account 4 | 5 | def account_factory do 6 | %Account{ 7 | email: Faker.Internet.email(), 8 | hash_password: Faker.Internet.slug() 9 | } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/auth/guardian_error_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.Auth.GuardianErrorHandler do 2 | import Plug.Conn 3 | 4 | def auth_error(conn, {type, _reason}, _opts) do 5 | body = Jason.encode!(%{error: to_string(type)}) 6 | conn 7 | |> put_resp_content_type("application/json") 8 | |> send_resp(401, body) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Support.DataCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using do 5 | quote do 6 | alias Ecto.Changeset 7 | import RealDealApi.Support.DataCase 8 | alias RealDealApi.{Support.Factory, Repo} 9 | end 10 | end 11 | 12 | setup _ do 13 | Ecto.Adapters.SQL.Sandbox.mode(RealDealApi.Repo, :manual) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/auth/pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.Auth.Pipeline do 2 | use Guardian.Plug.Pipeline, otp_app: :real_deal_api, 3 | module: RealDealApiWeb.Auth.Guardian, 4 | error_handler: RealDealApiWeb.Auth.GuardianErrorHandler 5 | 6 | plug Guardian.Plug.VerifySession 7 | plug Guardian.Plug.VerifyHeader 8 | plug Guardian.Plug.EnsureAuthenticated 9 | plug Guardian.Plug.LoadResource 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # RealDealApi.Repo.insert!(%RealDealApi.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20221129192316_create_accounts.exs: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Repo.Migrations.CreateAccounts do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:accounts, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :email, :string 8 | add :hash_password, :string 9 | 10 | timestamps() 11 | end 12 | 13 | create unique_index(:accounts, [:email]) 14 | 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/auth/error_response.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.Auth.ErrorResponse.Unauthorized do 2 | defexception [message: "Unauthorized", plug_status: 401] 3 | end 4 | 5 | defmodule RealDealApiWeb.Auth.ErrorResponse.Forbidden do 6 | defexception [message: "You do not have access to this resource.", plug_status: 403] 7 | end 8 | 9 | defmodule RealDealApiWeb.Auth.ErrorResponse.NotFound do 10 | defexception [message: "Not Found", plug_status: 404] 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230131180245_guardiandb.exs: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Repo.Migrations.CreateGuardianDBTokensTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:guardian_tokens, primary_key: false) do 6 | add(:jti, :string, primary_key: true) 7 | add(:aud, :string, primary_key: true) 8 | add(:typ, :string) 9 | add(:iss, :string) 10 | add(:sub, :string) 11 | add(:exp, :bigint) 12 | add(:jwt, :text) 13 | add(:claims, :map) 14 | timestamps() 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20221129192803_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :full_name, :string 8 | add :gender, :string 9 | add :biography, :text 10 | add :account_id, references(:accounts, on_delete: :delete_all, type: :binary_id) 11 | 12 | timestamps() 13 | end 14 | 15 | create index(:users, [:account_id, :full_name]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/auth/authorized_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.Auth.AuthorizedPlug do 2 | alias RealDealApiWeb.Auth.ErrorResponse 3 | 4 | def is_authorized(%{params: %{"account" => params}} = conn, _opts) do 5 | if conn.assigns.account.id == params["id"] do 6 | conn 7 | else 8 | raise ErrorResponse.Forbidden 9 | end 10 | end 11 | 12 | def is_authorized(%{params: %{"user" => params}} = conn, _opts) do 13 | if conn.assigns.account.user.id == params["id"] do 14 | conn 15 | else 16 | raise ErrorResponse.Forbidden 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/views/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.UserView do 2 | use RealDealApiWeb, :view 3 | alias RealDealApiWeb.UserView 4 | 5 | def render("index.json", %{users: users}) do 6 | %{data: render_many(users, UserView, "user.json")} 7 | end 8 | 9 | def render("show.json", %{user: user}) do 10 | %{data: render_one(user, UserView, "user.json")} 11 | end 12 | 13 | def render("user.json", %{user: user}) do 14 | %{ 15 | id: user.id, 16 | full_name: user.full_name, 17 | gender: user.gender, 18 | biography: user.biography 19 | } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.ErrorView do 2 | use RealDealApiWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.json", _assigns) do 7 | # %{errors: %{detail: "Internal Server Error"}} 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.json" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/views/changeset_view.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.ChangesetView do 2 | use RealDealApiWeb, :view 3 | 4 | @doc """ 5 | Traverses and translates changeset errors. 6 | 7 | See `Ecto.Changeset.traverse_errors/2` and 8 | `RealDealApiWeb.ErrorHelpers.translate_error/1` for more details. 9 | """ 10 | def translate_errors(changeset) do 11 | Ecto.Changeset.traverse_errors(changeset, &translate_error/1) 12 | end 13 | 14 | def render("error.json", %{changeset: changeset}) do 15 | # When encoded, the changeset returns its errors 16 | # as a JSON object. So we just pass it forward. 17 | %{errors: translate_errors(changeset)} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/auth/set_account.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.Auth.SetAccount do 2 | import Plug.Conn 3 | alias RealDealApiWeb.Auth.ErrorResponse 4 | alias RealDealApi.Accounts 5 | 6 | def init(_options) do 7 | end 8 | 9 | def call(conn, _options) do 10 | if conn.assigns[:account] do 11 | conn 12 | else 13 | account_id = get_session(conn, :account_id) 14 | 15 | if account_id == nil, do: raise(ErrorResponse.Unauthorized) 16 | 17 | account = Accounts.get_full_account(account_id) 18 | 19 | cond do 20 | account_id && account -> assign(conn, :account, account) 21 | true -> assign(conn, :account, nil) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | /.vscode 17 | 18 | # If the VM crashes, it generates a dump, let's ignore it too. 19 | erl_crash.dump 20 | 21 | # Also ignore archive artifacts (built via "mix archive.build"). 22 | *.ez 23 | 24 | *.orig 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | real_deal_api-*.tar 28 | 29 | .DS_Store 30 | 31 | -------------------------------------------------------------------------------- /lib/real_deal_api/users/user.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Users.User do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @optional_fields [:id, :biography, :full_name, :gender, :inserted_at, :updated_at] 6 | @primary_key {:id, :binary_id, autogenerate: true} 7 | @foreign_key_type :binary_id 8 | schema "users" do 9 | field :biography, :string 10 | field :full_name, :string 11 | field :gender, :string 12 | belongs_to :account, RealDealApi.Accounts.Account 13 | 14 | timestamps() 15 | end 16 | 17 | defp all_fields do 18 | __MODULE__.__schema__(:fields) 19 | end 20 | 21 | @doc false 22 | def changeset(user, attrs) do 23 | user 24 | |> cast(attrs, all_fields()) 25 | |> validate_required(all_fields() -- @optional_fields) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import RealDealApiWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :real_deal_api 24 | end 25 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/controllers/fallback_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.FallbackController do 2 | @moduledoc """ 3 | Translates controller action results into valid `Plug.Conn` responses. 4 | 5 | See `Phoenix.Controller.action_fallback/1` for more details. 6 | """ 7 | use RealDealApiWeb, :controller 8 | 9 | # This clause handles errors returned by Ecto's insert/update/delete. 10 | def call(conn, {:error, %Ecto.Changeset{} = changeset}) do 11 | conn 12 | |> put_status(:unprocessable_entity) 13 | |> put_view(RealDealApiWeb.ChangesetView) 14 | |> render("error.json", changeset: changeset) 15 | end 16 | 17 | # This clause is an example of how to handle resources that cannot be found. 18 | def call(conn, {:error, :not_found}) do 19 | conn 20 | |> put_status(:not_found) 21 | |> put_view(RealDealApiWeb.ErrorView) 22 | |> render(:"404") 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 3, May 2010 3 | 4 | Copyright (C) 2023 by Elixir Mentor 5 | Springville, UT USA 6 | 7 | Everyone is permitted to copy and distribute verbatim or modified 8 | copies of this license document, and changing it is allowed as long 9 | as the name is changed. 10 | 11 | This license applies to any copyrightable work with which it is 12 | packaged and/or distributed, except works that are already covered by 13 | another license. Any other license that applies to the same work 14 | shall take precedence over this one. 15 | 16 | To the extent permitted by applicable law, the works covered by this 17 | license are provided "as is" and do not come with any warranty except 18 | where otherwise explicitly stated. 19 | 20 | 21 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 22 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION, AND MODIFICATION 23 | 24 | 0. You just DO WHAT THE FUCK YOU WANT TO. 25 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :real_deal_api, RealDealApi.Repo, 9 | username: "backend_stuff", 10 | password: "blork_erlang", 11 | hostname: "localhost", 12 | database: "real_deal_api_test#{System.get_env("MIX_TEST_PARTITION")}", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: 10 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :real_deal_api, RealDealApiWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "gJ1J94FmCbpRWL0weOuc5UD8FDsGM5j/HY/XgozpYuwKHq0ZzbWTf1tYawmjezlX", 21 | server: false 22 | 23 | # Print only warnings and errors during test 24 | config :logger, level: :warn 25 | 26 | # Initialize plugs at runtime for faster test compilation 27 | config :phoenix, :plug_init_mode, :runtime 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Real Deal API SOLUTIONS 4 | Subscribe to [Elixir Mentor](https://www.youtube.com/channel/UChbS_z6KHQiIu9et38O37eQ) if you're interested in learning how to build scalable, production ready APIs in Elixir and some DevOps along the way, please subscribe to my channel Backend Stuff, and start your backend development journey. 5 | 6 | You will find the solutions for [Real Deal API](https://www.youtube.com/playlist?list=PL2Rv8vpZJz4zM3Go3X-dda478p-6xrmEl) here. 7 | 8 | #### INSTALL DEPENDENCIES 9 | ``` 10 | mix deps.get 11 | ``` 12 | 13 | ## SUPPORT ELIXIR MENTOR 14 | 15 | 🌐🌐 My website [Elixir Mentor](https://elixirmentor.com/) 16 | 17 | 🎙🎙 Check out my podcast [Big App Energy](https://www.hiredgunapps.com/podcast) 18 | 19 | 🆘🆘 NEED HELP?? Join the [Discord Server](https://discord.gg/HcnjPsWATg) 20 | 21 | ## FOLLOW ME 22 | Linktree: [@jacob_luetzow](https://linktr.ee/jacob_luetzow) 23 | 24 | Join the [Discord server](https://discord.gg/HcnjPsWATg) 25 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/views/account_view.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.AccountView do 2 | use RealDealApiWeb, :view 3 | alias RealDealApiWeb.{AccountView, UserView} 4 | 5 | def render("index.json", %{accounts: accounts}) do 6 | %{data: render_many(accounts, AccountView, "account.json")} 7 | end 8 | 9 | def render("show.json", %{account: account}) do 10 | %{data: render_one(account, AccountView, "account.json")} 11 | end 12 | 13 | def render("account.json", %{account: account}) do 14 | %{ 15 | id: account.id, 16 | email: account.email, 17 | hash_password: account.hash_password 18 | } 19 | end 20 | 21 | def render("full_account.json", %{account: account}) do 22 | %{ 23 | id: account.id, 24 | email: account.email, 25 | user: render_one(account.user, UserView, "user.json") 26 | } 27 | end 28 | 29 | def render("account_token.json", %{account: account, token: token}) do 30 | %{ 31 | id: account.id, 32 | email: account.email, 33 | token: token 34 | } 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/real_deal_api/accounts/account.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Accounts.Account do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @optional_fields [:id, :inserted_at, :updated_at] 6 | @primary_key {:id, :binary_id, autogenerate: true} 7 | @foreign_key_type :binary_id 8 | schema "accounts" do 9 | field :email, :string 10 | field :hash_password, :string 11 | has_one :user, RealDealApi.Users.User 12 | 13 | timestamps() 14 | end 15 | 16 | defp all_fields do 17 | __MODULE__.__schema__(:fields) 18 | end 19 | 20 | @doc false 21 | def changeset(account, attrs) do 22 | account 23 | |> cast(attrs, all_fields()) 24 | |> validate_required(all_fields() -- @optional_fields) 25 | |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") 26 | |> validate_length(:email, max: 160) 27 | |> unique_constraint(:email) 28 | |> put_password_hash() 29 | end 30 | 31 | defp put_password_hash( 32 | %Ecto.Changeset{valid?: true, changes: %{hash_password: hash_password}} = changeset 33 | ) do 34 | change(changeset, hash_password: Bcrypt.hash_pwd_salt(hash_password)) 35 | end 36 | 37 | defp put_password_hash(changeset), do: changeset 38 | end 39 | -------------------------------------------------------------------------------- /lib/real_deal_api/application.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Ecto repository 12 | RealDealApi.Repo, 13 | # Start the Telemetry supervisor 14 | RealDealApiWeb.Telemetry, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: RealDealApi.PubSub}, 17 | # Start the Endpoint (http/https) 18 | RealDealApiWeb.Endpoint 19 | # Start a worker by calling: RealDealApi.Worker.start_link(arg) 20 | # {RealDealApi.Worker, arg} 21 | ] 22 | 23 | # See https://hexdocs.pm/elixir/Supervisor.html 24 | # for other strategies and supported options 25 | opts = [strategy: :one_for_one, name: RealDealApi.Supervisor] 26 | Supervisor.start_link(children, opts) 27 | end 28 | 29 | # Tell Phoenix to update the endpoint configuration 30 | # whenever the application is updated. 31 | @impl true 32 | def config_change(changed, _new, removed) do 33 | RealDealApiWeb.Endpoint.config_change(changed, removed) 34 | :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.Router do 2 | use RealDealApiWeb, :router 3 | use Plug.ErrorHandler 4 | 5 | def handle_errors(conn, %{reason: %Phoenix.Router.NoRouteError{message: message}}) do 6 | conn |> json(%{errors: message}) |> halt() 7 | end 8 | 9 | def handle_errors(conn, %{reason: %{message: message}}) do 10 | conn |> json(%{errors: message}) |> halt() 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | plug :fetch_session 16 | end 17 | 18 | pipeline :auth do 19 | plug RealDealApiWeb.Auth.Pipeline 20 | plug RealDealApiWeb.Auth.SetAccount 21 | end 22 | 23 | scope "/api", RealDealApiWeb do 24 | pipe_through :api 25 | get "/", DefaultController, :index 26 | post "/accounts/create", AccountController, :create 27 | post "/accounts/sign_in", AccountController, :sign_in 28 | end 29 | 30 | scope "/api", RealDealApiWeb do 31 | pipe_through [:api, :auth] 32 | get "/accounts/current", AccountController, :current_account 33 | get "/accounts/sign_out", AccountController, :sign_out 34 | get "/accounts/refresh_session", AccountController, :refresh_session 35 | post "/accounts/update", AccountController, :update 36 | put "/users/update", UserController, :update 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/controllers/user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.UserController do 2 | use RealDealApiWeb, :controller 3 | 4 | alias RealDealApi.Users 5 | alias RealDealApi.Users.User 6 | 7 | import RealDealApiWeb.Auth.AuthorizedPlug 8 | 9 | plug :is_authorized when action in [:update, :delete] 10 | 11 | action_fallback RealDealApiWeb.FallbackController 12 | 13 | def index(conn, _params) do 14 | users = Users.list_users() 15 | render(conn, "index.json", users: users) 16 | end 17 | 18 | def create(conn, %{"user" => user_params}) do 19 | with {:ok, %User{} = user} <- Users.create_user(user_params) do 20 | conn 21 | |> put_status(:created) 22 | |> render("show.json", user: user) 23 | end 24 | end 25 | 26 | def show(conn, %{"id" => id}) do 27 | user = Users.get_user!(id) 28 | render(conn, "show.json", user: user) 29 | end 30 | 31 | def update(conn, %{"user" => user_params}) do 32 | with {:ok, %User{} = user} <- Users.update_user(conn.assigns.account.user, user_params) do 33 | render(conn, "show.json", user: user) 34 | end 35 | end 36 | 37 | def delete(conn, %{"id" => id}) do 38 | user = Users.get_user!(id) 39 | 40 | with {:ok, %User{}} <- Users.delete_user(user) do 41 | send_resp(conn, :no_content, "") 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/support/schema_case.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Support.SchemaCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using do 5 | quote do 6 | alias Ecto.Changeset 7 | import RealDealApi.Support.SchemaCase 8 | end 9 | end 10 | 11 | setup _ do 12 | Ecto.Adapters.SQL.Sandbox.mode(RealDealApi.Repo, :manual) 13 | end 14 | 15 | def valid_params(fields_with_types) do 16 | valid_value_by_type = %{ 17 | binary_id: fn -> Faker.UUID.v4() end, 18 | string: fn -> Faker.Lorem.word() end, 19 | naive_datetime: fn -> 20 | Faker.NaiveDateTime.backward(Enum.random(0..100)) 21 | |> NaiveDateTime.truncate(:second) 22 | end 23 | } 24 | 25 | for {field, type} <- fields_with_types, into: %{} do 26 | case field do 27 | :email -> {Atom.to_string(field), Faker.Internet.email()} 28 | _ -> {Atom.to_string(field), valid_value_by_type[type].()} 29 | end 30 | end 31 | end 32 | 33 | def invalid_params(fields_with_types) do 34 | invalid_value_by_type = %{ 35 | binary_id: fn -> DateTime.utc_now() end, 36 | string: fn -> DateTime.utc_now() end, 37 | naive_datetime: fn -> Faker.Lorem.word() end 38 | } 39 | 40 | for {field, type} <- fields_with_types, into: %{} do 41 | {Atom.to_string(field), invalid_value_by_type[type].()} 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | @doc """ 7 | Translates an error message using gettext. 8 | """ 9 | def translate_error({msg, opts}) do 10 | # When using gettext, we typically pass the strings we want 11 | # to translate as a static argument: 12 | # 13 | # # Translate "is invalid" in the "errors" domain 14 | # dgettext("errors", "is invalid") 15 | # 16 | # # Translate the number of files with plural rules 17 | # dngettext("errors", "1 file", "%{count} files", count) 18 | # 19 | # Because the error messages we show in our forms and APIs 20 | # are defined inside Ecto, we need to translate them dynamically. 21 | # This requires us to call the Gettext module passing our gettext 22 | # backend as first argument. 23 | # 24 | # Note we use the "errors" domain, which means translations 25 | # should be written to the errors.po file. The :count option is 26 | # set by Ecto and indicates we should also apply plural rules. 27 | if count = opts[:count] do 28 | Gettext.dngettext(RealDealApiWeb.Gettext, "errors", msg, msg, count, opts) 29 | else 30 | Gettext.dgettext(RealDealApiWeb.Gettext, "errors", msg, opts) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :real_deal_api, 11 | ecto_repos: [RealDealApi.Repo], 12 | generators: [binary_id: true] 13 | 14 | # Configures the endpoint 15 | config :real_deal_api, RealDealApiWeb.Endpoint, 16 | url: [host: "localhost"], 17 | render_errors: [view: RealDealApiWeb.ErrorView, accepts: ~w(json), layout: false], 18 | pubsub_server: RealDealApi.PubSub, 19 | live_view: [signing_salt: "p8pVImdk"] 20 | 21 | # Configures Elixir's Logger 22 | config :logger, :console, 23 | format: "$time $metadata[$level] $message\n", 24 | metadata: [:request_id] 25 | 26 | config :real_deal_api, RealDealApiWeb.Auth.Guardian, 27 | issuer: "real_deal_api", 28 | secret_key: "A2QhoBW5+qU4F79ac7Ozo4fUlRpzkeHOYORgJkCazWjvOH22e3esAjryekV/+5Qs" 29 | 30 | # Use Jason for JSON parsing in Phoenix 31 | config :phoenix, :json_library, Jason 32 | 33 | config :guardian, Guardian.DB, 34 | repo: RealDealApi.Repo, 35 | schema_name: "guardian_tokens", 36 | sweep_interval: 60 37 | 38 | # Import environment specific config. This must remain at the bottom 39 | # of this file so it overrides the configuration defined above. 40 | import_config "#{config_env()}.exs" 41 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :real_deal_api 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_real_deal_api_key", 10 | signing_salt: "f/m+yNQ/" 11 | ] 12 | 13 | # socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :real_deal_api, 22 | gzip: false, 23 | only: ~w(assets fonts images favicon.ico robots.txt) 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | plug Phoenix.CodeReloader 29 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :real_deal_api 30 | end 31 | 32 | plug Plug.RequestId 33 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 34 | 35 | plug Plug.Parsers, 36 | parsers: [:urlencoded, :multipart, :json], 37 | pass: ["*/*"], 38 | json_decoder: Phoenix.json_library() 39 | 40 | plug Plug.MethodOverride 41 | plug Plug.Head 42 | plug Plug.Session, @session_options 43 | plug RealDealApiWeb.Router 44 | end 45 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :real_deal_api, RealDealApiWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Do not print debug messages in production 15 | config :logger, level: :info 16 | 17 | # ## SSL Support 18 | # 19 | # To get SSL working, you will need to add the `https` key 20 | # to the previous section and set your `:url` port to 443: 21 | # 22 | # config :real_deal_api, RealDealApiWeb.Endpoint, 23 | # ..., 24 | # url: [host: "example.com", port: 443], 25 | # https: [ 26 | # ..., 27 | # port: 443, 28 | # cipher_suite: :strong, 29 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 30 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 31 | # ] 32 | # 33 | # The `cipher_suite` is set to `:strong` to support only the 34 | # latest and more secure SSL ciphers. This means old browsers 35 | # and clients may not be supported. You can set it to 36 | # `:compatible` for wider support. 37 | # 38 | # `:keyfile` and `:certfile` expect an absolute path to the key 39 | # and cert in disk or a relative path inside priv, for example 40 | # "priv/ssl/server.key". For all supported SSL configuration 41 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 42 | # 43 | # We also recommend setting `force_ssl` in your endpoint, ensuring 44 | # no data is ever sent via http, always redirecting to https: 45 | # 46 | # config :real_deal_api, RealDealApiWeb.Endpoint, 47 | # force_ssl: [hsts: true] 48 | # 49 | # Check `Plug.SSL` for all available options in `force_ssl`. 50 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :real_deal_api, 7 | version: "0.1.0", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {RealDealApi.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.6.15"}, 37 | {:phoenix_ecto, "~> 4.4"}, 38 | {:ecto_sql, "~> 3.6"}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:telemetry_metrics, "~> 0.6"}, 41 | {:telemetry_poller, "~> 1.0"}, 42 | {:gettext, "~> 0.18"}, 43 | {:jason, "~> 1.2"}, 44 | {:plug_cowboy, "~> 2.5"}, 45 | {:guardian, "~> 2.3"}, 46 | {:guardian_db, "~> 2.0"}, 47 | {:bcrypt_elixir, "~> 3.0"}, 48 | {:faker, "~> 0.17", only: :test}, 49 | {:ex_machina, "~> 2.7.0", only: :test} 50 | ] 51 | end 52 | 53 | # Aliases are shortcuts or tasks specific to the current project. 54 | # For example, to install project dependencies and perform other setup tasks, run: 55 | # 56 | # $ mix setup 57 | # 58 | # See the documentation for `Mix` for more info on aliases. 59 | defp aliases do 60 | [ 61 | setup: ["deps.get", "ecto.setup"], 62 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 63 | "ecto.reset": ["ecto.drop", "ecto.setup"], 64 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] 65 | ] 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :real_deal_api, RealDealApi.Repo, 5 | username: "backend_stuff", 6 | password: "blork_erlang", 7 | hostname: "localhost", 8 | database: "real_deal_api_dev", 9 | stacktrace: true, 10 | show_sensitive_data_on_connection_error: true, 11 | pool_size: 10 12 | 13 | # For development, we disable any cache and enable 14 | # debugging and code reloading. 15 | # 16 | # The watchers configuration can be used to run external 17 | # watchers to your application. For example, we use it 18 | # with esbuild to bundle .js and .css sources. 19 | config :real_deal_api, RealDealApiWeb.Endpoint, 20 | # Binding to loopback ipv4 address prevents access from other machines. 21 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 22 | http: [ip: {127, 0, 0, 1}, port: 4000], 23 | check_origin: false, 24 | code_reloader: true, 25 | debug_errors: true, 26 | secret_key_base: "aBETxtInjlzjtEtOXDW4r6T0q/o9LAwOOP7zQzynO0m3bkDfA8JFd6XRxsxdTMvj", 27 | watchers: [] 28 | 29 | # ## SSL Support 30 | # 31 | # In order to use HTTPS in development, a self-signed 32 | # certificate can be generated by running the following 33 | # Mix task: 34 | # 35 | # mix phx.gen.cert 36 | # 37 | # Note that this task requires Erlang/OTP 20 or later. 38 | # Run `mix help phx.gen.cert` for more information. 39 | # 40 | # The `http:` config above can be replaced with: 41 | # 42 | # https: [ 43 | # port: 4001, 44 | # cipher_suite: :strong, 45 | # keyfile: "priv/cert/selfsigned_key.pem", 46 | # certfile: "priv/cert/selfsigned.pem" 47 | # ], 48 | # 49 | # If desired, both `http:` and `https:` keys can be 50 | # configured to run both http and https servers on 51 | # different ports. 52 | 53 | # Do not include metadata nor timestamps in development logs 54 | config :logger, :console, format: "[$level] $message\n" 55 | 56 | # Set a higher stacktrace during development. Avoid configuring such 57 | # in production as building large stacktraces may be expensive. 58 | config :phoenix, :stacktrace_depth, 20 59 | 60 | # Initialize plugs at runtime for faster development compilation 61 | config :phoenix, :plug_init_mode, :runtime 62 | -------------------------------------------------------------------------------- /lib/real_deal_api_web.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use RealDealApiWeb, :controller 9 | use RealDealApiWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: RealDealApiWeb 23 | 24 | import Plug.Conn 25 | import RealDealApiWeb.Gettext 26 | alias RealDealApiWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/real_deal_api_web/templates", 34 | namespace: RealDealApiWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def router do 46 | quote do 47 | use Phoenix.Router 48 | 49 | import Plug.Conn 50 | import Phoenix.Controller 51 | end 52 | end 53 | 54 | def channel do 55 | quote do 56 | use Phoenix.Channel 57 | import RealDealApiWeb.Gettext 58 | end 59 | end 60 | 61 | defp view_helpers do 62 | quote do 63 | # Import basic rendering functionality (render, render_layout, etc) 64 | import Phoenix.View 65 | 66 | import RealDealApiWeb.ErrorHelpers 67 | import RealDealApiWeb.Gettext 68 | alias RealDealApiWeb.Router.Helpers, as: Routes 69 | end 70 | end 71 | 72 | @doc """ 73 | When used, dispatch to the appropriate controller/view/etc. 74 | """ 75 | defmacro __using__(which) when is_atom(which) do 76 | apply(__MODULE__, which, []) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/real_deal_api/users.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Users do 2 | @moduledoc """ 3 | The Users context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias RealDealApi.Repo 8 | 9 | alias RealDealApi.Users.User 10 | 11 | @doc """ 12 | Returns the list of users. 13 | 14 | ## Examples 15 | 16 | iex> list_users() 17 | [%User{}, ...] 18 | 19 | """ 20 | def list_users do 21 | Repo.all(User) 22 | end 23 | 24 | @doc """ 25 | Gets a single user. 26 | 27 | Raises `Ecto.NoResultsError` if the User does not exist. 28 | 29 | ## Examples 30 | 31 | iex> get_user!(123) 32 | %User{} 33 | 34 | iex> get_user!(456) 35 | ** (Ecto.NoResultsError) 36 | 37 | """ 38 | def get_user!(id), do: Repo.get!(User, id) 39 | 40 | @doc """ 41 | Creates a user. 42 | 43 | ## Examples 44 | 45 | iex> create_user(%{field: value}) 46 | {:ok, %User{}} 47 | 48 | iex> create_user(%{field: bad_value}) 49 | {:error, %Ecto.Changeset{}} 50 | 51 | """ 52 | def create_user(account, attrs \\ %{}) do 53 | account 54 | |> Ecto.build_assoc(:user) 55 | |> User.changeset(attrs) 56 | |> Repo.insert() 57 | end 58 | 59 | @doc """ 60 | Updates a user. 61 | 62 | ## Examples 63 | 64 | iex> update_user(user, %{field: new_value}) 65 | {:ok, %User{}} 66 | 67 | iex> update_user(user, %{field: bad_value}) 68 | {:error, %Ecto.Changeset{}} 69 | 70 | """ 71 | def update_user(%User{} = user, attrs) do 72 | user 73 | |> User.changeset(attrs) 74 | |> Repo.update() 75 | end 76 | 77 | @doc """ 78 | Deletes a user. 79 | 80 | ## Examples 81 | 82 | iex> delete_user(user) 83 | {:ok, %User{}} 84 | 85 | iex> delete_user(user) 86 | {:error, %Ecto.Changeset{}} 87 | 88 | """ 89 | def delete_user(%User{} = user) do 90 | Repo.delete(user) 91 | end 92 | 93 | @doc """ 94 | Returns an `%Ecto.Changeset{}` for tracking user changes. 95 | 96 | ## Examples 97 | 98 | iex> change_user(user) 99 | %Ecto.Changeset{data: %User{}} 100 | 101 | """ 102 | def change_user(%User{} = user, attrs \\ %{}) do 103 | User.changeset(user, attrs) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}, 15 | {Guardian.DB.Token.SweeperServer, []} 16 | # Add reporters as children of your supervision tree. 17 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 18 | ] 19 | 20 | Supervisor.init(children, strategy: :one_for_one) 21 | end 22 | 23 | def metrics do 24 | [ 25 | # Phoenix Metrics 26 | summary("phoenix.endpoint.stop.duration", 27 | unit: {:native, :millisecond} 28 | ), 29 | summary("phoenix.router_dispatch.stop.duration", 30 | tags: [:route], 31 | unit: {:native, :millisecond} 32 | ), 33 | 34 | # Database Metrics 35 | summary("real_deal_api.repo.query.total_time", 36 | unit: {:native, :millisecond}, 37 | description: "The sum of the other measurements" 38 | ), 39 | summary("real_deal_api.repo.query.decode_time", 40 | unit: {:native, :millisecond}, 41 | description: "The time spent decoding the data received from the database" 42 | ), 43 | summary("real_deal_api.repo.query.query_time", 44 | unit: {:native, :millisecond}, 45 | description: "The time spent executing the query" 46 | ), 47 | summary("real_deal_api.repo.query.queue_time", 48 | unit: {:native, :millisecond}, 49 | description: "The time spent waiting for a database connection" 50 | ), 51 | summary("real_deal_api.repo.query.idle_time", 52 | unit: {:native, :millisecond}, 53 | description: 54 | "The time the connection spent waiting before being checked out for the query" 55 | ), 56 | 57 | # VM Metrics 58 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 59 | summary("vm.total_run_queue_lengths.total"), 60 | summary("vm.total_run_queue_lengths.cpu"), 61 | summary("vm.total_run_queue_lengths.io") 62 | ] 63 | end 64 | 65 | defp periodic_measurements do 66 | [ 67 | # A module, function and arguments to be invoked periodically. 68 | # This function must call :telemetry.execute/3 and a metric must be added above. 69 | # {RealDealApiWeb, :count_users, []} 70 | ] 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/real_deal_api start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :real_deal_api, RealDealApiWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_url = 25 | System.get_env("DATABASE_URL") || 26 | raise """ 27 | environment variable DATABASE_URL is missing. 28 | For example: ecto://USER:PASS@HOST/DATABASE 29 | """ 30 | 31 | maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] 32 | 33 | config :real_deal_api, RealDealApi.Repo, 34 | # ssl: true, 35 | url: database_url, 36 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 37 | socket_options: maybe_ipv6 38 | 39 | # The secret key base is used to sign/encrypt cookies and other secrets. 40 | # A default value is used in config/dev.exs and config/test.exs but you 41 | # want to use a different value for prod and you most likely don't want 42 | # to check this value into version control, so we use an environment 43 | # variable instead. 44 | secret_key_base = 45 | System.get_env("SECRET_KEY_BASE") || 46 | raise """ 47 | environment variable SECRET_KEY_BASE is missing. 48 | You can generate one by calling: mix phx.gen.secret 49 | """ 50 | 51 | host = System.get_env("PHX_HOST") || "example.com" 52 | port = String.to_integer(System.get_env("PORT") || "4000") 53 | 54 | config :real_deal_api, RealDealApiWeb.Endpoint, 55 | url: [host: host, port: 443, scheme: "https"], 56 | http: [ 57 | # Enable IPv6 and bind on all interfaces. 58 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 59 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 60 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 61 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 62 | port: port 63 | ], 64 | secret_key_base: secret_key_base 65 | end 66 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/auth/guardian.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.Auth.Guardian do 2 | use Guardian, otp_app: :real_deal_api 3 | alias RealDealApi.Accounts 4 | 5 | def subject_for_token(%{id: id}, _claims) do 6 | sub = to_string(id) 7 | {:ok, sub} 8 | end 9 | 10 | def subject_for_token(_, _) do 11 | {:error, :no_id_provided} 12 | end 13 | 14 | def resource_from_claims(%{"sub" => id}) do 15 | case Accounts.get_account!(id) do 16 | nil -> {:error, :not_found} 17 | resource -> {:ok, resource} 18 | end 19 | end 20 | 21 | def resource_from_claims(_claims) do 22 | {:error, :no_id_provided} 23 | end 24 | 25 | def authenticate(email, password) do 26 | case Accounts.get_account_by_email(email) do 27 | nil -> 28 | {:error, :unauthored} 29 | 30 | account -> 31 | case validate_password(password, account.hash_password) do 32 | true -> create_token(account, :access) 33 | false -> {:error, :unauthorized} 34 | end 35 | end 36 | end 37 | 38 | def authenticate(token) do 39 | with {:ok, claims} <- decode_and_verify(token), 40 | {:ok, account} <- resource_from_claims(claims), 41 | {:ok, _old, {new_token, _claims}} <- refresh(token) do 42 | {:ok, account, new_token} 43 | end 44 | end 45 | 46 | def validate_password(password, hash_password) do 47 | Bcrypt.verify_pass(password, hash_password) 48 | end 49 | 50 | defp create_token(account, type) do 51 | {:ok, token, _claims} = encode_and_sign(account, %{}, token_options(type)) 52 | {:ok, account, token} 53 | end 54 | 55 | defp token_options(type) do 56 | case type do 57 | :access -> [token_type: "access", ttl: {2, :hour}] 58 | :reset -> [token_type: "reset", ttl: {15, :minute}] 59 | :admin -> [token_type: "admin", ttl: {90, :day}] 60 | end 61 | end 62 | 63 | def after_encode_and_sign(resource, claims, token, _options) do 64 | with {:ok, _} <- Guardian.DB.after_encode_and_sign(resource, claims["typ"], claims, token) do 65 | {:ok, token} 66 | end 67 | end 68 | 69 | def on_verify(claims, token, _options) do 70 | with {:ok, _} <- Guardian.DB.on_verify(claims, token) do 71 | {:ok, claims} 72 | end 73 | end 74 | 75 | def on_refresh({old_token, old_claims}, {new_token, new_claims}, _options) do 76 | with {:ok, _, _} <- Guardian.DB.on_refresh({old_token, old_claims}, {new_token, new_claims}) do 77 | {:ok, {old_token, old_claims}, {new_token, new_claims}} 78 | end 79 | end 80 | 81 | def on_revoke(claims, token, _options) do 82 | with {:ok, _} <- Guardian.DB.on_revoke(claims, token) do 83 | {:ok, claims} 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/real_deal_api/schema/user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Schema.UserTest do 2 | use RealDealApi.Support.SchemaCase 3 | alias RealDealApi.Users.User 4 | 5 | @expected_fields_with_types [ 6 | {:id, :binary_id}, 7 | {:biography, :string}, 8 | {:full_name, :string}, 9 | {:gender, :string}, 10 | {:account_id, :binary_id}, 11 | {:inserted_at, :naive_datetime}, 12 | {:updated_at, :naive_datetime} 13 | ] 14 | 15 | @optional [:id, :biography, :full_name, :gender, :inserted_at, :updated_at] 16 | 17 | describe "fields and types" do 18 | test "it has the correct fields and types" do 19 | actual_fields_with_types = 20 | for field <- User.__schema__(:fields) do 21 | type = User.__schema__(:type, field) 22 | {field, type} 23 | end 24 | 25 | assert MapSet.new(actual_fields_with_types) == MapSet.new(@expected_fields_with_types) 26 | end 27 | end 28 | 29 | describe "changeset/2" do 30 | test "success: returns a valid changeset when given valid arguments" do 31 | valid_params = valid_params(@expected_fields_with_types) 32 | 33 | changeset = User.changeset(%User{}, valid_params) 34 | assert %Changeset{valid?: true, changes: changes} = changeset 35 | 36 | for {field, _} <- @expected_fields_with_types do 37 | actual = Map.get(changes, field) 38 | expected = valid_params[Atom.to_string(field)] 39 | 40 | assert actual == expected, 41 | "Values did not match for field: #{field}\nexpected: #{inspect(expected)}\nactual: #{inspect(actual)}" 42 | end 43 | end 44 | 45 | test "error: returns an error changeset when given un-castable values" do 46 | invalid_params = invalid_params(@expected_fields_with_types) 47 | 48 | assert %Changeset{valid?: false, errors: errors} = User.changeset(%User{}, invalid_params) 49 | 50 | for {field, _} <- @expected_fields_with_types do 51 | assert errors[field], "The field :#{field} is missing from errors." 52 | {_, meta} = errors[field] 53 | 54 | assert meta[:validation] == :cast, 55 | "The validation type, #{meta[:validation]}, is incorrect." 56 | end 57 | end 58 | 59 | test "error: returns error changeset when required fields are missing" do 60 | params = %{} 61 | 62 | assert %Changeset{valid?: false, errors: errors} = User.changeset(%User{}, params) 63 | 64 | for {field, _} <- @expected_fields_with_types, field not in @optional do 65 | assert errors[field], "The field :#{field} is missing from errors." 66 | {_, meta} = errors[field] 67 | 68 | assert meta[:validation] == :required, 69 | "The validation type, #{meta[:validation]}, is incorrect." 70 | end 71 | 72 | for {field, _} <- @optional do 73 | refute errors[field], "The optional field #{field} is required when it shouldn't be." 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/real_deal_api/accounts.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Accounts do 2 | @moduledoc """ 3 | The Accounts context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias RealDealApi.Repo 8 | 9 | alias RealDealApi.Accounts.Account 10 | 11 | @doc """ 12 | Returns the list of accounts. 13 | 14 | ## Examples 15 | 16 | iex> list_accounts() 17 | [%Account{}, ...] 18 | 19 | """ 20 | def list_accounts do 21 | Repo.all(Account) 22 | end 23 | 24 | @doc """ 25 | Gets a single account. 26 | 27 | Raises `Ecto.NoResultsError` if the Account does not exist. 28 | 29 | ## Examples 30 | 31 | iex> get_account!(123) 32 | %Account{} 33 | 34 | iex> get_account!(456) 35 | ** (Ecto.NoResultsError) 36 | 37 | """ 38 | def get_account!(id), do: Repo.get!(Account, id) 39 | 40 | def get_full_account(id) do 41 | Account 42 | |> where(id: ^id) 43 | |> preload([:user]) 44 | |> Repo.one() 45 | end 46 | 47 | @doc """ 48 | Gets a single account.any() 49 | 50 | Returns 'nil' if the Account does not exist. 51 | 52 | ## Examples 53 | 54 | iex> get_account_by_email(test@email.com) 55 | %Account{} 56 | 57 | iex> get_account_by_email(no_account@email.com) 58 | nil 59 | """ 60 | def get_account_by_email(email) do 61 | Account 62 | |> where(email: ^email) 63 | |> Repo.one() 64 | end 65 | 66 | @doc """ 67 | Creates a account. 68 | 69 | ## Examples 70 | 71 | iex> create_account(%{field: value}) 72 | {:ok, %Account{}} 73 | 74 | iex> create_account(%{field: bad_value}) 75 | {:error, %Ecto.Changeset{}} 76 | 77 | """ 78 | def create_account(attrs \\ %{}) do 79 | %Account{} 80 | |> Account.changeset(attrs) 81 | |> Repo.insert() 82 | end 83 | 84 | @doc """ 85 | Updates a account. 86 | 87 | ## Examples 88 | 89 | iex> update_account(account, %{field: new_value}) 90 | {:ok, %Account{}} 91 | 92 | iex> update_account(account, %{field: bad_value}) 93 | {:error, %Ecto.Changeset{}} 94 | 95 | """ 96 | def update_account(%Account{} = account, attrs) do 97 | account 98 | |> Account.changeset(attrs) 99 | |> Repo.update() 100 | end 101 | 102 | @doc """ 103 | Deletes a account. 104 | 105 | ## Examples 106 | 107 | iex> delete_account(account) 108 | {:ok, %Account{}} 109 | 110 | iex> delete_account(account) 111 | {:error, %Ecto.Changeset{}} 112 | 113 | """ 114 | def delete_account(%Account{} = account) do 115 | Repo.delete(account) 116 | end 117 | 118 | @doc """ 119 | Returns an `%Ecto.Changeset{}` for tracking account changes. 120 | 121 | ## Examples 122 | 123 | iex> change_account(account) 124 | %Ecto.Changeset{data: %Account{}} 125 | 126 | """ 127 | def change_account(%Account{} = account, attrs \\ %{}) do 128 | Account.changeset(account, attrs) 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should have %{count} item(s)" 54 | msgid_plural "should have %{count} item(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should be %{count} character(s)" 59 | msgid_plural "should be %{count} character(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be %{count} byte(s)" 64 | msgid_plural "should be %{count} byte(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at least %{count} character(s)" 74 | msgid_plural "should be at least %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should be at least %{count} byte(s)" 79 | msgid_plural "should be at least %{count} byte(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | msgid "should have at most %{count} item(s)" 84 | msgid_plural "should have at most %{count} item(s)" 85 | msgstr[0] "" 86 | msgstr[1] "" 87 | 88 | msgid "should be at most %{count} character(s)" 89 | msgid_plural "should be at most %{count} character(s)" 90 | msgstr[0] "" 91 | msgstr[1] "" 92 | 93 | msgid "should be at most %{count} byte(s)" 94 | msgid_plural "should be at most %{count} byte(s)" 95 | msgstr[0] "" 96 | msgstr[1] "" 97 | 98 | ## From Ecto.Changeset.validate_number/3 99 | msgid "must be less than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be greater than %{number}" 103 | msgstr "" 104 | 105 | msgid "must be less than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be greater than or equal to %{number}" 109 | msgstr "" 110 | 111 | msgid "must be equal to %{number}" 112 | msgstr "" 113 | -------------------------------------------------------------------------------- /lib/real_deal_api_web/controllers/account_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule RealDealApiWeb.AccountController do 2 | use RealDealApiWeb, :controller 3 | 4 | alias RealDealApiWeb.{Auth.Guardian, Auth.ErrorResponse} 5 | alias RealDealApi.{Accounts, Accounts.Account, Users, Users.User} 6 | 7 | import RealDealApiWeb.Auth.AuthorizedPlug 8 | 9 | plug :is_authorized when action in [:update, :delete] 10 | 11 | action_fallback RealDealApiWeb.FallbackController 12 | 13 | def index(conn, _params) do 14 | accounts = Accounts.list_accounts() 15 | render(conn, "index.json", accounts: accounts) 16 | end 17 | 18 | def create(conn, %{"account" => account_params}) do 19 | with {:ok, %Account{} = account} <- Accounts.create_account(account_params), 20 | {:ok, %User{} = _user} <- Users.create_user(account, account_params) do 21 | authorize_account(conn, account.email, account_params["hash_password"]) 22 | end 23 | end 24 | 25 | def sign_in(conn, %{"email" => email, "hash_password" => hash_password}) do 26 | authorize_account(conn, email, hash_password) 27 | end 28 | 29 | defp authorize_account(conn, email, hash_password) do 30 | case Guardian.authenticate(email, hash_password) do 31 | {:ok, account, token} -> 32 | conn 33 | |> Plug.Conn.put_session(:account_id, account.id) 34 | |> put_status(:ok) 35 | |> render("account_token.json", %{account: account, token: token}) 36 | 37 | {:error, :unauthorized} -> 38 | raise ErrorResponse.Unauthorized, message: "Email or Password incorrect." 39 | end 40 | end 41 | 42 | def refresh_session(conn, %{}) do 43 | token = Guardian.Plug.current_token(conn) 44 | {:ok, account, new_token} = Guardian.authenticate(token) 45 | 46 | conn 47 | |> Plug.Conn.put_session(:account_id, account.id) 48 | |> put_status(:ok) 49 | |> render("account_token.json", %{account: account, token: new_token}) 50 | end 51 | 52 | def sign_out(conn, %{}) do 53 | account = conn.assigns[:account] 54 | token = Guardian.Plug.current_token(conn) 55 | Guardian.revoke(token) 56 | 57 | conn 58 | |> Plug.Conn.clear_session() 59 | |> put_status(:ok) 60 | |> render("account_token.json", %{account: account, token: nil}) 61 | end 62 | 63 | def show(conn, %{"id" => id}) do 64 | account = Accounts.get_full_account(id) 65 | render(conn, "full_account.json", account: account) 66 | end 67 | 68 | def current_account(conn, %{}) do 69 | conn 70 | |> put_status(:ok) 71 | |> render("full_account.json", %{account: conn.assigns.account}) 72 | end 73 | 74 | def update(conn, %{"current_hash" => current_hash, "account" => account_params}) do 75 | case Guardian.validate_password(current_hash, conn.assigns.account.hash_password) do 76 | true -> 77 | {:ok, account} = Accounts.update_account(conn.assigns.account, account_params) 78 | render(conn, "show.json", account: account) 79 | 80 | false -> 81 | raise ErrorResponse.Unauthorized, message: "Password incorrect." 82 | end 83 | end 84 | 85 | def delete(conn, %{"id" => id}) do 86 | account = Accounts.get_account!(id) 87 | 88 | with {:ok, %Account{}} <- Accounts.delete_account(account) do 89 | send_resp(conn, :no_content, "") 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/real_deal_api/accounts_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.AccountsTest do 2 | use RealDealApi.Support.DataCase 3 | alias RealDealApi.{Accounts, Accounts.Account} 4 | 5 | setup do 6 | Ecto.Adapters.SQL.Sandbox.checkout(RealDealApi.Repo) 7 | end 8 | 9 | describe "create_account/1" do 10 | test "success: it inserts an account in the db and returns the account" do 11 | params = Factory.string_params_for(:account) 12 | 13 | assert {:ok, %Account{} = returned_account} = Accounts.create_account(params) 14 | 15 | account_from_db = Repo.get(Account, returned_account.id) 16 | 17 | assert returned_account == account_from_db 18 | 19 | mutated = ["hash_password"] 20 | 21 | for {param_field, expected} <- params, param_field not in mutated do 22 | schema_field = String.to_existing_atom(param_field) 23 | actual = Map.get(account_from_db, schema_field) 24 | 25 | assert actual == expected, 26 | "Valuse did not match for field: #{param_field}\nexpected: #{inspect(expected)}\nactual: #{inspect(actual)}" 27 | end 28 | 29 | assert Bcrypt.verify_pass(params["hash_password"], returned_account.hash_password), 30 | "Password: #{inspect(params["hash_password"])} does not match \mhash: #{inspect(returned_account.hash_password)}" 31 | 32 | assert account_from_db.inserted_at == account_from_db.updated_at 33 | end 34 | 35 | test "error: returns an error tuple when account can't be created" do 36 | missing_params = %{} 37 | 38 | assert {:error, %Changeset{valid?: false}} = Accounts.create_account(missing_params) 39 | end 40 | end 41 | 42 | describe "get_account/1" do 43 | test "success: it returns an account when given a valid UUID" do 44 | existing_account = Factory.insert(:account) 45 | assert returned_account = Accounts.get_account!(existing_account.id) 46 | assert returned_account == existing_account 47 | end 48 | 49 | test "error: raises a Ecto.NoResultsError when an account doesn't exist" do 50 | assert_raise Ecto.NoResultsError, fn -> 51 | Accounts.get_account!(Ecto.UUID.autogenerate()) 52 | end 53 | end 54 | end 55 | 56 | describe "update_account/2" do 57 | test "success: it updates database and returns the account" do 58 | existing_account = Factory.insert(:account) 59 | 60 | params = 61 | Factory.string_params_for(:account) 62 | |> Map.take(["email"]) 63 | 64 | assert {:ok, returned_account} = Accounts.update_account(existing_account, params) 65 | 66 | account_from_db = Repo.get(Account, returned_account.id) 67 | 68 | assert returned_account == account_from_db 69 | 70 | expected_account_data = 71 | existing_account 72 | |> Map.from_struct() 73 | |> Map.put(:email, params["email"]) 74 | 75 | for {field, expected} <- expected_account_data do 76 | actual = Map.get(account_from_db, field) 77 | 78 | assert actual == expected, 79 | "Values did not match for field: #{field}\nexpected: #{inspect(expected)}\nactual: #{inspect(actual)}" 80 | end 81 | end 82 | 83 | test "error: returns an error tuple when account can't be updated" do 84 | existing_account = Factory.insert(:account) 85 | bad_params = %{"email" => NaiveDateTime.utc_now()} 86 | assert {:error, %Changeset{}} = Accounts.update_account(existing_account, bad_params) 87 | 88 | assert existing_account == Repo.get(Account, existing_account.id) 89 | end 90 | end 91 | 92 | describe "delete_account/1" do 93 | test "success: it deletes the account" do 94 | account = Factory.insert(:account) 95 | 96 | assert {:ok, _deteted_account} = Accounts.delete_account(account) 97 | 98 | refute Repo.get(Account, account.id) 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/real_deal_api/schema/account_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RealDealApi.Schema.AccountTest do 2 | use RealDealApi.Support.SchemaCase 3 | alias RealDealApi.Accounts.Account 4 | 5 | @expected_fields_with_types [ 6 | {:id, :binary_id}, 7 | {:email, :string}, 8 | {:hash_password, :string}, 9 | {:inserted_at, :naive_datetime}, 10 | {:updated_at, :naive_datetime} 11 | ] 12 | 13 | @optional [:id, :inserted_at, :updated_at] 14 | 15 | describe "fields and types" do 16 | test "it has the correct fields and types" do 17 | actual_fields_with_types = 18 | for field <- Account.__schema__(:fields) do 19 | type = Account.__schema__(:type, field) 20 | {field, type} 21 | end 22 | 23 | assert MapSet.new(actual_fields_with_types) == MapSet.new(@expected_fields_with_types) 24 | end 25 | end 26 | 27 | describe "changeset/2" do 28 | test "success: returns a valid changeset when given valid arguments" do 29 | valid_params = valid_params(@expected_fields_with_types) 30 | 31 | changeset = Account.changeset(%Account{}, valid_params) 32 | 33 | assert %Changeset{valid?: true, changes: changes} = changeset 34 | 35 | mutated = [:hash_password] 36 | 37 | for {field, _} <- @expected_fields_with_types, field not in mutated do 38 | actual = Map.get(changes, field) 39 | expected = valid_params[Atom.to_string(field)] 40 | 41 | assert actual == expected, 42 | "Values did not match for field: #{field}\nexpected: #{inspect(expected)}\nactual: #{inspect(actual)}" 43 | end 44 | 45 | assert Bcrypt.verify_pass(valid_params["hash_password"], changes.hash_password), 46 | "Password: #{inspect(valid_params["hash_password"])} does not match \nhash: #{inspect(changes.hash_password)}" 47 | end 48 | 49 | test "error: returns an error changeset when given un-castable values" do 50 | invalid_params = %{ 51 | "id" => NaiveDateTime.local_now(), 52 | "email" => NaiveDateTime.local_now(), 53 | "hash_password" => NaiveDateTime.local_now(), 54 | "inserted_at" => "lets put a string here", 55 | "updated_at" => "updated to a string" 56 | } 57 | 58 | assert %Changeset{valid?: false, errors: errors} = 59 | Account.changeset(%Account{}, invalid_params) 60 | 61 | for {field, _} <- @expected_fields_with_types do 62 | assert errors[field], "The field: #{field} is missing from errors." 63 | 64 | {_, meta} = errors[field] 65 | 66 | assert meta[:validation] == :cast, 67 | "The validation type, #{meta[:validation]}, is incorrect." 68 | end 69 | end 70 | 71 | test "error: returns an error changeset when required fields are missing" do 72 | params = %{} 73 | 74 | assert %Changeset{valid?: false, errors: errors} = Account.changeset(%Account{}, params) 75 | 76 | for {field, _} <- @expected_fields_with_types, field not in @optional do 77 | assert errors[field], "The field: #{field} is missing from errors." 78 | 79 | {_, meta} = errors[field] 80 | 81 | assert meta[:validation] == :required, 82 | "The validation type, #{meta[:validation]}, is incorrect." 83 | end 84 | 85 | for field <- @optional do 86 | refute errors[field], "The optional field #{field} is required when it shouldn't be." 87 | end 88 | end 89 | 90 | test "error: returns error changeset when an email address is reused" do 91 | Ecto.Adapters.SQL.Sandbox.checkout(RealDealApi.Repo) 92 | 93 | {:ok, existing_account} = 94 | %Account{} 95 | |> Account.changeset(valid_params(@expected_fields_with_types)) 96 | |> RealDealApi.Repo.insert() 97 | 98 | changeset_with_repeated_email = 99 | %Account{} 100 | |> Account.changeset( 101 | valid_params(@expected_fields_with_types) 102 | |> Map.put("email", existing_account.email) 103 | ) 104 | 105 | assert {:error, %Changeset{valid?: false, errors: errors}} = 106 | RealDealApi.Repo.insert(changeset_with_repeated_email) 107 | 108 | assert errors[:email], "The field :email is missing from errors." 109 | 110 | {_, meta} = errors[:email] 111 | 112 | assert meta[:constraint] == :unique, 113 | "The validation type, #{meta[:validation]}, is incorrect" 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"}, 3 | "castore": {:hex, :castore, "0.1.19", "a2c3e46d62b7f3aa2e6f88541c21d7400381e53704394462b9fd4f06f6d42bb6", [:mix], [], "hexpm", "e96e0161a5dc82ef441da24d5fa74aefc40d920f3a6645d15e1f9f3e66bb2109"}, 4 | "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, 5 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 6 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 7 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 8 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 9 | "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, 10 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 11 | "ecto": {:hex, :ecto, "3.9.2", "017db3bc786ff64271108522c01a5d3f6ba0aea5c84912cfb0dd73bf13684108", [:mix], [{:decimal, "~> 1.6 or ~> 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", "21466d5177e09e55289ac7eade579a642578242c7a3a9f91ad5c6583337a9d15"}, 12 | "ecto_sql": {:hex, :ecto_sql, "3.9.1", "9bd5894eecc53d5b39d0c95180d4466aff00e10679e13a5cfa725f6f85c03c22", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fd470a4fff2e829bbf9dcceb7f3f9f6d1e49b4241e802f614de6b8b67c51118"}, 13 | "elixir_make": {:hex, :elixir_make, "0.7.1", "314f2a5450254db0446ba94cc1ba12a25b83b457f24aa9cc21c128cead5d03aa", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "0f1ad4787b4d7489563351cbf85c9221a852f5441364a2cb3ffd36f2fda7f7fb"}, 14 | "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, 15 | "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, 16 | "gettext": {:hex, :gettext, "0.20.0", "75ad71de05f2ef56991dbae224d35c68b098dd0e26918def5bb45591d5c8d429", [:mix], [], "hexpm", "1c03b177435e93a47441d7f681a7040bd2a816ece9e2666d1c9001035121eb3d"}, 17 | "guardian": {:hex, :guardian, "2.3.1", "2b2d78dc399a7df182d739ddc0e566d88723299bfac20be36255e2d052fd215d", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bbe241f9ca1b09fad916ad42d6049d2600bbc688aba5b3c4a6c82592a54274c3"}, 18 | "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, 19 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 20 | "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, 21 | "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, 22 | "phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"}, 23 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, 24 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, 25 | "phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"}, 26 | "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, 27 | "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, 28 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, 29 | "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, 30 | "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, 31 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 32 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, 33 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 34 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 35 | } 36 | --------------------------------------------------------------------------------