Skip to content

Commit f30ce6a

Browse files
committed
Initial
0 parents  commit f30ce6a

File tree

15 files changed

+1158
-0
lines changed

15 files changed

+1158
-0
lines changed

.formatter.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Used by "mix format"
2+
[
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4+
]

.github/workflows/ci.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
release:
7+
types:
8+
- published
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
include:
16+
- elixir: 1.13
17+
otp: 25.0
18+
deploy: true
19+
name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}
20+
steps:
21+
- uses: actions/checkout@v2
22+
- uses: erlef/setup-elixir@v1
23+
with:
24+
otp-version: ${{matrix.otp}}
25+
elixir-version: ${{matrix.elixir}}
26+
- run: mix deps.get
27+
- run: mix test
28+
- run: MIX_ENV=test mix credo
29+
deploy:
30+
needs: test
31+
runs-on: ubuntu-latest
32+
if: github.event_name == 'release' && github.event.action == 'published'
33+
name: Deploy published release
34+
env:
35+
HEX_API_KEY: ${{secrets.HEX_API_KEY}}
36+
steps:
37+
- uses: actions/checkout@v2
38+
- uses: erlef/setup-elixir@v1
39+
with:
40+
otp-version: 24.0
41+
elixir-version: 1.12
42+
- run: mix deps.get
43+
- run: mix hex.publish --yes

.gitignore

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# The directory Mix will write compiled artifacts to.
2+
/_build/
3+
4+
# If you run "mix test --cover", coverage assets end up here.
5+
/cover/
6+
7+
# The directory Mix downloads your dependencies sources to.
8+
/deps/
9+
10+
# Where third-party dependencies like ExDoc output generated docs.
11+
/doc/
12+
13+
# Ignore .fetch files in case you like to edit your project deps locally.
14+
/.fetch
15+
16+
# If the VM crashes, it generates a dump, let's ignore it too.
17+
erl_crash.dump
18+
19+
# Also ignore archive artifacts (built via "mix archive.build").
20+
*.ez
21+
22+
# Ignore package tarball (built via "mix hex.build").
23+
test_server-*.tar
24+
25+
# Temporary files, for example, from tests.
26+
/tmp/
27+
28+
.DS_Store
29+
/.elixir_ls

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
## v0.1.0 (TBA)
2+
3+
* Initial release

LICENSE

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
(The MIT License)
2+
3+
Copyright (c) 2022 Dan Schultzer & the Contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
7+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8+
9+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# TestServer
2+
3+
<!-- MDOC !-->
4+
5+
No fuzz ExUnit test server to mock third party services.
6+
7+
Features:
8+
9+
* HTTP/1
10+
* HTTP/2
11+
* Built-in TLS with self-signed certificates
12+
* Plug route matching
13+
14+
<!-- MDOC !-->
15+
16+
## Installation
17+
18+
Add `test_server` to your list of dependencies in `mix.exs`:
19+
20+
```elixir
21+
def deps do
22+
[
23+
{:test_server, "~> 0.1.0", only: [:test]}
24+
]
25+
end
26+
```
27+
28+
## Usage
29+
30+
```elixir
31+
test "fetch_url/0" do
32+
# The test server will autostart the current test, if not already running
33+
TestServer.add("/", via: :get)
34+
35+
# The URL is derrived from the current test server instance
36+
Application.put_env(:my_app, :fetch_url, TestServer.url())
37+
38+
{:ok, "HTTP"} = MyModule.fetch_url()
39+
end
40+
```
41+
42+
The `TestServer.add/2` function can route a request to an anonymous function:
43+
44+
```elixir
45+
TestServer.add("/", to: fn conn ->
46+
Conn.send_resp(conn, 200, "success")
47+
end)
48+
```
49+
50+
It can also route to a plug:
51+
52+
```elixir
53+
TestServer.add("/", to: MyPlug)
54+
```
55+
56+
The method to listen to can be defined with `:via`, by default it'll match any method:
57+
58+
```elixir
59+
TestServer.add("/", via: :post)
60+
```
61+
62+
A custom match function can be set with `:match` option:
63+
64+
```elixir
65+
TestServer.add("/", match: fn
66+
%{params: %{"a" => 1}} = _conn -> true
67+
_conn -> false
68+
end)
69+
```
70+
71+
By default all routes are served as plain HTTP.
72+
73+
HTTPS can be enabled with the `:scheme` option when starting the test server. The certificate suite is will automatically be generated.
74+
75+
```elixir
76+
{:ok, instance} = TestServer.start(scheme: :https)
77+
cacerts = TestServer.x509_suite().cacerts
78+
```
79+
80+
Custom SSL certificates can also be used by defining the cowboy options:
81+
82+
```elixir
83+
TestServer.start(scheme: :https, cowboy_options: [keyfile: key, certfile: cert])
84+
```
85+
86+
When a route is matched it'll be removed from active routes list. The route will be triggered in the order they where added:
87+
88+
```elixir
89+
TestServer.add("/", via: :get, &Conn.send_resp(&1, 200, "first"))
90+
TestServer.add("/", via: :get, &Conn.send_resp(&1, 200, "second"))
91+
92+
{:ok, "first"} = fetch_request()
93+
{:ok, "second"} = fetch_request()
94+
```
95+
96+
<!-- MDOC !-->
97+
98+
## LICENSE
99+
100+
(The MIT License)
101+
102+
Copyright (c) 2022 Dan Schultzer & the Contributors
103+
104+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
105+
106+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
107+
108+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

