From 7aefa05386abd10f08130c16ac08f82da019b5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Souza?= Date: Sun, 28 Feb 2021 13:47:12 -0300 Subject: [PATCH] lesson 3 --- .credo.exs | 189 ++++++++++++++++++ .formatter.exs | 5 + .gitignore | 28 +++ README.md | 19 ++ config/config.exs | 35 ++++ config/dev.exs | 57 ++++++ config/prod.exs | 55 +++++ config/prod.secret.exs | 41 ++++ config/test.exs | 22 ++ lib/rocketpay.ex | 5 + lib/rocketpay/account.ex | 24 +++ lib/rocketpay/application.ex | 34 ++++ lib/rocketpay/numbers.ex | 18 ++ lib/rocketpay/repo.ex | 5 + lib/rocketpay/user.ex | 41 ++++ lib/rocketpay/users/create.ex | 25 +++ lib/rocketpay_web.ex | 78 ++++++++ lib/rocketpay_web/channels/user_socket.ex | 35 ++++ .../controllers/fallback_controller.ex | 10 + .../controllers/users_controller.ex | 15 ++ .../controllers/welcome_controller.ex | 24 +++ lib/rocketpay_web/endpoint.ex | 52 +++++ lib/rocketpay_web/gettext.ex | 24 +++ lib/rocketpay_web/router.ex | 30 +++ lib/rocketpay_web/telemetry.ex | 55 +++++ lib/rocketpay_web/views/error_helpers.ex | 33 +++ lib/rocketpay_web/views/error_view.ex | 30 +++ lib/rocketpay_web/views/users_view.ex | 14 ++ mix.exs | 65 ++++++ mix.lock | 33 +++ numbers.csv | 1 + priv/gettext/en/LC_MESSAGES/errors.po | 97 +++++++++ priv/gettext/errors.pot | 95 +++++++++ priv/repo/migrations/.formatter.exs | 4 + .../20210223121607_create_user_table.exs | 18 ++ .../20210224163906_create_accounts_table.exs | 14 ++ priv/repo/seeds.exs | 11 + test/rocketpay/numbers_test.exs | 23 +++ test/rocketpay_web/views/error_view_test.exs | 15 ++ test/support/channel_case.ex | 40 ++++ test/support/conn_case.ex | 43 ++++ test/support/data_case.ex | 55 +++++ test/test_helper.exs | 2 + 43 files changed, 1519 insertions(+) create mode 100644 .credo.exs create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/prod.secret.exs create mode 100644 config/test.exs create mode 100644 lib/rocketpay.ex create mode 100644 lib/rocketpay/account.ex create mode 100644 lib/rocketpay/application.ex create mode 100644 lib/rocketpay/numbers.ex create mode 100644 lib/rocketpay/repo.ex create mode 100644 lib/rocketpay/user.ex create mode 100644 lib/rocketpay/users/create.ex create mode 100644 lib/rocketpay_web.ex create mode 100644 lib/rocketpay_web/channels/user_socket.ex create mode 100644 lib/rocketpay_web/controllers/fallback_controller.ex create mode 100644 lib/rocketpay_web/controllers/users_controller.ex create mode 100644 lib/rocketpay_web/controllers/welcome_controller.ex create mode 100644 lib/rocketpay_web/endpoint.ex create mode 100644 lib/rocketpay_web/gettext.ex create mode 100644 lib/rocketpay_web/router.ex create mode 100644 lib/rocketpay_web/telemetry.ex create mode 100644 lib/rocketpay_web/views/error_helpers.ex create mode 100644 lib/rocketpay_web/views/error_view.ex create mode 100644 lib/rocketpay_web/views/users_view.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 numbers.csv create mode 100644 priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 priv/gettext/errors.pot create mode 100644 priv/repo/migrations/.formatter.exs create mode 100644 priv/repo/migrations/20210223121607_create_user_table.exs create mode 100644 priv/repo/migrations/20210224163906_create_accounts_table.exs create mode 100644 priv/repo/seeds.exs create mode 100644 test/rocketpay/numbers_test.exs create mode 100644 test/rocketpay_web/views/error_view_test.exs create mode 100644 test/support/channel_case.ex create mode 100644 test/support/conn_case.ex create mode 100644 test/support/data_case.ex create mode 100644 test/test_helper.exs diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..3607e1a --- /dev/null +++ b/.credo.exs @@ -0,0 +1,189 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + {Credo.Check.Design.TagFIXME, []}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, false}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + # {Credo.Check.Refactor.MapInto, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + # {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.MixEnv, false}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.UnsafeExec, []}, + + # + # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) + + # + # Controversial and experimental checks (opt-in, just replace `false` with `[]`) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, + {Credo.Check.Consistency.UnusedVariableNames, false}, + {Credo.Check.Design.DuplicatedCode, false}, + {Credo.Check.Readability.AliasAs, false}, + {Credo.Check.Readability.BlockPipe, false}, + {Credo.Check.Readability.ImplTrue, false}, + {Credo.Check.Readability.MultiAlias, false}, + {Credo.Check.Readability.SeparateAliasRequire, false}, + {Credo.Check.Readability.SinglePipe, false}, + {Credo.Check.Readability.Specs, false}, + {Credo.Check.Readability.StrictModuleLayout, false}, + {Credo.Check.Readability.WithCustomTaggedTuple, false}, + {Credo.Check.Refactor.ABCSize, false}, + {Credo.Check.Refactor.AppendSingleItem, false}, + {Credo.Check.Refactor.DoubleBooleanNegation, false}, + {Credo.Check.Refactor.ModuleDependencies, false}, + {Credo.Check.Refactor.NegatedIsNil, false}, + {Credo.Check.Refactor.PipeChainStart, false}, + {Credo.Check.Refactor.VariableRebinding, false}, + {Credo.Check.Warning.LeakyEnvironment, false}, + {Credo.Check.Warning.MapGetUnsafePass, false}, + {Credo.Check.Warning.UnsafeToAtom, false} + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + ] +} diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..8a6391c --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:ecto, :phoenix], + inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], + subdirectories: ["priv/*/migrations"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95279e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# 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 3rd-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"). +rocketpay-*.tar + +# Since we are building assets from assets/, +# we ignore priv/static. You may want to comment +# this depending on your deployment strategy. +/priv/static/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a806a0 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Rocketpay + +To start your Phoenix server: + + * Install dependencies with `mix deps.get` + * Create and migrate your database with `mix ecto.setup` + * Start Phoenix endpoint with `mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + + * Official website: https://www.phoenixframework.org/ + * Guides: https://hexdocs.pm/phoenix/overview.html + * Docs: https://hexdocs.pm/phoenix + * Forum: https://elixirforum.com/c/phoenix-forum + * Source: https://github.com/phoenixframework/phoenix diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..4aa4f25 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,35 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +use Mix.Config + +config :rocketpay, + ecto_repos: [Rocketpay.Repo] + +# Configures the endpoint +config :rocketpay, RocketpayWeb.Endpoint, + url: [host: "localhost"], + secret_key_base: "YC4F013+4lHWU5oaxTEZfDrXRdbLXBeqhI0ma+I4aDSciDPfef2+0tvTmCa1b749", + render_errors: [view: RocketpayWeb.ErrorView, accepts: ~w(json), layout: false], + pubsub_server: Rocketpay.PubSub, + live_view: [signing_salt: "JZ7OW6fN"] + +config :rocketpay, Rocketpay.Repo, + migration_primary_key: [type: :binary_id], + migration_foreign_key: [type: :binary_id] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..9d4f99e --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,57 @@ +use Mix.Config + +# Configure your database +config :rocketpay, Rocketpay.Repo, + username: "postgres", + password: "password", + database: "rocketpay_dev", + hostname: "localhost", + show_sensitive_data_on_connection_error: true, + pool_size: 10 + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with webpack to recompile .js and .css sources. +config :rocketpay, RocketpayWeb.Endpoint, + http: [port: 4000], + debug_errors: true, + code_reloader: true, + check_origin: false, + watchers: [] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Note that this task requires Erlang/OTP 20 or later. +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..72359fc --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,55 @@ +use Mix.Config + +# For production, don't forget to configure the url host +# to something meaningful, Phoenix uses this information +# when generating URLs. +# +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix phx.digest` task, +# which you should run after static files are built and +# before starting your production server. +config :rocketpay, RocketpayWeb.Endpoint, + url: [host: "example.com", port: 80], + cache_static_manifest: "priv/static/cache_manifest.json" + +# Do not print debug messages in production +config :logger, level: :info + +# ## SSL Support +# +# To get SSL working, you will need to add the `https` key +# to the previous section and set your `:url` port to 443: +# +# config :rocketpay, RocketpayWeb.Endpoint, +# ... +# url: [host: "example.com", port: 443], +# https: [ +# port: 443, +# cipher_suite: :strong, +# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), +# certfile: System.get_env("SOME_APP_SSL_CERT_PATH"), +# transport_options: [socket_opts: [:inet6]] +# ] +# +# The `cipher_suite` is set to `:strong` to support only the +# latest and more secure SSL ciphers. This means old browsers +# and clients may not be supported. You can set it to +# `:compatible` for wider support. +# +# `:keyfile` and `:certfile` expect an absolute path to the key +# and cert in disk or a relative path inside priv, for example +# "priv/ssl/server.key". For all supported SSL configuration +# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 +# +# We also recommend setting `force_ssl` in your endpoint, ensuring +# no data is ever sent via http, always redirecting to https: +# +# config :rocketpay, RocketpayWeb.Endpoint, +# force_ssl: [hsts: true] +# +# Check `Plug.SSL` for all available options in `force_ssl`. + +# Finally import the config/prod.secret.exs which loads secrets +# and configuration from environment variables. +import_config "prod.secret.exs" diff --git a/config/prod.secret.exs b/config/prod.secret.exs new file mode 100644 index 0000000..7dbcff4 --- /dev/null +++ b/config/prod.secret.exs @@ -0,0 +1,41 @@ +# In this file, we load production configuration and secrets +# from environment variables. You can also hardcode secrets, +# although such is generally not recommended and you have to +# remember to add this file to your .gitignore. +use Mix.Config + +database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + +config :rocketpay, Rocketpay.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") + +secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + +config :rocketpay, RocketpayWeb.Endpoint, + http: [ + port: String.to_integer(System.get_env("PORT") || "4000"), + transport_options: [socket_opts: [:inet6]] + ], + secret_key_base: secret_key_base + +# ## Using releases (Elixir v1.9+) +# +# If you are doing OTP releases, you need to instruct Phoenix +# to start each relevant endpoint: +# +# config :rocketpay, RocketpayWeb.Endpoint, server: true +# +# Then you can assemble a release by calling `mix release`. +# See `mix help release` for more information. diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..13ff8a1 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,22 @@ +use Mix.Config + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +config :rocketpay, Rocketpay.Repo, + username: "postgres", + password: "password", + database: "rocketpay_test#{System.get_env("MIX_TEST_PARTITION")}", + hostname: "localhost", + pool: Ecto.Adapters.SQL.Sandbox + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :rocketpay, RocketpayWeb.Endpoint, + http: [port: 4002], + server: false + +# Print only warnings and errors during test +config :logger, level: :warn diff --git a/lib/rocketpay.ex b/lib/rocketpay.ex new file mode 100644 index 0000000..def6357 --- /dev/null +++ b/lib/rocketpay.ex @@ -0,0 +1,5 @@ +defmodule Rocketpay do + alias Rocketpay.Users.Create, as: UserCreate + + defdelegate create_user(params), to: UserCreate, as: :call +end diff --git a/lib/rocketpay/account.ex b/lib/rocketpay/account.ex new file mode 100644 index 0000000..54fabea --- /dev/null +++ b/lib/rocketpay/account.ex @@ -0,0 +1,24 @@ +defmodule Rocketpay.Account do + use Ecto.Schema + import Ecto.Changeset + + alias Rocketpay.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + @required_params [:balance, :user_id] + + schema "accounts" do + field :balance, :decimal + belongs_to :user, User + + timestamps() + end + + def changeset(params) do + %__MODULE__{} + |> cast(params, @required_params) + |> validate_required(@required_params) + |> check_constraint(:balance, name: :balance_must_be_positive_or_zero) + end +end diff --git a/lib/rocketpay/application.ex b/lib/rocketpay/application.ex new file mode 100644 index 0000000..d1fb2fe --- /dev/null +++ b/lib/rocketpay/application.ex @@ -0,0 +1,34 @@ +defmodule Rocketpay.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + def start(_type, _args) do + children = [ + # Start the Ecto repository + Rocketpay.Repo, + # Start the Telemetry supervisor + RocketpayWeb.Telemetry, + # Start the PubSub system + {Phoenix.PubSub, name: Rocketpay.PubSub}, + # Start the Endpoint (http/https) + RocketpayWeb.Endpoint + # Start a worker by calling: Rocketpay.Worker.start_link(arg) + # {Rocketpay.Worker, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Rocketpay.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + def config_change(changed, _new, removed) do + RocketpayWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/rocketpay/numbers.ex b/lib/rocketpay/numbers.ex new file mode 100644 index 0000000..1137251 --- /dev/null +++ b/lib/rocketpay/numbers.ex @@ -0,0 +1,18 @@ +defmodule Rocketpay.Numbers do + def sum_from_file(filename) do + "#{filename}.csv" + |> File.read() + |> handle_file() + end + + defp handle_file({:ok, result}) do + result = + result + |> String.split(",") + |> Stream.map(fn number -> String.to_integer(number) end) + |> Enum.sum() + {:ok, %{result: result}} + end + + defp handle_file({:error, _reason}), do: {:error, %{message: "fail"}} +end diff --git a/lib/rocketpay/repo.ex b/lib/rocketpay/repo.ex new file mode 100644 index 0000000..e8a057c --- /dev/null +++ b/lib/rocketpay/repo.ex @@ -0,0 +1,5 @@ +defmodule Rocketpay.Repo do + use Ecto.Repo, + otp_app: :rocketpay, + adapter: Ecto.Adapters.Postgres +end diff --git a/lib/rocketpay/user.ex b/lib/rocketpay/user.ex new file mode 100644 index 0000000..955eb4e --- /dev/null +++ b/lib/rocketpay/user.ex @@ -0,0 +1,41 @@ +defmodule Rocketpay.User do + use Ecto.Schema + import Ecto.Changeset + + alias Ecto.Changeset + alias Rocketpay.Account + + @primary_key {:id, :binary_id, autogenerate: true} + @required_params [:name, :age, :email, :password, :nickname] + + schema "users" do + field :name, :string + field :age, :integer + field :email, :string + field :password, :string, virtual: true + field :password_hash, :string + field :nickname, :string + + has_one :account, Account + + timestamps() + end + + def changeset(params) do + %__MODULE__{} + |> cast(params, @required_params) + |> validate_required(@required_params) + |> validate_length(:password, min: 6) + |> validate_number(:age, greater_than_or_equal_to: 18) + |> validate_format(:email, ~r/@/) + |> unique_constraint([:email]) + |> unique_constraint([:nickname]) + |> put_password_hash() + end + + defp put_password_hash(%Changeset{valid?: true, changes: %{password: password}} = changeset) do + change(changeset, Bcrypt.add_hash(password)) + end + + defp put_password_hash(changeset), do: changeset +end diff --git a/lib/rocketpay/users/create.ex b/lib/rocketpay/users/create.ex new file mode 100644 index 0000000..00f4764 --- /dev/null +++ b/lib/rocketpay/users/create.ex @@ -0,0 +1,25 @@ +defmodule Rocketpay.Users.Create do + alias Rocketpay.{User, Account} + alias Ecto.Multi + + def call(params) do + Multi.new() + |> Multi.insert(:create_user, User.changeset(params)) + |> Multi.run(:create_account, fn repo, %{create_user: user} -> + insert_account(repo, user) + end) + end + + defp insert_account(repo, user) do + user.id + |> account_changeset() + |> repo.insert() + end + + defp account_changeset(user_id) do + params = %{user_id: user_id, balance: "0.00"} + + Account.changeset(params) + + end +end diff --git a/lib/rocketpay_web.ex b/lib/rocketpay_web.ex new file mode 100644 index 0000000..d22bf2c --- /dev/null +++ b/lib/rocketpay_web.ex @@ -0,0 +1,78 @@ +defmodule RocketpayWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, views, channels and so on. + + This can be used in your application as: + + use RocketpayWeb, :controller + use RocketpayWeb, :view + + The definitions below will be executed for every view, + controller, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define any helper function in modules + and import those modules here. + """ + + def controller do + quote do + use Phoenix.Controller, namespace: RocketpayWeb + + import Plug.Conn + import RocketpayWeb.Gettext + alias RocketpayWeb.Router.Helpers, as: Routes + end + end + + def view do + quote do + use Phoenix.View, + root: "lib/rocketpay_web/templates", + namespace: RocketpayWeb + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] + + # Include shared imports and aliases for views + unquote(view_helpers()) + end + end + + def router do + quote do + use Phoenix.Router + + import Plug.Conn + import Phoenix.Controller + end + end + + def channel do + quote do + use Phoenix.Channel + import RocketpayWeb.Gettext + end + end + + defp view_helpers do + quote do + # Import basic rendering functionality (render, render_layout, etc) + import Phoenix.View + + import RocketpayWeb.ErrorHelpers + import RocketpayWeb.Gettext + alias RocketpayWeb.Router.Helpers, as: Routes + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/rocketpay_web/channels/user_socket.ex b/lib/rocketpay_web/channels/user_socket.ex new file mode 100644 index 0000000..b1abd7a --- /dev/null +++ b/lib/rocketpay_web/channels/user_socket.ex @@ -0,0 +1,35 @@ +defmodule RocketpayWeb.UserSocket do + use Phoenix.Socket + + ## Channels + # channel "room:*", RocketpayWeb.RoomChannel + + # Socket params are passed from the client and can + # be used to verify and authenticate a user. After + # verification, you can put default assigns into + # the socket that will be set for all channels, ie + # + # {:ok, assign(socket, :user_id, verified_user_id)} + # + # To deny connection, return `:error`. + # + # See `Phoenix.Token` documentation for examples in + # performing token verification on connect. + @impl true + def connect(_params, socket, _connect_info) do + {:ok, socket} + end + + # Socket id's are topics that allow you to identify all sockets for a given user: + # + # def id(socket), do: "user_socket:#{socket.assigns.user_id}" + # + # Would allow you to broadcast a "disconnect" event and terminate + # all active sockets and channels for a given user: + # + # RocketpayWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) + # + # Returning `nil` makes this socket anonymous. + @impl true + def id(_socket), do: nil +end diff --git a/lib/rocketpay_web/controllers/fallback_controller.ex b/lib/rocketpay_web/controllers/fallback_controller.ex new file mode 100644 index 0000000..00e9464 --- /dev/null +++ b/lib/rocketpay_web/controllers/fallback_controller.ex @@ -0,0 +1,10 @@ +defmodule RocketpayWeb.FallbackController do + use RocketpayWeb, :controller + + def call(conn, {:error, result}) do + conn + |> put_status(:bad_request) + |> put_view(RocketpayWeb.ErrorView) + |> render("400.json", result: result) + end +end diff --git a/lib/rocketpay_web/controllers/users_controller.ex b/lib/rocketpay_web/controllers/users_controller.ex new file mode 100644 index 0000000..cfe683e --- /dev/null +++ b/lib/rocketpay_web/controllers/users_controller.ex @@ -0,0 +1,15 @@ +defmodule RocketpayWeb.UsersController do + use RocketpayWeb, :controller + + alias Rocketpay.User + + action_fallback RocketpayWeb.FallbackController + + def create(conn, params) do + with {:ok, %User{} = user} <- Rocketpay.create_user(params) do + conn + |> put_status(:created) + |> render("create.json", user: user) + end + end +end diff --git a/lib/rocketpay_web/controllers/welcome_controller.ex b/lib/rocketpay_web/controllers/welcome_controller.ex new file mode 100644 index 0000000..da70f46 --- /dev/null +++ b/lib/rocketpay_web/controllers/welcome_controller.ex @@ -0,0 +1,24 @@ +defmodule RocketpayWeb.WelcomeController do + use RocketpayWeb, :controller + + alias Rocketpay.Numbers + + def index(conn, %{"filename" => filename}) do + filename + |> Numbers.sum_from_file() + |> handle_response(conn) + # text(conn, "welcome to the Rocketpay API") + end + + defp handle_response({:ok, %{result: result}}, conn) do + conn + |> put_status(:ok) + |> json(%{message: "Welcome to API. Here is your number #{result}"}) + end + + defp handle_response({:error, reason}, conn) do + conn + |> put_status(:bad_request) + |> json(reason) + end +end diff --git a/lib/rocketpay_web/endpoint.ex b/lib/rocketpay_web/endpoint.ex new file mode 100644 index 0000000..eef9528 --- /dev/null +++ b/lib/rocketpay_web/endpoint.ex @@ -0,0 +1,52 @@ +defmodule RocketpayWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :rocketpay + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_rocketpay_key", + signing_salt: "OChs6IbA" + ] + + socket "/socket", RocketpayWeb.UserSocket, + websocket: true, + longpoll: false + + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :rocketpay, + gzip: false, + only: ~w(css fonts images js favicon.ico robots.txt) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :rocketpay + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug RocketpayWeb.Router +end diff --git a/lib/rocketpay_web/gettext.ex b/lib/rocketpay_web/gettext.ex new file mode 100644 index 0000000..697f3ed --- /dev/null +++ b/lib/rocketpay_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule RocketpayWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import RocketpayWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext, otp_app: :rocketpay +end diff --git a/lib/rocketpay_web/router.ex b/lib/rocketpay_web/router.ex new file mode 100644 index 0000000..c71cdf6 --- /dev/null +++ b/lib/rocketpay_web/router.ex @@ -0,0 +1,30 @@ +defmodule RocketpayWeb.Router do + use RocketpayWeb, :router + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/api", RocketpayWeb do + pipe_through :api + + get "/:filename", WelcomeController, :index + post "/users", UsersController, :create + end + + # Enables LiveDashboard only for development + # + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + if Mix.env() in [:dev, :test] do + import Phoenix.LiveDashboard.Router + + scope "/" do + pipe_through [:fetch_session, :protect_from_forgery] + live_dashboard "/dashboard", metrics: RocketpayWeb.Telemetry + end + end +end diff --git a/lib/rocketpay_web/telemetry.ex b/lib/rocketpay_web/telemetry.ex new file mode 100644 index 0000000..11281f3 --- /dev/null +++ b/lib/rocketpay_web/telemetry.ex @@ -0,0 +1,55 @@ +defmodule RocketpayWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("rocketpay.repo.query.total_time", unit: {:native, :millisecond}), + summary("rocketpay.repo.query.decode_time", unit: {:native, :millisecond}), + summary("rocketpay.repo.query.query_time", unit: {:native, :millisecond}), + summary("rocketpay.repo.query.queue_time", unit: {:native, :millisecond}), + summary("rocketpay.repo.query.idle_time", unit: {:native, :millisecond}), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {RocketpayWeb, :count_users, []} + ] + end +end diff --git a/lib/rocketpay_web/views/error_helpers.ex b/lib/rocketpay_web/views/error_helpers.ex new file mode 100644 index 0000000..b6a55c4 --- /dev/null +++ b/lib/rocketpay_web/views/error_helpers.ex @@ -0,0 +1,33 @@ +defmodule RocketpayWeb.ErrorHelpers do + @moduledoc """ + Conveniences for translating and building error messages. + """ + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate "is invalid" in the "errors" domain + # dgettext("errors", "is invalid") + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # Because the error messages we show in our forms and APIs + # are defined inside Ecto, we need to translate them dynamically. + # This requires us to call the Gettext module passing our gettext + # backend as first argument. + # + # Note we use the "errors" domain, which means translations + # should be written to the errors.po file. The :count option is + # set by Ecto and indicates we should also apply plural rules. + if count = opts[:count] do + Gettext.dngettext(RocketpayWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(RocketpayWeb.Gettext, "errors", msg, opts) + end + end +end diff --git a/lib/rocketpay_web/views/error_view.ex b/lib/rocketpay_web/views/error_view.ex new file mode 100644 index 0000000..1c26277 --- /dev/null +++ b/lib/rocketpay_web/views/error_view.ex @@ -0,0 +1,30 @@ +defmodule RocketpayWeb.ErrorView do + use RocketpayWeb, :view + + import Ecto.Changeset, only: [traverse_errors: 2] + + # If you want to customize a particular status code + # for a certain format, you may uncomment below. + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def template_not_found(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end + + def render("400.json", %{result: %Ecto.Changeset{} = changeset}) do + %{message: translate_errors(changeset)} + end + + defp translate_errors(changeset) do + traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/lib/rocketpay_web/views/users_view.ex b/lib/rocketpay_web/views/users_view.ex new file mode 100644 index 0000000..95f6d87 --- /dev/null +++ b/lib/rocketpay_web/views/users_view.ex @@ -0,0 +1,14 @@ +defmodule RocketpayWeb.UsersView do + alias Rocketpay.User + + def render("create.json", %{user: %User{id: id, name: name, nickname: nickname}}) do + %{ + message: "User created", + user: %{ + id: id, + name: name, + nickname: nickname + } + } + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..6fa67d1 --- /dev/null +++ b/mix.exs @@ -0,0 +1,65 @@ +defmodule Rocketpay.MixProject do + use Mix.Project + + def project do + [ + app: :rocketpay, + version: "0.1.0", + elixir: "~> 1.7", + elixirc_paths: elixirc_paths(Mix.env()), + compilers: [:phoenix, :gettext] ++ Mix.compilers(), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Rocketpay.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.5.7"}, + {:phoenix_ecto, "~> 4.1"}, + {:ecto_sql, "~> 3.4"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_live_dashboard, "~> 0.4"}, + {:telemetry_metrics, "~> 0.4"}, + {:telemetry_poller, "~> 0.4"}, + {:gettext, "~> 0.11"}, + {:jason, "~> 1.0"}, + {:plug_cowboy, "~> 2.0"}, + {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, + {:bcrypt_elixir, "~> 2.0"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..abbee8a --- /dev/null +++ b/mix.lock @@ -0,0 +1,33 @@ +%{ + "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.3.0", "6cb662d5c1b0a8858801cf20997bd006e7016aa8c52959c9ef80e0f34fb60b7a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2c81d61d4f6ed0e5cf7bf27a9109b791ff216a1034b3d541327484f46dd43769"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "comeonin": {:hex, :comeonin, "5.3.2", "5c2f893d05c56ae3f5e24c1b983c2d5dfb88c6d979c9287a76a7feb1e1d8d646", [:mix], [], "hexpm", "d0993402844c49539aeadb3fe46a3c9bd190f1ecf86b6f9ebd71957534c95f04"}, + "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, + "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, + "credo": {:hex, :credo, "1.5.5", "e8f422026f553bc3bebb81c8e8bf1932f498ca03339856c7fec63d3faac8424b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd8623ab7091956a855dc9f3062486add9c52d310dfd62748779c4315d8247de"}, + "db_connection": {:hex, :db_connection, "2.3.1", "4c9f3ed1ef37471cbdd2762d6655be11e38193904d9c5c1c9389f1b891a3088e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "abaab61780dde30301d840417890bd9f74131041afd02174cf4e10635b3a63f5"}, + "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, + "ecto": {:hex, :ecto, "3.5.8", "8ebf12be6016cb99313348ba7bb4612f4114b9a506d6da79a2adc7ef449340bc", [: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", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea0be182ea8922eb7742e3ae8e71b67ee00ae177de1bf76210299a5f16ba4c77"}, + "ecto_sql": {:hex, :ecto_sql, "3.5.4", "a9e292c40bd79fff88885f95f1ecd7b2516e09aa99c7dd0201aa84c54d2358e4", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1fff1a28a898d7bbef263f1f3ea425b04ba9f33816d843238c84eff883347343"}, + "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, + "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, + "mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"}, + "phoenix": {:hex, :phoenix, "1.5.7", "2923bb3af924f184459fe4fa4b100bd25fa6468e69b2803dfae82698269aa5e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "774cd64417c5a3788414fdbb2be2eb9bcd0c048d9e6ad11a0c1fd67b7c0d0978"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"}, + "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.4.0", "87990e68b60213d7487e65814046f9a2bed4a67886c943270125913499b3e5c3", [:mix], [{:ecto_psql_extras, "~> 0.4.1 or ~> 0.5", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "8d52149e58188e9e4497cc0d8900ab94d9b66f96998ec38c47c7a4f8f4f50e57"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.15.4", "86908dc9603cc81c07e84725ee42349b5325cb250c9c20d3533856ff18dbb7dc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35d78f3c35fe10a995dca5f4ab50165b7a90cbe02e23de245381558f821e9462"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, + "plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.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", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.1", "5c854427528bf61d159855cedddffc0625e2228b5f30eff76d5a4de42d896ef4", [:mix], [], "hexpm", "6961c0e17febd9d0bfa89632d391d2545d2e0eb73768f5f50305a23961d8782c"}, + "postgrex": {:hex, :postgrex, "0.15.8", "f5e782bbe5e8fa178d5e3cd1999c857dc48eda95f0a4d7f7bd92a50e84a0d491", [:mix], [{:connection, "~> 1.0", [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]}], "hexpm", "698fbfacea34c4cf22c8281abeb5cf68d99628d541874f085520ab3b53d356fe"}, + "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, + "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.0", "da9d49ee7e6bb1c259d36ce6539cd45ae14d81247a2b0c90edf55e2b50507f7b", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5cfe67ad464b243835512aa44321cee91faed6ea868d7fb761d7016e02915c3d"}, + "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"}, +} diff --git a/numbers.csv b/numbers.csv new file mode 100644 index 0000000..38fd022 --- /dev/null +++ b/numbers.csv @@ -0,0 +1 @@ +1,2,3,4,8,9,10 \ No newline at end of file diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..a589998 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,97 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 0000000..39a220b --- /dev/null +++ b/priv/gettext/errors.pot @@ -0,0 +1,95 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/migrations/20210223121607_create_user_table.exs b/priv/repo/migrations/20210223121607_create_user_table.exs new file mode 100644 index 0000000..6b804db --- /dev/null +++ b/priv/repo/migrations/20210223121607_create_user_table.exs @@ -0,0 +1,18 @@ +defmodule Rocketpay.Repo.Migrations.CreateUserTable do + use Ecto.Migration + + def change do + create table :users do + add :name, :string + add :age, :integer + add :email, :string + add :password_hash, :string + add :nickname, :string + + timestamps() + end + + create unique_index(:users, [:email]) + create unique_index(:users, [:nickname]) + end +end diff --git a/priv/repo/migrations/20210224163906_create_accounts_table.exs b/priv/repo/migrations/20210224163906_create_accounts_table.exs new file mode 100644 index 0000000..8d9f728 --- /dev/null +++ b/priv/repo/migrations/20210224163906_create_accounts_table.exs @@ -0,0 +1,14 @@ +defmodule Rocketpay.Repo.Migrations.CreateAccountsTable do + use Ecto.Migration + + def change do + create table :accounts do + add :balance, :decimal + add :user_id, references(:users, type: :binary_id) + + timestamps() + end + + create constraint(:accounts, :balance_must_be_positive_or_zero, check: "balance >= 0") + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..cd631ac --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# Rocketpay.Repo.insert!(%Rocketpay.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/test/rocketpay/numbers_test.exs b/test/rocketpay/numbers_test.exs new file mode 100644 index 0000000..d47ac1e --- /dev/null +++ b/test/rocketpay/numbers_test.exs @@ -0,0 +1,23 @@ +defmodule Rocketpay.NumbersTest do + use ExUnit.Case + + alias Rocketpay.Numbers + + describe "sum_from_file/1" do + test "when there is a file with the given name, returns the sum of numbers" do + response = Numbers.sum_from_file("numbers") + + expected_response = {:ok, %{result: 37}} + + assert response == expected_response + end + + test "when there is no a file with the given name, returns an error" do + response = Numbers.sum_from_file("banana") + + expected_response = {:error, %{message: "fail"}} + + assert response == expected_response + end + end +end diff --git a/test/rocketpay_web/views/error_view_test.exs b/test/rocketpay_web/views/error_view_test.exs new file mode 100644 index 0000000..829d163 --- /dev/null +++ b/test/rocketpay_web/views/error_view_test.exs @@ -0,0 +1,15 @@ +defmodule RocketpayWeb.ErrorViewTest do + use RocketpayWeb.ConnCase, async: true + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + test "renders 404.json" do + assert render(RocketpayWeb.ErrorView, "404.json", []) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500.json" do + assert render(RocketpayWeb.ErrorView, "500.json", []) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex new file mode 100644 index 0000000..7e86660 --- /dev/null +++ b/test/support/channel_case.ex @@ -0,0 +1,40 @@ +defmodule RocketpayWeb.ChannelCase do + @moduledoc """ + This module defines the test case to be used by + channel tests. + + Such tests rely on `Phoenix.ChannelTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use RocketpayWeb.ChannelCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with channels + import Phoenix.ChannelTest + import RocketpayWeb.ChannelCase + + # The default endpoint for testing + @endpoint RocketpayWeb.Endpoint + end + end + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Rocketpay.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Rocketpay.Repo, {:shared, self()}) + end + + :ok + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..1489724 --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,43 @@ +defmodule RocketpayWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use RocketpayWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import RocketpayWeb.ConnCase + + alias RocketpayWeb.Router.Helpers, as: Routes + + # The default endpoint for testing + @endpoint RocketpayWeb.Endpoint + end + end + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Rocketpay.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Rocketpay.Repo, {:shared, self()}) + end + + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..7b0ca21 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,55 @@ +defmodule Rocketpay.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use Rocketpay.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias Rocketpay.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import Rocketpay.DataCase + end + end + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Rocketpay.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Rocketpay.Repo, {:shared, self()}) + end + + :ok + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..49639cb --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Rocketpay.Repo, :manual)