Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

documentation and typespec #34

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## v1.0.0 (2020-10-02)

* Initial release
13 changes: 13 additions & 0 deletions lib/mix/upload_recipe.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
defmodule Mix.Tasks.UploadRecipe do
@moduledoc """
A mix task for uploading JSON recipes in the `examples` directory to OriginSimulator.

```
# upload `examples/demo.json` to OriginSimulator running locally (http://localhost:8080).
mix upload_recipe demo

# upload `examples/demo.json to OriginSimulator on a specific host.
mix upload_recipe "http://origin-simulator.com" demo
```
"""

use Mix.Task

@spec run(list()) :: {:ok, HTTPoison.Response.t() | HTTPoison.AsyncResponse.t()} | {:error, HTTPoison.Error.t()}
def run([host, recipe]) do
{:ok, _started} = Application.ensure_all_started(:httpoison)

Expand Down
7 changes: 7 additions & 0 deletions lib/origin_simulator.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
defmodule OriginSimulator do
@moduledoc """
Main router for handling and responding to OriginSimulator requests.
"""

use Plug.Router
alias OriginSimulator.{Payload, Simulation, Plug.ResponseCounter}

Expand All @@ -20,6 +24,7 @@ defmodule OriginSimulator do
send_resp(conn, 404, "not found")
end

@doc false
def admin_domain(), do: Application.get_env(:origin_simulator, :admin_domain)

defp serve_payload(conn, route) do
Expand Down Expand Up @@ -52,9 +57,11 @@ defmodule OriginSimulator do
defp sleep(%Range{} = time), do: :timer.sleep(Enum.random(time))
defp sleep(duration), do: :timer.sleep(duration)

@doc false
def recipe_not_set(),
do: "Recipe not set, please POST a recipe to /#{admin_domain()}/add_recipe"

@doc false
def recipe_not_set(path) do
"Recipe not set at #{path}, please POST a recipe for this route to /#{admin_domain()}/add_recipe"
end
Expand Down
30 changes: 30 additions & 0 deletions lib/origin_simulator/admin_router.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,34 @@
defmodule OriginSimulator.AdminRouter do
@moduledoc """
Router for handling and responding to admin requests.

#### Admin routes

* /_admin/status

Check if the simulator is running, return `ok!`

* /_admin/add_recipe

Post (POST) recipe: update or create new origins

* /_admin/current_recipe

List existing recipe for all origins and routes

* /_admin/restart

Reset the simulator: remove all recipes

* /_admin/routes

List all origins and routes

* /_admin/routes_status

List all origin and routes with the corresponding current status and latency values
"""

use Plug.Router

alias OriginSimulator.{Recipe, Simulation, Counter}
Expand Down
39 changes: 39 additions & 0 deletions lib/origin_simulator/body.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
defmodule OriginSimulator.Body do
@moduledoc """
Utilities for generating OriginSimulator recipe response payloads (body).
"""
@regex ~r"<<(.+?)>>"

alias OriginSimulator.Size

@doc """
Parse a string value to include generated and compressed random payload if required.

OriginSimulator response payload can be defined in recipes. The payload
can also include random content specified with tags, e.g. <<10kb>>.

```
iex> OriginSimulator.Body.parse("{\"payload\":\"test\"}")
"{\"payload\":\"test\"}"

# generate random content with <<100b>>
iex> OriginSimulator.Body.parse("{\"payload\":\"<<100b>>\"}")
"{\"payload\":\"iDS0MMNKT3QMmEiOPvjeIsEXB7cjGlGktCLCMta3D8ZleSHbcbUn1mNa470POxzDJAhJvP4L3cDhDvwBP5eQC8fGMo3DCgewZzBv\"}"
```

Payload can be compressed with a `%{"content-encoding" => "gzip"}` header.
```
iex> OriginSimulator.Body.parse("{\"payload\":\"<<100b>>\"}", %{"content-encoding" => "gzip"})
<<31, 139, 8, 0, 0, 0, 0, 0, 0, 19, 171, 86, 42, 72, 172, 204, 201, 79, 76, 81,
178, 82, 50, 8, 115, 214, 15, 169, 114, 245, 74, 244, 204, 205, 44, 54, 52,
174, 242, 170, 204, 244, 11, 171, 172, 50, 205, 201, 43, 54, ...>>
```
"""
@spec parse(binary(), map()) :: binary()
def parse(str, headers \\ %{})