lib/test_server.ex

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
defmodule TestServer do
2+
@external_resource "README.md"
3+
@moduledoc "README.md"
4+
|> File.read!()
5+
|> String.split("<!-- MDOC !-->")
6+
|> Enum.fetch!(1)
7+
8+
alias Plug.Conn
9+
alias TestServer.{Instance, InstanceManager}
10+
11+
@doc """
12+
Start a test server instance.
13+
14+
The instance will be terminated when the test case finishes.
15+
16+
## Options
17+
18+
* `:port` - integer of port number, defaults to random port that can be opened;
19+
* `:scheme` - an atom for the http scheme. Defaults to `:http`;
20+
* `:cowboy_options` - See [Cowboy docs](https://ninenines.eu/docs/en/cowboy/2.5/manual/cowboy_http/)
21+
"""
22+
@spec start(keyword()) :: {:ok, pid()}
23+
def start(options \\ []) do
24+
case ExUnit.fetch_test_supervisor() do
25+
{:ok, sup} ->
26+
start_with_ex_unit(options, sup)
27+
28+
:error ->
29+
raise ArgumentError, "can only be called in a test process"
30+
end
31+
end
32+
33+
defp start_with_ex_unit(options, _sup) do
34+
case InstanceManager.start_instance(options) do
35+
{:ok, instance} ->
36+
ExUnit.Callbacks.on_exit(fn -> stop(instance) end)
37+
38+
{:ok, instance}
39+
40+
{:error, error} ->
41+
raise_start_failure({:error, error})
42+
end
43+
end
44+
45+
defp raise_start_failure({:error, {{:EXIT, reason}, _spec}}) do
46+
raise_start_failure({:error, reason})
47+
end
48+
49+
defp raise_start_failure({:error, error}) do
50+
raise """
51+
EXIT when starting #{__MODULE__.Instance}:
52+
53+
#{Exception.format_exit(error)}
54+
"""
55+
end
56+
57+
@doc """
58+
Shuts down the current test server instance
59+
"""
60+
@spec stop() :: :ok | {:error, term()}
61+
def stop do
62+
case InstanceManager.get_by_caller(self()) do
63+
nil -> :ok
64+
instance -> stop(instance)
65+
end
66+
end
67+
68+
@doc """
69+
Shuts down a test server instance
70+
"""
71+
@spec stop(pid()) :: :ok | {:error, term()}
72+
def stop(instance) do
73+
case Process.alive?(instance) do
74+
true ->
75+
verify_routes!(instance)
76+
InstanceManager.stop_instance(instance)
77+
78+
false ->
79+
:ok
80+
end
81+
end
82+
83+
defp verify_routes!(instance) do
84+
case Instance.active_routes(instance) do
85+
[] ->
86+
:ok
87+
88+
routes ->
89+
raise """
90+
The test ended before the following #{inspect(__MODULE__)} route(s) received a request:
91+
92+
#{Instance.routes_info(routes)}
93+
"""
94+
end
95+
end
96+
97+
@spec url() :: binary()
98+
def url, do: url("")
99+
100+
@spec url(binary() | keyword()) :: binary()
101+
def url(uri) when is_binary(uri), do: url(uri, [])
102+
def url(opts) when is_list(opts), do: url("", opts)
103+
104+
@doc """
105+
Produces a URL for the test server instance.
106+
107+
## Options
108+
* `:host` - binary host value, it'll be added to inet for IP 127.0.0.1, defaults to `"localhost"`;
109+
"""
110+
@spec url(binary(), keyword()) :: binary()
111+
def url(uri, opts) do
112+
unless is_nil(opts[:host]) or is_binary(opts[:host]),
113+
do: raise("Invalid host, got: #{inspect(opts[:host])}")
114+
115+
{:ok, instance} = autostart()
116+
117+
domain = maybe_enable_host(opts[:host])
118+
options = Instance.get_options(instance)
119+
120+
"#{Keyword.fetch!(options, :scheme)}://#{domain}:#{Keyword.fetch!(options, :port)}#{uri}"
121+
end
122+
123+
defp autostart do
124+
case InstanceManager.get_by_caller(self()) do
125+
nil -> start([])
126+
instance -> {:ok, instance}
127+
end
128+
end
129+
130+
defp maybe_enable_host(nil), do: "localhost"
131+
132+
defp maybe_enable_host(host) do
133+
:inet_db.set_lookup([:file, :dns])
134+
:inet_db.add_host({127, 0, 0, 1}, [String.to_charlist(host)])
135+
136+
host
137+
end
138+
139+
@doc """
140+
Adds a route to the test server.
141+
142+
## Options
143+
144+
* `:via` - matches the route against some specific HTTP method(s) specified as an atom, like `:get` or `:put`, or a list, like `[:get, :post]`.
145+
* `:match` - an anonymous function that will be called to see if a route matches, defaults to matching with arguments of uri and `:via` option.
146+
* `:to` - a Plug or anonymous function that will be called when the route matches.
147+
"""
148+
@spec add(binary(), keyword()) :: :ok | {:error, term()}
149+
def add(uri, options \\ []) when is_binary(uri) and is_list(options) do
150+
{:ok, instance} = autostart()
151+
152+
{:current_stacktrace, [_process, _test_server | stacktrace]} =
153+
Process.info(self(), :current_stacktrace)
154+
155+
options = Keyword.put_new(options, :to, &default_response_handler/1)
156+
157+
Instance.register(instance, {uri, options, stacktrace})
158+
end
159+
160+
defp default_response_handler(conn) do
161+
Conn.send_resp(conn, 200, to_string(Conn.get_http_protocol(conn)))
162+
end
163+
164+
@doc """
165+
Fetches the generated x509 suite for the current test server instance.
166+
"""
167+
@spec x509_suite() :: term()
168+
def x509_suite do
169+
case InstanceManager.get_by_caller(self()) do
170+
nil -> raise "#{inspect(Instance)} is not running, did you start it?"
171+
instance -> x509_suite(instance)
172+
end
173+
end
174+
175+
@doc """
176+
Fetches the generated x509 suite for a test server instance.
177+
"""
178+
@spec x509_suite(pid()) :: term()
179+
def x509_suite(instance) do
180+
options = Instance.get_options(instance)
181+
182+
cond do
183+
not (options[:scheme] == :https) ->
184+
raise "The #{inspect(Instance)} is not running with `[scheme: :https]` option"
185+
186+
not Keyword.has_key?(options, :x509_suite) ->
187+
raise "The #{inspect(Instance)} is running with custom SSL"
188+
189+
true ->
190+
options[:x509_suite]
191+
end
192+
end
193+
end

0 commit comments

Comments
 (0)