diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7f26b12 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +charset = utf-8 + +[{!*.md,!*.markdown}] +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aebfcfe --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build + +# If you run "mix test --cover", coverage assets end up here. +/cover + +# The directory Mix downloads your dependencies sources to. +/deps + +# 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 + +api_example/deps + +/slap +report.html +.DS_Store + +*.zip +*.bz2 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..841c653 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: elixir +elixir: +- 1.4 +otp_release: +- 18.2.1 +script: +- mix test +- mix escript.build +deploy: + provider: releases + skip_cleanup: true + api_key: + secure: L6QWceDiVkuXCwv3g6crVYp6vxScmT0Sm4EJL4z5HKc6UMDPFspNeeA5tMAXk0plJPkliS+W189cZALDok9fH1dIkGcx4WY4vbxLJ0lXVEEWstYGNOZsMj22sDoIRp+3P9IdInAtStcRyr9TCiODtOw68TkdSUMUyqbIHGA+VLZKnzK5wAniueCFjI1vU+Q6leddLIGQFbpL7TVt+ptIW3x9olfK0Fpq5j7ENpVV8kvaVPKoBFeIMZLi6IpylH5avHgVK4KY8TbbhStuvGFQwymLGb6HJSYDQLZSJuiOfkEQH4v17s0Stt3vzzoHVgZxIAkshqZelII5bZ2AVw16Deu+6ywlWTPyOQmSRZQ6qQioABWu9JqoAu+6hQytlqzTfBaf85ajBlsPnEORGMnsPQACcJZEvGvxxAVG5bL9bndqPownZ0fqBVpZVjxWzc7UABgBHnHUGS/KfMBZwLGgu2r4y7EEzatfVejl3Z0CExapDMmPuMir622eje1Zwr1dqfA9/vYz1HhEe35IpjkBb+a+w9LVh7qE5vwgO/cIAUMTgqi0dLt5xfZs50QWvhBhm1iBsQiIvKiMbeT17maoKxDXpx6Co1uDA65hlM1LDGYJJ9BflIlTAPDq2WaRyjrOek7LVxzNYgpxomxEYtnx0KS8PNTUgX+ZMnSrD858B7g= + file: slap + on: + repo: laibulle/slap + branch: travis + tags: true diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7cea7b --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Slap + +[![Build Status](https://travis-ci.org/laibulle/slap.svg?branch=master)](https://travis-ci.org/laibulle/slap) + +Slap is a load testing tool for developers. + +__Features__ + +- Real time reporting +- Script scenario with Elixir + +__TODO__ + +- Distribute between several OTP instances +- Integrate script dependencies +- Parse CLI arguments +- Documentation + +![Report](https://raw.githubusercontent.com/laibulle/slap/master/doc/bar.png) + +![Plot](https://raw.githubusercontent.com/laibulle/slap/master/doc/plot.png) + + +## Getting started + +First of all you have to start the fake server that will handle the traffic. + +``` +cd examples/api +mix deps.get +mix phoenix.server +``` + +Build Slap with the following command +```bash +mix escript.build && chmod +x slap +``` + +Now you can run the example scenario with the Slap binary. +``` +slap examples/scene1.exs +``` diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..4a979b0 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,30 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +use Mix.Config + +# This configuration is loaded before any dependency and is restricted +# to this project. If another project depends on this project, this +# file won't be loaded nor affect the parent project. For this reason, +# if you want to provide default values for your application for +# 3rd-party users, it should be done in your "mix.exs" file. + +# You can configure for your application as: +# +# config :slap, key: :value +# +# And access this configuration in your application as: +# +# Application.get_env(:slap, :key) +# +# Or configure a 3rd-party app: +# +# config :logger, level: :info +# + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# by uncommenting the line below and defining dev.exs, test.exs and such. +# Configuration from the imported file will override the ones defined +# here (which is why it is important to import them last). +# +# import_config "#{Mix.env}.exs" diff --git a/doc/bar.png b/doc/bar.png new file mode 100644 index 0000000..29ee6a9 Binary files /dev/null and b/doc/bar.png differ diff --git a/doc/plot.png b/doc/plot.png new file mode 100644 index 0000000..f1076f9 Binary files /dev/null and b/doc/plot.png differ diff --git a/examples/api/.gitignore b/examples/api/.gitignore new file mode 100644 index 0000000..ee144dd --- /dev/null +++ b/examples/api/.gitignore @@ -0,0 +1,16 @@ +# App artifacts +/_build +/db +/deps +/*.ez + +# Generated on crash by the VM +erl_crash.dump + +# The config/prod.secret.exs file by default contains sensitive +# data and you should not commit it into version control. +# +# Alternatively, you may comment the line below and commit the +# secrets file as long as you replace its contents by environment +# variables. +/config/prod.secret.exs diff --git a/examples/api/README.md b/examples/api/README.md new file mode 100644 index 0000000..6553893 --- /dev/null +++ b/examples/api/README.md @@ -0,0 +1,18 @@ +# ApiExample + +To start your Phoenix app: + + * Install dependencies with `mix deps.get` + * Start Phoenix endpoint with `mix phoenix.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](http://www.phoenixframework.org/docs/deployment). + +## Learn more + + * Official website: http://www.phoenixframework.org/ + * Guides: http://phoenixframework.org/docs/overview + * Docs: https://hexdocs.pm/phoenix + * Mailing list: http://groups.google.com/group/phoenix-talk + * Source: https://github.com/phoenixframework/phoenix diff --git a/examples/api/config/config.exs b/examples/api/config/config.exs new file mode 100644 index 0000000..3b20502 --- /dev/null +++ b/examples/api/config/config.exs @@ -0,0 +1,23 @@ +# 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. +use Mix.Config + +# Configures the endpoint +config :api_example, ApiExample.Endpoint, + url: [host: "localhost"], + secret_key_base: "9bNtISoZVA86Ihjmz116clpFR/bY0zvBqDBrISgwuHditIEZyN7bS/DVOl3TA2ll", + render_errors: [view: ApiExample.ErrorView, accepts: ~w(html json)], + pubsub: [name: ApiExample.PubSub, + adapter: Phoenix.PubSub.PG2] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# 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/examples/api/config/dev.exs b/examples/api/config/dev.exs new file mode 100644 index 0000000..3486d6a --- /dev/null +++ b/examples/api/config/dev.exs @@ -0,0 +1,33 @@ +use Mix.Config + +# 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 brunch.io to recompile .js and .css sources. +config :api_example, ApiExample.Endpoint, + http: [port: 4000], + debug_errors: true, + code_reloader: true, + check_origin: false, + watchers: [] + + +# Watch static and templates for browser reloading. +config :api_example, ApiExample.Endpoint, + live_reload: [ + patterns: [ + ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, + ~r{priv/gettext/.*(po)$}, + ~r{web/views/.*(ex)$}, + ~r{web/templates/.*(eex)$} + ] + ] + +# 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 diff --git a/examples/api/config/prod.exs b/examples/api/config/prod.exs new file mode 100644 index 0000000..0582aa7 --- /dev/null +++ b/examples/api/config/prod.exs @@ -0,0 +1,61 @@ +use Mix.Config + +# For production, we configure the host to read the PORT +# from the system environment. Therefore, you will need +# to set PORT=80 before running your server. +# +# You should also configure the url host to something +# meaningful, we use this information when generating URLs. +# +# Finally, we also include the path to a manifest +# containing the digested version of static files. This +# manifest is generated by the mix phoenix.digest task +# which you typically run after static files are built. +config :api_example, ApiExample.Endpoint, + http: [port: {:system, "PORT"}], + url: [host: "example.com", port: 80], + cache_static_manifest: "priv/static/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 :api_example, ApiExample.Endpoint, +# ... +# url: [host: "example.com", port: 443], +# https: [port: 443, +# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), +# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] +# +# Where those two env variables return an absolute path to +# the key and cert in disk or a relative path inside priv, +# for example "priv/ssl/server.key". +# +# We also recommend setting `force_ssl`, ensuring no data is +# ever sent via http, always redirecting to https: +# +# config :api_example, ApiExample.Endpoint, +# force_ssl: [hsts: true] +# +# Check `Plug.SSL` for all available options in `force_ssl`. + +# ## Using releases +# +# If you are doing OTP releases, you need to instruct Phoenix +# to start the server for all endpoints: +# +# config :phoenix, :serve_endpoints, true +# +# Alternatively, you can configure exactly which server to +# start per endpoint: +# +# config :api_example, ApiExample.Endpoint, server: true +# + +# Finally import the config/prod.secret.exs +# which should be versioned separately. +import_config "prod.secret.exs" diff --git a/examples/api/config/test.exs b/examples/api/config/test.exs new file mode 100644 index 0000000..4bcf0a3 --- /dev/null +++ b/examples/api/config/test.exs @@ -0,0 +1,10 @@ +use Mix.Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :api_example, ApiExample.Endpoint, + http: [port: 4001], + server: false + +# Print only warnings and errors during test +config :logger, level: :warn diff --git a/examples/api/lib/api_example.ex b/examples/api/lib/api_example.ex new file mode 100644 index 0000000..bbf5fb0 --- /dev/null +++ b/examples/api/lib/api_example.ex @@ -0,0 +1,30 @@ +defmodule ApiExample do + use Application + + # See http://elixir-lang.org/docs/stable/elixir/Application.html + # for more information on OTP Applications + def start(_type, _args) do + import Supervisor.Spec + + # Define workers and child supervisors to be supervised + children = [ + # Start the endpoint when the application starts + supervisor(ApiExample.Endpoint, []), + # Start your own worker by calling: ApiExample.Worker.start_link(arg1, arg2, arg3) + # worker(ApiExample.Worker, [arg1, arg2, arg3]), + worker(ApiExample.Database, [%{users: []}, [name: Database]]), + ] + + # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: ApiExample.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 + ApiExample.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/examples/api/lib/api_example/database.ex b/examples/api/lib/api_example/database.ex new file mode 100644 index 0000000..6bbea0e --- /dev/null +++ b/examples/api/lib/api_example/database.ex @@ -0,0 +1,54 @@ +defmodule ApiExample.Database do + use GenServer + + def start_link(state, opts \\ []) do + GenServer.start_link(__MODULE__, state, opts) + end + + def handle_cast({:add_user, user}, state) do + case find_user(state.users, user["id"]) do + :notfound -> {:noreply, %{state | users: [user] ++ state.users}} + _ -> {:noreply, state} + end + end + + def handle_call({:find_user, id}, _from, state) do + {:reply, find_user(state.users, id), state} + end + + def handle_call({:delete_user, id}, _from, state) do + case delete_user(state.users, [], id) do + {:ok, users} -> {:reply, :ok, %{ state | users: users}} + end + end + + def handle_call({:find_user, id}, _from, state) do + {:reply, find_user(state.users, id), state} + end + + def handle_call(:get_users, _from, state) do + {:reply, state.users, state} + end + + def find_user([head | tail], id) do + case head["id"] == id do + true -> head + false -> find_user(tail, id) + end + end + + def find_user([], id) do + :notfound + end + + def delete_user([head | tail], state, id) do + case head["id"] == id do + true -> delete_user(tail, state, id) + false -> delete_user(tail, [head] ++ state, id) + end + end + + def delete_user([], state, id) do + {:ok, state} + end +end diff --git a/examples/api/lib/api_example/endpoint.ex b/examples/api/lib/api_example/endpoint.ex new file mode 100644 index 0000000..cadcf39 --- /dev/null +++ b/examples/api/lib/api_example/endpoint.ex @@ -0,0 +1,42 @@ +defmodule ApiExample.Endpoint do + use Phoenix.Endpoint, otp_app: :api_example + + socket "/socket", ApiExample.UserSocket + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phoenix.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", from: :api_example, gzip: false, + only: ~w(css fonts images js favicon.ico robots.txt) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Plug.RequestId + plug Plug.Logger + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Poison + + plug Plug.MethodOverride + plug Plug.Head + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + plug Plug.Session, + store: :cookie, + key: "_api_example_key", + signing_salt: "1au9a+6e" + + plug ApiExample.Router +end diff --git a/examples/api/mix.exs b/examples/api/mix.exs new file mode 100644 index 0000000..15b7ad7 --- /dev/null +++ b/examples/api/mix.exs @@ -0,0 +1,38 @@ +defmodule ApiExample.Mixfile do + use Mix.Project + + def project do + [app: :api_example, + version: "0.0.1", + elixir: "~> 1.2", + elixirc_paths: elixirc_paths(Mix.env), + compilers: [:phoenix, :gettext] ++ Mix.compilers, + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + deps: deps()] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [mod: {ApiExample, []}, + applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext]] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "web", "test/support"] + defp elixirc_paths(_), do: ["lib", "web"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [{:phoenix, "~> 1.2.1"}, + {:phoenix_pubsub, "~> 1.0"}, + {:phoenix_html, "~> 2.6"}, + {:phoenix_live_reload, "~> 1.0", only: :dev}, + {:gettext, "~> 0.11"}, + {:cowboy, "~> 1.0"}] + end +end diff --git a/examples/api/mix.lock b/examples/api/mix.lock new file mode 100644 index 0000000..e512ea4 --- /dev/null +++ b/examples/api/mix.lock @@ -0,0 +1,12 @@ +%{"cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, optional: false]}]}, + "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, + "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], []}, + "gettext": {:hex, :gettext, "0.13.1", "5e0daf4e7636d771c4c71ad5f3f53ba09a9ae5c250e1ab9c42ba9edccc476263", [:mix], []}, + "mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], []}, + "phoenix": {:hex, :phoenix, "1.2.3", "b68dd6a7e6ff3eef38ad59771007d2f3f344988ea6e658e9b2c6ffb2ef494810", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, optional: false]}, {:plug, "~> 1.4 or ~> 1.3.3 or ~> 1.2.4 or ~> 1.1.8 or ~> 1.0.5", [hex: :plug, optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}]}, + "phoenix_html": {:hex, :phoenix_html, "2.9.3", "1b5a2122cbf743aa242f54dced8a4f1cc778b8bd304f4b4c0043a6250c58e258", [:mix], [{:plug, "~> 1.0", [hex: :plug, optional: false]}]}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.0.8", "4333f9c74190f485a74866beff2f9304f069d53f047f5fbb0fb8d1ee4c495f73", [:mix], [{:fs, "~> 0.9.1", [hex: :fs, optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2-rc", [hex: :phoenix, optional: false]}]}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.1", "c10ddf6237007c804bf2b8f3c4d5b99009b42eca3a0dfac04ea2d8001186056a", [:mix], []}, + "plug": {:hex, :plug, "1.3.4", "b4ef3a383f991bfa594552ded44934f2a9853407899d47ecc0481777fb1906f6", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, optional: true]}, {:mime, "~> 1.0", [hex: :mime, optional: false]}]}, + "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []}, + "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], []}} diff --git a/examples/api/priv/gettext/en/LC_MESSAGES/errors.po b/examples/api/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..cdec3a1 --- /dev/null +++ b/examples/api/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,11 @@ +## `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" diff --git a/examples/api/priv/gettext/errors.pot b/examples/api/priv/gettext/errors.pot new file mode 100644 index 0000000..6988141 --- /dev/null +++ b/examples/api/priv/gettext/errors.pot @@ -0,0 +1,10 @@ +## This file 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 as no +## effect: edit them in PO (`.po`) files instead. + diff --git a/examples/api/priv/static/robots.txt b/examples/api/priv/static/robots.txt new file mode 100644 index 0000000..3c9c7c0 --- /dev/null +++ b/examples/api/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/examples/api/test/controllers/page_controller_test.exs b/examples/api/test/controllers/page_controller_test.exs new file mode 100644 index 0000000..d88b515 --- /dev/null +++ b/examples/api/test/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule ApiExample.PageControllerTest do + use ApiExample.ConnCase + + test "GET /", %{conn: conn} do + conn = get conn, "/" + assert html_response(conn, 200) =~ "Welcome to Phoenix!" + end +end diff --git a/examples/api/test/support/channel_case.ex b/examples/api/test/support/channel_case.ex new file mode 100644 index 0000000..16d8324 --- /dev/null +++ b/examples/api/test/support/channel_case.ex @@ -0,0 +1,33 @@ +defmodule ApiExample.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 and query models. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with channels + use Phoenix.ChannelTest + + + # The default endpoint for testing + @endpoint ApiExample.Endpoint + end + end + + setup tags do + + :ok + end +end diff --git a/examples/api/test/support/conn_case.ex b/examples/api/test/support/conn_case.ex new file mode 100644 index 0000000..4c8320f --- /dev/null +++ b/examples/api/test/support/conn_case.ex @@ -0,0 +1,34 @@ +defmodule ApiExample.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 and query models. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with connections + use Phoenix.ConnTest + + import ApiExample.Router.Helpers + + # The default endpoint for testing + @endpoint ApiExample.Endpoint + end + end + + setup tags do + + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/examples/api/test/test_helper.exs b/examples/api/test/test_helper.exs new file mode 100644 index 0000000..2df156f --- /dev/null +++ b/examples/api/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start + diff --git a/examples/api/test/views/error_view_test.exs b/examples/api/test/views/error_view_test.exs new file mode 100644 index 0000000..542719d --- /dev/null +++ b/examples/api/test/views/error_view_test.exs @@ -0,0 +1,21 @@ +defmodule ApiExample.ErrorViewTest do + use ApiExample.ConnCase, async: true + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + test "renders 404.html" do + assert render_to_string(ApiExample.ErrorView, "404.html", []) == + "Page not found" + end + + test "render 500.html" do + assert render_to_string(ApiExample.ErrorView, "500.html", []) == + "Internal server error" + end + + test "render any other" do + assert render_to_string(ApiExample.ErrorView, "505.html", []) == + "Internal server error" + end +end diff --git a/examples/api/test/views/layout_view_test.exs b/examples/api/test/views/layout_view_test.exs new file mode 100644 index 0000000..e611194 --- /dev/null +++ b/examples/api/test/views/layout_view_test.exs @@ -0,0 +1,3 @@ +defmodule ApiExample.LayoutViewTest do + use ApiExample.ConnCase, async: true +end diff --git a/examples/api/test/views/page_view_test.exs b/examples/api/test/views/page_view_test.exs new file mode 100644 index 0000000..a477e05 --- /dev/null +++ b/examples/api/test/views/page_view_test.exs @@ -0,0 +1,3 @@ +defmodule ApiExample.PageViewTest do + use ApiExample.ConnCase, async: true +end diff --git a/examples/api/web/channels/user_socket.ex b/examples/api/web/channels/user_socket.ex new file mode 100644 index 0000000..0f2af71 --- /dev/null +++ b/examples/api/web/channels/user_socket.ex @@ -0,0 +1,37 @@ +defmodule ApiExample.UserSocket do + use Phoenix.Socket + + ## Channels + # channel "room:*", ApiExample.RoomChannel + + ## Transports + transport :websocket, Phoenix.Transports.WebSocket + # transport :longpoll, Phoenix.Transports.LongPoll + + # 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. + def connect(_params, socket) do + {:ok, socket} + end + + # Socket id's are topics that allow you to identify all sockets for a given user: + # + # def id(socket), do: "users_socket:#{socket.assigns.user_id}" + # + # Would allow you to broadcast a "disconnect" event and terminate + # all active sockets and channels for a given user: + # + # ApiExample.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{}) + # + # Returning `nil` makes this socket anonymous. + def id(_socket), do: nil +end diff --git a/examples/api/web/controllers/api/v1/users_controller.ex b/examples/api/web/controllers/api/v1/users_controller.ex new file mode 100644 index 0000000..3391c80 --- /dev/null +++ b/examples/api/web/controllers/api/v1/users_controller.ex @@ -0,0 +1,26 @@ +defmodule ApiExample.Api.V1.UsersController do + use ApiExample.Web, :controller + + def index(conn, _params) do + render conn, "index.json", users: GenServer.call(Database, :get_users) + end + + def create(conn, user) do + GenServer.cast(Database, {:add_user, user}) + render conn, "get.json", user: user + end + + def show(conn, %{"id" => id_str}) do + {id, _} = Integer.parse(id_str) + case GenServer.call(Database, {:find_user, id}) do + :notfound -> put_status(conn, 404) |> render(ApiExample.ErrorView, "404.json", %{message: "user not found"}) + user -> render conn, "get.json", user: user + end + end + + def delete(conn, %{"id" => id_str}) do + {id, _} = Integer.parse(id_str) + GenServer.call(Database, {:delete_user, id}) + put_status(conn, 204) |> render(ApiExample.ErrorView, "204.json", %{message: "no content"}) + end +end diff --git a/examples/api/web/gettext.ex b/examples/api/web/gettext.ex new file mode 100644 index 0000000..d0bf717 --- /dev/null +++ b/examples/api/web/gettext.ex @@ -0,0 +1,24 @@ +defmodule ApiExample.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 ApiExample.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: :api_example +end diff --git a/examples/api/web/router.ex b/examples/api/web/router.ex new file mode 100644 index 0000000..1987e7a --- /dev/null +++ b/examples/api/web/router.ex @@ -0,0 +1,14 @@ +defmodule ApiExample.Router do + use ApiExample.Web, :router + + pipeline :api do + plug :accepts, ["json"] + end + + # Other scopes may use custom stacks. + scope "/api/v1", ApiExample.Api.V1 do + pipe_through :api + + resources "/users", UsersController + end +end diff --git a/examples/api/web/views/api/v1/users_view.ex b/examples/api/web/views/api/v1/users_view.ex new file mode 100644 index 0000000..4038305 --- /dev/null +++ b/examples/api/web/views/api/v1/users_view.ex @@ -0,0 +1,17 @@ +defmodule ApiExample.Api.V1.UsersView do + use ApiExample.Web, :view + + def render("index.json", %{users: users}) do + Enum.map(users, &user_json/1) + end + + def render("get.json", %{user: user}) do + user_json(user) + end + + def user_json(user) do + %{ + id: user["id"] + } + end +end diff --git a/examples/api/web/views/error_helpers.ex b/examples/api/web/views/error_helpers.ex new file mode 100644 index 0000000..2358858 --- /dev/null +++ b/examples/api/web/views/error_helpers.ex @@ -0,0 +1,40 @@ +defmodule ApiExample.ErrorHelpers do + @moduledoc """ + Conveniences for translating and building error messages. + """ + + use Phoenix.HTML + + @doc """ + Generates tag for inlined form input errors. + """ + def error_tag(form, field) do + if error = form.errors[field] do + content_tag :span, translate_error(error), class: "help-block" + end + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # Because error messages were defined within Ecto, we must + # call the Gettext module passing our Gettext backend. We + # also use the "errors" domain as translations are placed + # in the errors.po file. + # Ecto will pass the :count keyword if the error message is + # meant to be pluralized. + # On your own code and templates, depending on whether you + # need the message to be pluralized or not, this could be + # written simply as: + # + # dngettext "errors", "1 file", "%{count} files", count + # dgettext "errors", "is invalid" + # + if count = opts[:count] do + Gettext.dngettext(ApiExample.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(ApiExample.Gettext, "errors", msg, opts) + end + end +end diff --git a/examples/api/web/views/error_view.ex b/examples/api/web/views/error_view.ex new file mode 100644 index 0000000..fa3a681 --- /dev/null +++ b/examples/api/web/views/error_view.ex @@ -0,0 +1,31 @@ +defmodule ApiExample.ErrorView do + use ApiExample.Web, :view + + def render("404.html", _assigns) do + "Page not found" + end + + def render("500.html", _assigns) do + "Internal server error" + end + + # In case no render clause matches or no + # template is found, let's render it as 500 + def template_not_found(_template, assigns) do + render "500.html", assigns + end + + def render("404.json", %{message: message}) do + %{ + status: 404, + message: message + } + end + + def render("204.json", %{message: message}) do + %{ + status: 204, + message: message + } + end +end diff --git a/examples/api/web/views/layout_view.ex b/examples/api/web/views/layout_view.ex new file mode 100644 index 0000000..6e67905 --- /dev/null +++ b/examples/api/web/views/layout_view.ex @@ -0,0 +1,3 @@ +defmodule ApiExample.LayoutView do + use ApiExample.Web, :view +end diff --git a/examples/api/web/views/page_view.ex b/examples/api/web/views/page_view.ex new file mode 100644 index 0000000..f7430bd --- /dev/null +++ b/examples/api/web/views/page_view.ex @@ -0,0 +1,3 @@ +defmodule ApiExample.PageView do + use ApiExample.Web, :view +end diff --git a/examples/api/web/web.ex b/examples/api/web/web.ex new file mode 100644 index 0000000..e135f3e --- /dev/null +++ b/examples/api/web/web.ex @@ -0,0 +1,69 @@ +defmodule ApiExample.Web do + @moduledoc """ + A module that keeps using definitions for controllers, + views and so on. + + This can be used in your application as: + + use ApiExample.Web, :controller + use ApiExample.Web, :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. + """ + + def model do + quote do + # Define common model functionality + end + end + + def controller do + quote do + use Phoenix.Controller + + import ApiExample.Router.Helpers + import ApiExample.Gettext + end + end + + def view do + quote do + use Phoenix.View, root: "web/templates" + + # Import convenience functions from controllers + import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] + + # Use all HTML functionality (forms, tags, etc) + use Phoenix.HTML + + import ApiExample.Router.Helpers + import ApiExample.ErrorHelpers + import ApiExample.Gettext + end + end + + def router do + quote do + use Phoenix.Router + end + end + + def channel do + quote do + use Phoenix.Channel + import ApiExample.Gettext + 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/examples/scene1.exs b/examples/scene1.exs new file mode 100644 index 0000000..da7afc9 --- /dev/null +++ b/examples/scene1.exs @@ -0,0 +1,42 @@ +defmodule Scene do + import Slap.Script + + def before_run(_args) do + url = "http://localhost:4000" + # We create 100 user in the API + ids = create_users(url <> "/api/v1/users", 100, []) + %{users_id: ids, url: url} + end + + def run(_args, %{users_id: ids, url: url}) do + # Request 3 of the users of the API and returns the metric + metrics = request("GET", url <> "/api/v1/users/#{Enum.random(ids)}") + metrics ++ request("GET", url <> "/api/v1/users/#{Enum.random(ids)}") + metrics ++ request("GET", url <> "/api/v1/users/#{Enum.random(ids)}") + end + + def after_run(_args, %{users_id: ids, url: url}) do + remove_users(url, ids) + end + + # Custom methods + def create_users(url, 0, ids) do + ids + end + + def create_users(url, id, ids) do + headers = [{"Content-type", "application/json"}] + + body = Poison.encode!(%{id: id}) + request("POST", url, body, headers) + create_users(url, id - 1, [id] ++ ids) + end + + def remove_users(url, []) do + end + + def remove_users(url, [head | tail]) do + request("DELETE", url <> "/api/v1/users/#{head}") + remove_users(url, tail) + end +end diff --git a/lib/slap.ex b/lib/slap.ex new file mode 100644 index 0000000..c0989c3 --- /dev/null +++ b/lib/slap.ex @@ -0,0 +1,75 @@ +defmodule Slap do + import Crontab.CronExpression + + @logo "IC5kODg4OGIuICA4ODggICAgICAgICAgICAgZDg4ODggODg4ODg4OGIuICANCmQ4OFAgIFk4OGIgODg4ICAgICAgICAgICAgZDg4ODg4IDg4OCAgIFk4OGIgDQpZODhiLiAgICAgIDg4OCAgICAgICAgICAgZDg4UDg4OCA4ODggICAgODg4IA0KICJZODg4Yi4gICA4ODggICAgICAgICAgZDg4UCA4ODggODg4ICAgZDg4UCANCiAgICAiWTg4Yi4gODg4ICAgICAgICAgZDg4UCAgODg4IDg4ODg4ODhQIiAgDQogICAgICAiODg4IDg4OCAgICAgICAgZDg4UCAgIDg4OCA4ODggICAgICAgIA0KWTg4YiAgZDg4UCA4ODggICAgICAgZDg4ODg4ODg4ODggODg4ICAgICAgICANCiAiWTg4ODhQIiAgODg4ODg4ODggZDg4UCAgICAgODg4IDg4OCAgICAgICAgDQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIA0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICANCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgDQo=" + + def init() do + GenServer.start_link(Slap.Reporter, []) + end + + def run(duration, clients, script, run_args, reporter_id) do + Code.eval_string script + alias Scene + HTTPoison.start + + GenServer.cast(reporter_id, {:set_iterations, duration}) + + data = Scene.before_run run_args + + # Start sending requests every seconds + job = %Quantum.Job{ + schedule: ~e[*/1]e, # required + task: {Slap.ClientRunner, :run}, # required + args: [&Scene.run/2, run_args, data, clients, reporter_id], + } + + Quantum.add_job(:ticker, job) + :timer.sleep(duration * 1000 ) # Wait for the duration of the test + + # Stop the test + Quantum.delete_job(:ticker) + :timer.sleep(3000); # Waiting for requests to finish, assuming it would take at most 3 seconds + + # Run the clean up function + Scene.after_run(run_args, data) + + # Generate report + report = GenServer.call reporter_id, :compute + GenServer.stop(reporter_id) + report + end + + def draw_report(reporter_id) do + Slap.ReporterCli.print(GenServer.call(reporter_id, :compute)) + :timer.sleep(1000); + draw_report(reporter_id) + end + + def main(args) do + # Print logo + {:ok, logo} = Base.decode64(@logo) + IO.puts logo + + case args do + [scene, clients_str, duration_str | custom_args ] -> + clients = case Integer.parse(clients_str) do + {clients, _} -> clients + _ -> IO.puts "Invalid clienst number: #{clients_str}"; System.halt(0) + end + + duration = case Integer.parse(duration_str) do + {duration, _} -> duration + _ -> IO.puts "Invalid duration: #{duration_str}"; System.halt(0) + end + + {:ok, script} = File.read scene # Parse the script file + {:ok, reporter_id} = init # Start a GenServer to handle the data report + spawn(fn -> draw_report(reporter_id) end) + report = run(duration, clients, script, custom_args, reporter_id) + Slap.ReporterHTML.print report # Print report into HTML + IO.puts "\nYou can open an HTML report with `open report.html`" + + _ -> IO.puts "Invalid arguments\n Exemple: slap examples/scene1.exs 10 10 --custom_arg1 32" + end + end +end diff --git a/lib/slap/client_runner.ex b/lib/slap/client_runner.ex new file mode 100644 index 0000000..206ff63 --- /dev/null +++ b/lib/slap/client_runner.ex @@ -0,0 +1,25 @@ +defmodule Slap.ClientRunner do + use GenServer + + def run(call, run_args, data, clients, reporter_id) do + sleep_duration = 1/clients * 1000 # + + start(call, run_args, data, round(sleep_duration), clients, reporter_id) + GenServer.cast(reporter_id, {:iterate}) # Notify reporter that we have run the script + end + + defp start(call, run_args, data, _sleep_duration, 0, _reporter_id) do + :ok + end + + defp start(call, run_args, data, sleep_duration, clients, reporter_id) do + spawn(fn -> handle(call, run_args, data, reporter_id) end) + :timer.sleep(sleep_duration) + start(call, run_args, data, sleep_duration, clients - 1, reporter_id) + end + + defp handle(call, run_args, data, reporter_id) do + metrics = call.(run_args, data) + GenServer.cast(reporter_id, {:push, metrics}) + end +end diff --git a/lib/slap/importer.ex b/lib/slap/importer.ex new file mode 100644 index 0000000..66d7f84 --- /dev/null +++ b/lib/slap/importer.ex @@ -0,0 +1,24 @@ +defmodule Slap.Importer do + @moduledoc """ + Dynamically import configured module into your module. + ### Examples + defmodule SomeModule do + ... + use MyApp.Importer + ... + def xyz do + ... + some_func_from_ref_module() + ... + end + end + """ + defmacro __using__(_) do + quote do + import unquote(Application.get_env(:my_app, :module_ref)) + end + end +end + +#config :my_app, +# module_ref: MyApp.SomeModule diff --git a/lib/slap/metric.ex b/lib/slap/metric.ex new file mode 100644 index 0000000..5224223 --- /dev/null +++ b/lib/slap/metric.ex @@ -0,0 +1,6 @@ +defmodule Slap.Metric do + @moduledoc """ + Metric of a request + """ + defstruct status_code: 0, created_at: 0, latency: 0, success: false, start: 0, stop: 0, average_latency: 0 +end diff --git a/lib/slap/report.ex b/lib/slap/report.ex new file mode 100644 index 0000000..f9a7959 --- /dev/null +++ b/lib/slap/report.ex @@ -0,0 +1,3 @@ +defmodule Slap.Report do + defstruct metrics: [], success: 0, total: 0, average_latency: 0, total_iterations: 0, current_iteration: 0 +end diff --git a/lib/slap/report_html.ex b/lib/slap/report_html.ex new file mode 100644 index 0000000..85115af --- /dev/null +++ b/lib/slap/report_html.ex @@ -0,0 +1,20 @@ +defmodule Slap.ReporterHTML do + @moduledoc """ + Reporter HTML print report into HTML file + """ + + @layout "