def parse(str, %{"content-encoding" => "gzip"}) do
Expand All @@ -14,6 +41,18 @@ defmodule OriginSimulator.Body do
Regex.replace(@regex, str, fn _whole, tag -> randomise(tag) end)
end

@doc """
Generate and compress random payload.

```
# generate 100kb random payload in gzip format
iex(1)> OriginSimulator.Body.randomise("100kb", %{"content-encoding" => "gzip"})
<<31, 139, 8, 0, 0, 0, 0, 0, 0, 19, 20, 154, 69, 178, 227, 64, 16, 5, 15, 228,
133, 152, 150, 98, 102, 214, 78, 204, 204, 58, 253, 252, 89, 59, 28, 182, 186,
171, 222, 203, 116, 88, 158, 88, 201, 207, 21, 14, 52, 48, ...>>
```
"""
@spec randomise(binary(), map()) :: binary()
def randomise(tag, headers \\ %{})
def randomise(tag, %{"content-encoding" => "gzip"}), do: randomise(tag, %{}) |> :zlib.gzip()

Expand Down
2 changes: 2 additions & 0 deletions lib/origin_simulator/counter.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
defmodule OriginSimulator.Counter do
@moduledoc false

use Agent

@initial_state %{total_requests: 0}
Expand Down
1 change: 1 addition & 0 deletions lib/origin_simulator/duration.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
defmodule OriginSimulator.Duration do
@moduledoc false
def parse(0), do: 0

def parse(time) when is_integer(time) do
Expand Down
1 change: 1 addition & 0 deletions lib/origin_simulator/http/client.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
defmodule OriginSimulator.HTTP.Client do
@moduledoc false
def get(endpoint, headers \\ %{})

def get(endpoint, %{"content-encoding" => "gzip"} = headers) do
Expand Down
1 change: 1 addition & 0 deletions lib/origin_simulator/http/mock_client.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
defmodule OriginSimulator.HTTP.MockClient do
@moduledoc false
def get(_endpoint, headers \\ %{})
def get(_endpoint, %{"content-type" => "application/json"}), do: {:ok, %HTTPoison.Response{body: "{\"hello\":\"world\"}"}}
def get(_endpoint, %{"content-encoding" => "gzip"}), do: {:ok, %HTTPoison.Response{body: :zlib.gzip("some content from origin")}}
Expand Down
25 changes: 24 additions & 1 deletion lib/origin_simulator/payload.ex
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
defmodule OriginSimulator.Payload do
use GenServer
@moduledoc """
Server for fetching payload from origin, storing and serving payloads.

Recipe payload is pre-created and stored in memory
([Erlang ETS](https://erlang.org/doc/man/ets.html)) when the
recipe is upload to OriginSimulator. This module provides API to
fetch and store payload from origins specified in recipe so that
payload can be served repeatedly during simulation without hitting
the simulated origins. It also deals with body / random content payloads
if these are specified in recipe.
"""

use GenServer
alias OriginSimulator.{Body, Recipe}

@http_client Application.get_env(:origin_simulator, :http_client)

@type server :: pid() | :atom
@type recipe :: OriginSimulator.Recipe.t()

## Client API

@doc false
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: :payload)
end

@doc """
Fetch (from origin) or generate payload specified in recipe for in-memory storage.
"""
@spec fetch(server, recipe) :: :ok
def fetch(server, %Recipe{origin: value, route: route} = recipe) when is_binary(value) do
GenServer.call(server, {:fetch, recipe, route})
end
Expand All @@ -24,6 +43,10 @@ defmodule OriginSimulator.Payload do
GenServer.call(server, {:generate, recipe, route})
end

@doc """
Retrieve a payload from server for a given path and matching route.
"""
@spec body(server, integer(), binary(), binary()) :: {:ok, term()} | {:error, binary()}
def body(_server, status, path \\ Recipe.default_route(), route \\ Recipe.default_route()) do
case {status, path} do
{200, _} -> cache_lookup(route)
Expand Down
2 changes: 2 additions & 0 deletions lib/origin_simulator/plug/response_counter.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
defmodule OriginSimulator.Plug.ResponseCounter do
@moduledoc false

@behaviour Plug
import Plug.Conn, only: [register_before_send: 2]

Expand Down
13 changes: 13 additions & 0 deletions lib/origin_simulator/recipe.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
defmodule OriginSimulator.Recipe do
@moduledoc """
Data struct and functions underpinning OriginSimulator recipes.

A recipe defines the different stages of a [simulation scenario](readme.html#scenarios).
It is a JSON that can be uploaded to OriginSimulator via HTTP POST. The recipe is
represented internally as a struct. This module also provides a function to parse
JSON recipe into struct.
"""

defstruct origin: nil, body: nil, random_content: nil, headers: %{}, stages: [], route: "/*"

@type t :: %__MODULE__{
Expand All @@ -9,11 +18,15 @@ defmodule OriginSimulator.Recipe do
route: String.t()
}

@doc """
Parse a JSON recipe into `t:OriginSimulator.Recipe.t/0` data struct.
"""
# TODO: parameters don't make sense, need fixing
@spec parse({:ok, binary(), any()}) :: binary()
def parse({:ok, "[" <> body, _conn}), do: Poison.decode!("[" <> body, as: [%__MODULE__{}])
def parse({:ok, body, _conn}), do: Poison.decode!(body, as: %__MODULE__{})

@doc false
@spec default_route() :: binary()
def default_route(), do: %__MODULE__{}.route
end
66 changes: 66 additions & 0 deletions lib/origin_simulator/simulation.ex
Original file line number Diff line number Diff line change
@@ -1,38 +1,100 @@
defmodule OriginSimulator.Simulation do
@moduledoc """
Server facilitating simulation recipe usage before and during load tests.
"""

use GenServer

alias OriginSimulator.{Recipe, Payload, Duration}

@type latency :: integer()
@type recipe :: OriginSimulator.Recipe.t()
@type status :: integer()

@type route :: binary()
@type server :: :simulation | module()
@type simulation_state :: %{required(:latency) => latency, required(:recipe) => recipe, required(:status) => status}

## Client API

@doc false
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: :simulation)
end

@doc """
Retrieve the simulation state for all routes.
"""
@spec state(server) :: %{required(route) => simulation_state}
def state(server) do
GenServer.call(server, :state)
end

@doc """
Retrieve the latency and status data for a specific route.

```
iex> OriginSimulator.Simulation.state(:simulation, "/news")
{200, 100}
```
"""
@spec state(server, route) :: {status, latency}
def state(server, route) do
GenServer.call(server, {:state, route})
end

@doc """
Retrieve all recipes uploaded to OriginSimulator.
"""
@spec recipe(server) :: list(recipe)
def recipe(server) do
GenServer.call(server, :recipe)
end

@doc """
Retrieve the recipe for a specific route.
"""
@spec recipe(server, route) :: recipe
def recipe(server, route) do
GenServer.call(server, {:recipe, route})
end

@doc """
Find a matching recipe route pattern for a given path.

OriginSimulator is capable of serving multiple simulation recipes
on multiple routes which could also be wildcard routes. For example:

```
iex> OriginSimulator.Simulation.route(:simulation, "/news/weather")
"/news*"
```

The matching route is used for retrieving simulation state (latency, status)
in `state/2`.
"""
@spec route(server, route) :: route
def route(server, route) do
GenServer.call(server, {:route, route})
end

@doc """
Retrieve a list of simulated routes.

```
iex> OriginSimulator.Simulation.route(:simulation)
["/*", "/example/endpoint", "/news"]
```
"""
@spec route(server, route) :: list(route)
def route(server) do
GenServer.call(server, :route)
end

@doc """
Add a recipe or a list of recipes to the server.
"""
@spec add_recipe(server, recipe | list(recipe)) :: :ok | :error
def add_recipe(server, recipes) when is_list(recipes) do
resp =
for recipe <- recipes do
Expand All @@ -46,6 +108,10 @@ defmodule OriginSimulator.Simulation do
GenServer.call(server, {:add_recipe, new_recipe})
end

@doc """
Deletes simulation state including recipes and restart server.
"""
@spec restart() :: :ok
def restart do
GenServer.stop(:simulation)
end
Expand Down
1 change: 1 addition & 0 deletions lib/origin_simulator/size.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
defmodule OriginSimulator.Size do
@moduledoc false
def parse(size) when is_binary(size) do
Integer.parse(size) |> parse()
end
Expand Down
4 changes: 4 additions & 0 deletions lib/origin_simulator/supervisor.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
defmodule OriginSimulator.Supervisor do
@moduledoc """
`Supervisor` of OriginSimulator server processes.
"""

use Supervisor

def start_link(init_arg) do
Expand Down