32 | Joined on <%= Timex.format!(user.registered_at, "{YYYY}-0{M}-0{D}") %> 33 |
34 |124 | Joined on <%= display_date(user.registered_at) %> 125 |
126 |Hello, <%= name %>
13 | 14 | # layout.html.eex 15 |Hello, Alice
" 28 | 29 | Raxx.response(:ok) 30 | |> Greet.render("Bob") 31 | # => %Raxx.Response{ 32 | # status: 200, 33 | # headers: [{"content-type", "text/html"}], 34 | # body: "Hello, Bob
" 35 | # } 36 | 37 | ## Options 38 | 39 | - **arguments:** A list of atoms for variables used in the template. 40 | This will be the argument list for the html function. 41 | The render function takes one additional argument to this list, 42 | a response struct. 43 | 44 | - **template (optional):** The eex file containing a main content template. 45 | If not given the template file will be generated from the file of the calling module. 46 | i.e. `path/to/file.ex` -> `path/to/file.html.eex` 47 | 48 | - **layout (optional):** An eex file containing a layout template. 49 | This template can use all the same variables as the main template. 50 | In addition it must include the content using `<%= __content__ %>` 51 | 52 | ## Safety 53 | 54 | ### [XSS (Cross Site Scripting) Prevention](https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet#RULE_.231_-_HTML_Escape_Before_Inserting_Untrusted_Data_into_HTML_Element_Content) 55 | 56 | All content interpolated into a view is escaped. 57 | 58 | iex> Greet.html(" 77 | ``` 78 | 79 | Use `javascript_variables/1` for injecting variables into any JavaScript environment. 80 | """ 81 | defmacro __using__(options) do 82 | {options, []} = Module.eval_quoted(__CALLER__, options) 83 | 84 | {arguments, options} = Keyword.pop_first(options, :arguments, []) 85 | {optional_arguments, options} = Keyword.pop_first(options, :optional, []) 86 | 87 | {page_template, options} = 88 | Keyword.pop_first(options, :template, Raxx.View.template_for(__CALLER__.file)) 89 | 90 | page_template = Path.expand(page_template, Path.dirname(__CALLER__.file)) 91 | 92 | {layout_template, remaining_options} = Keyword.pop_first(options, :layout) 93 | 94 | if remaining_options != [] do 95 | keys = 96 | Keyword.keys(remaining_options) 97 | |> Enum.map(&inspect/1) 98 | |> Enum.join(", ") 99 | 100 | raise ArgumentError, "Unexpected options for #{inspect(unquote(__MODULE__))}: [#{keys}]" 101 | end 102 | 103 | layout_template = 104 | if layout_template do 105 | Path.expand(layout_template, Path.dirname(__CALLER__.file)) 106 | end 107 | 108 | arguments = Enum.map(arguments, fn a when is_atom(a) -> {a, [line: 1], nil} end) 109 | 110 | optional_bindings = 111 | for {arg, _value} when is_atom(arg) <- optional_arguments do 112 | {arg, {arg, [], nil}} 113 | end 114 | 115 | optional_bindings = {:%{}, [], optional_bindings} 116 | 117 | optional_values = 118 | for {arg, value} when is_atom(arg) <- optional_arguments do 119 | {arg, Macro.escape(value)} 120 | end 121 | 122 | optional_values = {:%{}, [], optional_values} 123 | 124 | compiled_page = EEx.compile_file(page_template, engine: EExHTML.Engine) 125 | 126 | # This step would not be necessary if the compiler could return a wrapped value. 127 | safe_compiled_page = 128 | quote do 129 | EExHTML.raw(unquote(compiled_page)) 130 | end 131 | 132 | compiled_layout = 133 | if layout_template do 134 | EEx.compile_file(layout_template, engine: EExHTML.Engine) 135 | else 136 | {:__content__, [], nil} 137 | end 138 | 139 | {compiled, has_page?} = 140 | Macro.prewalk(compiled_layout, false, fn 141 | {:__content__, _opts, nil}, _acc -> 142 | {safe_compiled_page, true} 143 | 144 | ast, acc -> 145 | {ast, acc} 146 | end) 147 | 148 | if !has_page? do 149 | raise ArgumentError, "Layout missing content, add `<%= __content__ %>` to template" 150 | end 151 | 152 | quote do 153 | import EExHTML 154 | import unquote(__MODULE__), only: [partial: 2, partial: 3] 155 | 156 | if unquote(layout_template) do 157 | @external_resource unquote(layout_template) 158 | @file unquote(layout_template) 159 | end 160 | 161 | @external_resource unquote(page_template) 162 | @file unquote(page_template) 163 | def render(request, unquote_splicing(arguments), optional \\ []) do 164 | request 165 | |> Raxx.set_header("content-type", "text/html") 166 | |> Raxx.set_body(html(unquote_splicing(arguments), optional).data) 167 | end 168 | 169 | def html(unquote_splicing(arguments), optional \\ []) do 170 | optional = 171 | case Keyword.split(optional, Map.keys(unquote(optional_values))) do 172 | {optional, []} -> 173 | optional 174 | 175 | {_, unexpected} -> 176 | raise ArgumentError, 177 | "Unexpect optional variables '#{Enum.join(Keyword.keys(unexpected), ", ")}'" 178 | end 179 | 180 | unquote(optional_bindings) = Enum.into(optional, unquote(optional_values)) 181 | # NOTE from eex_html >= 0.2.0 the content will already be wrapped as safe. 182 | EExHTML.raw(unquote(compiled)) 183 | end 184 | end 185 | end 186 | 187 | @doc """ 188 | Generate template partials from eex templates. 189 | """ 190 | defmacro partial(name, arguments, options \\ []) do 191 | {private, options} = Keyword.pop(options, :private, false) 192 | type = if private, do: :defp, else: :def 193 | file = Keyword.get(options, :template, "#{name}.html.eex") 194 | file = Path.expand(file, Path.dirname(__CALLER__.file)) 195 | {_, options} = Keyword.pop(options, :engine, false) 196 | options = options ++ [engine: EExHTML.Engine] 197 | 198 | quote do 199 | require EEx 200 | 201 | EEx.function_from_file( 202 | unquote(type), 203 | unquote(name), 204 | unquote(file), 205 | unquote(arguments), 206 | unquote(options) 207 | ) 208 | end 209 | end 210 | 211 | @doc false 212 | def template_for(file) do 213 | case String.split(file, ~r/\.ex(s)?$/) do 214 | [path_and_name, ""] -> 215 | path_and_name <> ".html.eex" 216 | 217 | _ -> 218 | raise "#{__MODULE__} needs to be used from a `.ex` or `.exs` file" 219 | end 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /extensions/raxx_view/lib/raxx/view/layout.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.View.Layout do 2 | @moduledoc """ 3 | Create a general template that can be reused by views. 4 | 5 | Using this module will create a module that can be used as a view. 6 | All functions created in the layout module will be available in the layout template 7 | and the content template 8 | 9 | ## Example 10 | 11 | ### Creating a new layout 12 | 13 | # www/layout.html.eex 14 |signed up at <%= format_datetime(user.interted_at) %>
32 | 33 | # www/show_user.ex 34 | defmodule WWW.ShowUser do 35 | use Raxx.SimpleServer 36 | use WWW.Layout, 37 | template: "show_user.html.eex", 38 | arguments: [:user] 39 | 40 | @impl Raxx.Server 41 | def handle_request(_request, _state) do 42 | user = # fetch user somehow 43 | 44 | response(:ok) 45 | |> render(user) 46 | end 47 | end 48 | 49 | ## Options 50 | 51 | - **layout (optional):** The eex file containing the layout template. 52 | If not given the template file will be generated from the file of the calling module. 53 | i.e. `path/to/file.ex` -> `path/to/file.html.eex` 54 | 55 | - **imports (optional):** A list of modules to import into the template. 56 | The default behaviour is to import only the layout module into each view. 57 | Set this option to false to import no functions. 58 | """ 59 | defmacro __using__(options) do 60 | {options, []} = Module.eval_quoted(__CALLER__, options) 61 | {imports, options} = Keyword.pop_first(options, :imports) 62 | {layout_optional, options} = Keyword.pop_first(options, :optional, []) 63 | 64 | imports = 65 | case imports do 66 | nil -> 67 | [__CALLER__.module] 68 | 69 | false -> 70 | [] 71 | 72 | imports when is_list(imports) -> 73 | imports 74 | end 75 | 76 | {layout_template, remaining_options} = 77 | Keyword.pop_first(options, :layout, Raxx.View.template_for(__CALLER__.file)) 78 | 79 | if remaining_options != [] do 80 | keys = 81 | Keyword.keys(remaining_options) 82 | |> Enum.map(&inspect/1) 83 | |> Enum.join(", ") 84 | 85 | raise ArgumentError, "Unexpected options for #{inspect(unquote(__MODULE__))}: [#{keys}]" 86 | end 87 | 88 | layout_template = Path.expand(layout_template, Path.dirname(__CALLER__.file)) 89 | 90 | quote do 91 | import EExHTML 92 | import Raxx.View, only: [partial: 2, partial: 3] 93 | 94 | defmacro __using__(options) do 95 | imports = unquote(imports) 96 | layout_template = unquote(layout_template) 97 | layout_optional = unquote(Macro.escape(layout_optional)) 98 | 99 | imports = 100 | for i <- imports do 101 | quote do 102 | import unquote(i) 103 | end 104 | end 105 | 106 | {view_optional, options} = Keyword.pop(options, :optional, []) 107 | optional_arguments = Macro.escape(Keyword.merge(layout_optional, view_optional)) 108 | 109 | quote do 110 | unquote(imports) 111 | 112 | use Raxx.View, 113 | Keyword.merge( 114 | [ 115 | layout: unquote(layout_template), 116 | optional: unquote(optional_arguments) 117 | ], 118 | unquote(options) 119 | ) 120 | end 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /extensions/raxx_view/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule RaxxView.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :raxx_view, 7 | version: "0.1.7", 8 | elixir: "~> 1.7", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: description(), 12 | docs: [extras: ["README.md"], main: "readme"], 13 | package: package() 14 | ] 15 | end 16 | 17 | def application do 18 | [ 19 | extra_applications: [:logger] 20 | ] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:raxx, "~> 0.17.6 or ~> 0.18.0 or ~> 1.0"}, 26 | {:eex_html, "~> 0.2.1 or ~> 1.0"}, 27 | {:ex_doc, ">= 0.0.0", only: :dev} 28 | ] 29 | end 30 | 31 | defp description do 32 | """ 33 | Generate HTML views from `.eex` template files for Raxx web applications. 34 | """ 35 | end 36 | 37 | defp package do 38 | [ 39 | maintainers: ["Peter Saxton"], 40 | licenses: ["Apache 2.0"], 41 | links: %{ 42 | "GitHub" => "https://github.com/crowdhailer/raxx/tree/master/extensions/raxx_view" 43 | } 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /extensions/raxx_view/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cookie": {:hex, :cookie, "0.1.1", "89438362ee0f0ed400e9f076d617d630f82d682e3fbcf767072a46a6e1ed5781", [:mix], [], "hexpm"}, 3 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, 4 | "eex_html": {:hex, :eex_html, "1.0.0", "c88020b584d5bfc48ef6c18176af2cfed558225fc5290b435e73119b8ce98ce0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 5 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 7 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 10 | "raxx": {:hex, :raxx, "1.0.1", "8c51ec5227c85f999360fc844fc1d4e2e5a2adf2b0ce068eb56243ee6b2f65e3", [:mix], [], "hexpm"}, 11 | } 12 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/other.html.eex: -------------------------------------------------------------------------------- 1 | <%= var %> 2 | <%= private() %> 3 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/partial.html.eex: -------------------------------------------------------------------------------- 1 | <%= var %> 2 | <%= private() %> 3 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view/layout_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.View.LayoutTest do 2 | use ExUnit.Case 3 | 4 | defmodule Helpers do 5 | def helper_function() do 6 | "helper_function" 7 | end 8 | end 9 | 10 | defmodule DefaultLayout do 11 | use Raxx.View.Layout, 12 | imports: [__MODULE__, Helpers], 13 | optional: [foo: "foo", bar: "foo", nested: %{inner: "inner"}] 14 | 15 | def layout_function() do 16 | "layout_function" 17 | end 18 | end 19 | 20 | defmodule DefaultLayoutExample do 21 | use DefaultLayout, 22 | arguments: [:x, :y], 23 | optional: [bar: "bar"], 24 | template: "layout_test_example.html.eex" 25 | end 26 | 27 | test "List of imports are available in template" do 28 | assert ["foobar", "inner", "7", "layout_function", "helper_function"] = 29 | lines("#{DefaultLayoutExample.html(3, 4)}") 30 | end 31 | 32 | # Inner checks that non primitive values can be set as defaults 33 | test "optional arguments can be overwritten in layout" do 34 | assert ["bazbaz", "inner", "7", "layout_function", "helper_function"] = 35 | lines("#{DefaultLayoutExample.html(3, 4, foo: "baz", bar: "baz")}") 36 | end 37 | 38 | defp lines(text) do 39 | String.split(text, ~r/\R/) 40 | |> Enum.reject(fn line -> line == "" end) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view/layout_test.html.eex: -------------------------------------------------------------------------------- 1 | <%= foo %><%= bar %> 2 | <%= nested.inner %> 3 | <%= __content__ %> 4 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view/layout_test_example.html.eex: -------------------------------------------------------------------------------- 1 | <%= x + y %> 2 | <%= layout_function() %> 3 | <%= helper_function() %> 4 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.ViewTest do 2 | use ExUnit.Case 3 | 4 | defmodule DefaultTemplate do 5 | use Raxx.View, arguments: [:var], optional: [opt: "opt"] 6 | defp private(), do: "DefaultTemplate" 7 | end 8 | 9 | defmodule WithLayout do 10 | use Raxx.View, arguments: [:var], optional: [opt: "opt"], layout: "view_test_layout.html.eex" 11 | 12 | defp private(), do: "WithLayout" 13 | end 14 | 15 | defmodule AbsoluteTemplate do 16 | use Raxx.View, arguments: [:var], template: Path.join(__DIR__, "view_test_other.html.eex") 17 | end 18 | 19 | defmodule RelativeTemplate do 20 | use Raxx.View, arguments: [:var], template: "view_test_other.html.eex" 21 | end 22 | 23 | test "Arguments and private module functions are available in templated" do 24 | assert ["foo", "opt", "DefaultTemplate"] = lines("#{DefaultTemplate.html("foo")}") 25 | end 26 | 27 | test "Optional arguments can be overwritten" do 28 | assert ["foo", "overwrite", "DefaultTemplate"] = 29 | lines("#{DefaultTemplate.html("foo", opt: "overwrite")}") 30 | end 31 | 32 | test "Unexpected optional arguments are an error" do 33 | assert_raise ArgumentError, "Unexpect optional variables 'random'", fn -> 34 | DefaultTemplate.html("foo", random: "random") 35 | end 36 | end 37 | 38 | test "HTML content is escaped" do 39 | assert "<p>" = hd(lines("#{DefaultTemplate.html("")}")) 40 | end 41 | 42 | test "Safe HTML content is not escaped" do 43 | assert "
" = hd(lines("#{DefaultTemplate.html(EExHTML.raw("
"))}")) 44 | end 45 | 46 | test "Render will set content-type and body" do 47 | response = 48 | Raxx.response(:ok) 49 | |> DefaultTemplate.render("bar", opt: "overwrite") 50 | 51 | assert ["bar", "overwrite", "DefaultTemplate"] = lines(response.body) 52 | assert [{"content-type", "text/html"}, {"content-length", "30"}] = response.headers 53 | end 54 | 55 | test "View can be rendered within a layout" do 56 | assert ["LAYOUT", "baz", "opt", "WithLayout"] = lines("#{WithLayout.html("baz")}") 57 | end 58 | 59 | test "Default template can changed" do 60 | assert ["OTHER", "5"] = lines("#{AbsoluteTemplate.html("5")}") 61 | end 62 | 63 | test "Template path can be relative to calling file" do 64 | assert ["OTHER", "5"] = lines("#{RelativeTemplate.html("5")}") 65 | end 66 | 67 | test "An layout missing space for content is invalid" do 68 | assert_raise ArgumentError, fn -> 69 | defmodule Tmp do 70 | use Raxx.View, layout: "view_test_invalid_layout.html.eex" 71 | end 72 | end 73 | end 74 | 75 | test "Unexpected options are an argument error" do 76 | assert_raise ArgumentError, fn -> 77 | defmodule Tmp do 78 | use Raxx.View, random: :foo 79 | end 80 | end 81 | end 82 | 83 | defp lines(text) do 84 | String.split("#{text}", ~r/\R/) 85 | |> Enum.reject(fn line -> line == "" end) 86 | end 87 | 88 | defmodule DefaultTemplatePartial do 89 | import Raxx.View 90 | 91 | partial(:partial, [:var]) 92 | 93 | defp private do 94 | "Default" 95 | end 96 | end 97 | 98 | defmodule RelativeTemplatePartial do 99 | import Raxx.View 100 | 101 | partial(:partial, [:var], template: "other.html.eex") 102 | 103 | defp private do 104 | "Relative" 105 | end 106 | end 107 | 108 | test "Arguments and private funcations are available in the partial template" do 109 | assert ["5", "Default"] = lines("#{DefaultTemplatePartial.partial("5")}") 110 | end 111 | 112 | test "HTML content in a partial is escaped" do 113 | assert ["5", "Default"] = lines("#{DefaultTemplatePartial.partial("5")}") 114 | end 115 | 116 | test "Partial template path can be relative to calling file" do 117 | assert ["5", "Relative"] = lines("#{RelativeTemplatePartial.partial("5")}") 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view_test.html.eex: -------------------------------------------------------------------------------- 1 | <%= var %> 2 | <%= opt %> 3 | <%= raw private() %> 4 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view_test_invalid_layout.html.eex: -------------------------------------------------------------------------------- 1 | LAYOUT 2 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view_test_layout.html.eex: -------------------------------------------------------------------------------- 1 | LAYOUT 2 | <%= __content__ %> 3 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/raxx/view_test_other.html.eex: -------------------------------------------------------------------------------- 1 | OTHER 2 | <%= var %> 3 | -------------------------------------------------------------------------------- /extensions/raxx_view/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/raxx/context.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Context do 2 | @type section_name :: term() 3 | 4 | @typedoc """ 5 | An opaque type for the context snapshot data. 6 | """ 7 | @opaque snapshot :: map() 8 | 9 | @moduledoc """ 10 | `Raxx.Context` is a mechanism for simple sharing of state/information between 11 | `Raxx.Middleware`s and `Raxx.Server`s. 12 | 13 | It is designed to be flexible and to enable different middlewares to operate 14 | on it without conflicts. Each separate functionality using the context 15 | can be in a different "section", containing arbitrary data. 16 | 17 | Context is implicitly shared using the process dictionary and persists for the 18 | duration of a single request/response cycle. If you want to pass the context 19 | to a different process, you need to take its snapshot, pass it explicitly and 20 | "restore" it in the other process. See `Raxx.Context.get_snapshot/0` and 21 | `Raxx.Context.restore_snapshot/1` for details. 22 | """ 23 | 24 | @doc """ 25 | Sets the value of a context section. 26 | 27 | Returns the previous value of the section or `nil` if one was 28 | not set. 29 | """ 30 | @spec set(section_name, term) :: term | nil 31 | def set(section_name, value) do 32 | Process.put(tag(section_name), value) 33 | end 34 | 35 | @doc """ 36 | Deletes the section from the context. 37 | 38 | Returns the previous value of the section or `nil` if one was 39 | not set. 40 | """ 41 | @spec delete(section_name) :: term | nil 42 | def delete(section_name) do 43 | Process.delete(tag(section_name)) 44 | end 45 | 46 | @doc """ 47 | Retrieves the value of the context section. 48 | 49 | If the section wasn't set yet, it will return `nil`. 50 | """ 51 | @spec retrieve(section_name, default :: term) :: term 52 | def retrieve(section_name, default \\ nil) do 53 | Process.get(tag(section_name), default) 54 | end 55 | 56 | @doc """ 57 | Restores a previously created context snapshot. 58 | 59 | It will restore the implicit state of the context for the current 60 | process to what it was when the snapshot was created using 61 | `Raxx.Context.get_snapshot/0`. The current context values won't 62 | be persisted in any way. 63 | """ 64 | @spec restore_snapshot(snapshot()) :: :ok 65 | def restore_snapshot(context) when is_map(context) do 66 | new_context_tuples = 67 | context 68 | |> Enum.map(fn {k, v} -> {tag(k), v} end) 69 | 70 | current_context_keys = 71 | Process.get_keys() 72 | |> Enum.filter(&tagged_key?/1) 73 | 74 | new_keys = Enum.map(new_context_tuples, fn {k, _v} -> k end) 75 | keys_to_remove = current_context_keys -- new_keys 76 | 77 | Enum.each(keys_to_remove, &Process.delete/1) 78 | Enum.each(new_context_tuples, fn {k, v} -> Process.put(k, v) end) 79 | end 80 | 81 | @doc """ 82 | Creates a snapshot of the current process' context. 83 | 84 | The returned context data can be passed between processes and restored 85 | using `Raxx.Context.restore_snapshot/1` 86 | """ 87 | @spec get_snapshot() :: snapshot() 88 | def get_snapshot() do 89 | Process.get() 90 | |> Enum.filter(fn {k, _v} -> tagged_key?(k) end) 91 | |> Enum.map(fn {k, v} -> {strip_tag(k), v} end) 92 | |> Map.new() 93 | end 94 | 95 | defp tagged_key?({__MODULE__, _}) do 96 | true 97 | end 98 | 99 | defp tagged_key?(_) do 100 | false 101 | end 102 | 103 | defp strip_tag({__MODULE__, key}) do 104 | key 105 | end 106 | 107 | defp tag(key) do 108 | {__MODULE__, key} 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/raxx/data.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Data do 2 | @moduledoc """ 3 | A part of an HTTP messages body. 4 | 5 | *NOTE: There are no guarantees on how a message's body will be divided into data.* 6 | """ 7 | 8 | @typedoc """ 9 | Container for a section of an HTTP message. 10 | """ 11 | @type t :: %__MODULE__{ 12 | data: iodata 13 | } 14 | 15 | @enforce_keys [:data] 16 | defstruct @enforce_keys 17 | end 18 | -------------------------------------------------------------------------------- /lib/raxx/middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Middleware do 2 | alias Raxx.Server 3 | 4 | @moduledoc """ 5 | A "middleware" is a component that sits between the HTTP server 6 | such as as [Ace](https://github.com/CrowdHailer/Ace) and a `Raxx.Server` controller. 7 | The middleware can modify requests request before giving it to the controller and 8 | modify the controllers response before it's given to the server. 9 | 10 | Oftentimes multiple middlewaress might be attached to a controller and 11 | function as a single `t:Raxx.Server.t/0` - see `Raxx.Stack` for details. 12 | 13 | The `Raxx.Middleware` provides a behaviour to be implemented by middlewares. 14 | 15 | ## Example 16 | 17 | Traditionally, middlewares are used for a variety of purposes: managing CORS, 18 | CSRF protection, logging, error handling, and many more. This example shows 19 | a middleware that given a HEAD request "translates" it to a GET one, hands 20 | it over to the controller and strips the response body transforms the 21 | response according to [RFC 2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.13) 22 | 23 | This way the controller doesn't heed to handle the HEAD case at all. 24 | 25 | defmodule Raxx.Middleware.Head do 26 | alias Raxx.Server 27 | alias Raxx.Middleware 28 | 29 | @behaviour Middleware 30 | 31 | @impl Middleware 32 | def process_head(request = %{method: :HEAD}, _config, inner_server) do 33 | request = %{request | method: :GET} 34 | state = :engage 35 | {parts, inner_server} = Server.handle_head(inner_server, request) 36 | 37 | parts = modify_response_parts(parts, state) 38 | {parts, state, inner_server} 39 | end 40 | 41 | def process_head(request = %{method: _}, _config, inner_server) do 42 | {parts, inner_server} = Server.handle_head(inner_server, request) 43 | {parts, :disengage, inner_server} 44 | end 45 | 46 | @impl Middleware 47 | def process_data(data, state, inner_server) do 48 | {parts, inner_server} = Server.handle_data(inner_server, data) 49 | parts = modify_response_parts(parts, state) 50 | {parts, state, inner_server} 51 | end 52 | 53 | @impl Middleware 54 | def process_tail(tail, state, inner_server) do 55 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 56 | parts = modify_response_parts(parts, state) 57 | {parts, state, inner_server} 58 | end 59 | 60 | @impl Middleware 61 | def process_info(info, state, inner_server) do 62 | {parts, inner_server} = Server.handle_info(inner_server, info) 63 | parts = modify_response_parts(parts, state) 64 | {parts, state, inner_server} 65 | end 66 | 67 | defp modify_response_parts(parts, :disengage) do 68 | parts 69 | end 70 | 71 | defp modify_response_parts(parts, :engage) do 72 | Enum.flat_map(parts, &do_handle_response_part(&1)) 73 | end 74 | 75 | defp do_handle_response_part(response = %Raxx.Response{}) do 76 | # the content-length will remain the same 77 | [%Raxx.Response{response | body: false}] 78 | end 79 | 80 | defp do_handle_response_part(%Raxx.Data{}) do 81 | [] 82 | end 83 | 84 | defp do_handle_response_part(%Raxx.Tail{}) do 85 | [] 86 | end 87 | end 88 | 89 | Within the callback implementations the middleware should call through 90 | to the "inner" server and make sure to return its updated state as part 91 | of the `t:Raxx.Middleware.next/0` tuple. 92 | 93 | In certain situations the middleware might want to short-circuit processing 94 | of the incoming messages, bypassing the server. In that case, it should not 95 | call through using `Raxx.Server`'s `handle_*` helper functions and return 96 | the `inner_server` unmodified. 97 | 98 | ## Gotchas 99 | 100 | ### Info messages forwarding 101 | 102 | As you can see in the above example, the middleware can even modify 103 | the `info` messages sent to the server and is responsible for forwarding them 104 | to the inner servers. 105 | 106 | ### Iodata contents 107 | 108 | While much of the time the request body, response body and data chunks will 109 | be represented with binaries, they can be represented 110 | as [`iodata`](https://hexdocs.pm/elixir/typespecs.html#built-in-types). 111 | 112 | A robust middleware should handle that. 113 | """ 114 | 115 | @typedoc """ 116 | The behaviour module and state/config of a raxx middleware 117 | """ 118 | @type t :: {module, state} 119 | 120 | @typedoc """ 121 | State of middleware. 122 | """ 123 | @type state :: any() 124 | 125 | @typedoc """ 126 | Values returned from the `process_*` callbacks 127 | """ 128 | @type next :: {[Raxx.part()], state, Server.t()} 129 | 130 | @doc """ 131 | Called once when a client starts a stream, 132 | 133 | The arguments a `Raxx.Request`, the middleware configuration and 134 | the "inner" server for the middleware to call through to. 135 | 136 | This callback can be relied upon to execute before any other callbacks 137 | """ 138 | @callback process_head(request :: Raxx.Request.t(), state(), inner_server :: Server.t()) :: 139 | next() 140 | 141 | @doc """ 142 | Called every time data from the request body is received. 143 | """ 144 | @callback process_data(binary(), state(), inner_server :: Server.t()) :: next() 145 | 146 | @doc """ 147 | Called once when a request finishes. 148 | 149 | This will be called with an empty list of headers is request is completed without trailers. 150 | 151 | Will not be called at all if the `t:Raxx.Request.t/0` passed to `c:process_head/3` had `body: false`. 152 | """ 153 | @callback process_tail(trailers :: [{binary(), binary()}], state(), inner_server :: Server.t()) :: 154 | next() 155 | 156 | @doc """ 157 | Called for all other messages the middleware may recieve. 158 | 159 | The middleware is responsible for forwarding them to the inner server. 160 | """ 161 | @callback process_info(any(), state(), inner_server :: Server.t()) :: next() 162 | 163 | defmacro __using__(_options) do 164 | quote do 165 | @behaviour unquote(__MODULE__) 166 | 167 | @impl unquote(__MODULE__) 168 | def process_head(request, state, inner_server) do 169 | {parts, inner_server} = Server.handle_head(inner_server, request) 170 | {parts, state, inner_server} 171 | end 172 | 173 | @impl unquote(__MODULE__) 174 | def process_data(data, state, inner_server) do 175 | {parts, inner_server} = Server.handle_data(inner_server, data) 176 | {parts, state, inner_server} 177 | end 178 | 179 | @impl unquote(__MODULE__) 180 | def process_tail(tail, state, inner_server) do 181 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 182 | {parts, state, inner_server} 183 | end 184 | 185 | @impl unquote(__MODULE__) 186 | def process_info(message, state, inner_server) do 187 | {parts, inner_server} = Server.handle_info(inner_server, message) 188 | {parts, state, inner_server} 189 | end 190 | 191 | defoverridable unquote(__MODULE__) 192 | end 193 | end 194 | 195 | @doc false 196 | @spec is_implemented?(module) :: boolean 197 | def is_implemented?(module) when is_atom(module) do 198 | # taken from Raxx.Server 199 | case Code.ensure_compiled(module) do 200 | {:module, module} -> 201 | module.module_info[:attributes] 202 | |> Keyword.get(:behaviour, []) 203 | |> Enum.member?(__MODULE__) 204 | _ -> false 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /lib/raxx/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Request do 2 | @moduledoc """ 3 | HTTP requests to a Raxx application are encapsulated in a `Raxx.Request` struct. 4 | 5 | A request has all the properties of the url it was sent to. 6 | In addition it has optional content, in the body. 7 | As well as a variable number of headers that contain meta data. 8 | 9 | Where appropriate URI properties are named from this definition. 10 | 11 | > scheme:[//[user:password@]host[:port]][/]path[?query][#fragment] 12 | 13 | from [wikipedia](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax) 14 | 15 | The contents are itemised below: 16 | 17 | | **scheme** | `http` or `https`, depending on the transport used. | 18 | | **authority** | The location of the hosting server, as a binary. e.g. `www.example.com`. Plus an optional port number, separated from the hostname by a colon | 19 | | **method** | The HTTP request method, such as `:GET` or `:POST`, as an atom. This cannot ever be `nil`. It is always uppercase. | 20 | | **path** | The remainder of the request URL's “path”, split into segments. It designates the virtual “location” of the request's target within the application. This may be an empty array, if the requested URL targets the application root. | 21 | | **raw_path** | The request URL's "path" | 22 | | **query** | the URL query string. | 23 | | **headers** | The headers from the HTTP request as a proplist of strings. Note all headers will be downcased, e.g. `[{"content-type", "text/plain"}]` | 24 | | **body** | The body content sent with the request | 25 | 26 | """ 27 | 28 | @typedoc """ 29 | Method to indicate the desired action to be performed on the identified resource. 30 | """ 31 | @type method :: atom 32 | 33 | @typedoc """ 34 | Scheme describing protocol used. 35 | """ 36 | @type scheme :: :http | :https 37 | 38 | @typedoc """ 39 | Elixir representation for an HTTP request. 40 | """ 41 | @type t :: %__MODULE__{ 42 | scheme: scheme, 43 | authority: binary, 44 | method: method, 45 | path: [binary], 46 | raw_path: binary, 47 | query: binary | nil, 48 | headers: Raxx.headers(), 49 | body: Raxx.body() 50 | } 51 | 52 | defstruct scheme: nil, 53 | authority: nil, 54 | method: nil, 55 | path: [], 56 | raw_path: "", 57 | query: nil, 58 | headers: [], 59 | body: nil 60 | 61 | @default_ports %{ 62 | http: 80, 63 | https: 443 64 | } 65 | 66 | @doc """ 67 | Return the host value for the request. 68 | 69 | The `t:Raxx.Request.t/0` struct contains `authority` field, which 70 | may contain the port number. This function returns the host value which 71 | won't include the port number. 72 | """ 73 | def host(%__MODULE__{authority: authority}) do 74 | hd(String.split(authority, ":")) 75 | end 76 | 77 | @doc """ 78 | Return the port number used for the request. 79 | 80 | If no port number is explicitly specified in the request url, the 81 | default one for the scheme is used. 82 | """ 83 | @spec port(t, %{optional(atom) => :inet.port_number()}) :: :inet.port_number() 84 | def port(%__MODULE__{scheme: scheme, authority: authority}, default_ports \\ @default_ports) do 85 | case String.split(authority, ":") do 86 | [_host] -> 87 | Map.get(default_ports, scheme) 88 | 89 | [_host, port_string] -> 90 | case Integer.parse(port_string) do 91 | {port, _} when port in 0..65535 -> 92 | port 93 | end 94 | end 95 | end 96 | 97 | @doc """ 98 | Returns an `URI` struct corresponding to the url used in the provided request. 99 | 100 | **NOTE**: the `userinfo` field of the `URI` will always be `nil`, even if there 101 | is `Authorization` header basic auth information contained in the request. 102 | 103 | The `fragment` will also be `nil`, as the servers don't have access to it. 104 | """ 105 | @spec uri(t) :: URI.t() 106 | def uri(%__MODULE__{} = request) do 107 | scheme = 108 | case request.scheme do 109 | nil -> nil 110 | atom when is_atom(atom) -> Atom.to_string(atom) 111 | end 112 | 113 | %URI{ 114 | authority: request.authority, 115 | host: Raxx.request_host(request), 116 | path: request.raw_path, 117 | port: port(request), 118 | query: request.query, 119 | scheme: scheme, 120 | # you can't provide userinfo in a http request url (anymore) 121 | # pulling it out of Authorization headers would go against the 122 | # main use-case for this function 123 | userinfo: nil 124 | } 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/raxx/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Response do 2 | @moduledoc """ 3 | HTTP responses from a Raxx application are encapsulated in a `Raxx.Response` struct. 4 | 5 | The contents are itemised below: 6 | 7 | | **status** | The HTTP status code for the response: `1xx, 2xx, 3xx, 4xx, 5xx` | 8 | | **headers** | The response headers as a list: `[{"content-type", "text/plain"}` | 9 | | **body** | The response body, by default an empty string. | 10 | 11 | """ 12 | 13 | @typedoc """ 14 | Integer code for server response type 15 | """ 16 | @type status_code :: integer 17 | 18 | @typedoc """ 19 | Elixir representation for an HTTP response. 20 | """ 21 | @type t :: %__MODULE__{ 22 | status: status_code, 23 | headers: Raxx.headers(), 24 | body: Raxx.body() 25 | } 26 | 27 | @enforce_keys [:status, :headers, :body] 28 | defstruct @enforce_keys 29 | end 30 | -------------------------------------------------------------------------------- /lib/raxx/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Router do 2 | @moduledoc """ 3 | Routing for Raxx applications. 4 | 5 | Routes are defined as a match and an action module. 6 | Standard Elixir pattern matching is used to apply the match to an incoming request. 7 | An action module another implementation of `Raxx.Server` 8 | 9 | Sections group routes that all have the same middleware. 10 | Middleware in a section maybe defined as a list, 11 | this is useful when all configuration is known at compile-time. 12 | Alternativly an arity 1 function can be used. 13 | This can be used when middleware require runtime configuration. 14 | The argument passed to this function is server initial state. 15 | 16 | ## Examples 17 | 18 | defmodule MyRouter do 19 | use Raxx.Router 20 | 21 | section [{Raxx.Logger, level: :debug}], [ 22 | {%{method: :GET, path: ["ping"]}, Ping}, 23 | ] 24 | 25 | section &web/1, [ 26 | {%{method: :GET, path: []}, HomePage}, 27 | {%{method: :GET, path: ["users"]}, UsersPage}, 28 | {%{method: :GET, path: ["users", _id]}, UserPage}, 29 | {%{method: :POST, path: ["users"]}, CreateUser}, 30 | {_, NotFoundPage} 31 | ] 32 | 33 | def web(state) do 34 | [ 35 | {Raxx.Logger, level: state.log_level}, 36 | {MyMiddleware, foo: state.foo} 37 | ] 38 | end 39 | end 40 | *If the sections DSL does not work for an application it is possible to instead just implement a `route/2` function.* 41 | """ 42 | 43 | @callback route(Raxx.Request.t(), term) :: Raxx.Stack.t() 44 | 45 | @doc false 46 | defmacro __using__([]) do 47 | quote location: :keep do 48 | @behaviour Raxx.Server 49 | import unquote(__MODULE__) 50 | @behaviour unquote(__MODULE__) 51 | 52 | @impl Raxx.Server 53 | def handle_head(request, state) do 54 | stack = route(request, state) 55 | Raxx.Server.handle_head(stack, request) 56 | end 57 | 58 | @impl Raxx.Server 59 | def handle_data(data, stack) do 60 | Raxx.Server.handle_data(stack, data) 61 | end 62 | 63 | @impl Raxx.Server 64 | def handle_tail(trailers, stack) do 65 | Raxx.Server.handle_tail(stack, trailers) 66 | end 67 | 68 | @impl Raxx.Server 69 | def handle_info(message, stack) do 70 | Raxx.Server.handle_info(stack, message) 71 | end 72 | end 73 | end 74 | 75 | @doc """ 76 | Define a set of routes with a common set of middlewares applied to them. 77 | 78 | The first argument may be a list of middlewares; 79 | or a function that accepts one argument, the initial state, and returns a list of middleware. 80 | 81 | If all settings for a middleware can be decided at compile-time then a list is preferable. 82 | """ 83 | defmacro section(middlewares, routes) do 84 | state = quote do: state 85 | 86 | resolved_middlewares = 87 | case middlewares do 88 | middlewares when is_list(middlewares) -> 89 | middlewares 90 | 91 | _ -> 92 | quote do 93 | unquote(middlewares).(unquote(state)) 94 | end 95 | end 96 | 97 | for {match, action} <- routes do 98 | quote do 99 | def route(unquote(match), unquote(state)) do 100 | # Should this verify_implementation for the action/middlewares 101 | # Perhaps Stack.new should do it 102 | Raxx.Stack.new(unquote(resolved_middlewares), {unquote(action), unquote(state)}) 103 | end 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/raxx/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Server do 2 | @moduledoc """ 3 | Interface to handle server side communication in an HTTP message exchange. 4 | 5 | If simple `request -> response` transformation is possible, try `Raxx.SimpleServer` 6 | 7 | *A module implementing `Raxx.Server` is run by an HTTP server. 8 | For example [Ace](https://github.com/CrowdHailer/Ace) 9 | can run such a module for both HTTP/1.x and HTTP/2 exchanges* 10 | 11 | ## Getting Started 12 | 13 | **Send complete response as soon as request headers are received.** 14 | 15 | defmodule HelloServer do 16 | use Raxx.Server 17 | 18 | def handle_head(%Raxx.Request{method: :GET, path: []}, _state) do 19 | response(:ok) 20 | |> set_header("content-type", "text/plain") 21 | |> set_body("Hello, World!") 22 | end 23 | end 24 | 25 | **Store data as it is available from a clients request** 26 | 27 | defmodule StreamingRequest do 28 | use Raxx.Server 29 | 30 | def handle_head(%Raxx.Request{method: :PUT, body: true}, _state) do 31 | {:ok, io_device} = File.open("my/path") 32 | {[], {:file, device}} 33 | end 34 | 35 | def handle_data(body_chunk, state = {:file, device}) do 36 | IO.write(device, body_chunk) 37 | {[], state} 38 | end 39 | 40 | def handle_tail(_trailers, state) do 41 | response(:see_other) 42 | |> set_header("location", "/") 43 | end 44 | end 45 | 46 | **Subscribe server to event source and forward notifications to client.** 47 | 48 | defmodule SubscribeToMessages do 49 | use Raxx.Server 50 | 51 | def handle_head(_request, _state) do 52 | {:ok, _} = ChatRoom.join() 53 | response(:ok) 54 | |> set_header("content-type", "text/event-stream") 55 | |> set_body(true) 56 | end 57 | 58 | def handle_info({ChatRoom, data}, state) do 59 | {[body(data)], state} 60 | end 61 | end 62 | 63 | ### Notes 64 | 65 | - `handle_head/2` will always be called with a request that has body as a boolean. 66 | For small requests where buffering the whole request is acceptable a simple middleware can be used. 67 | - Acceptable return values are the same for all callbacks; 68 | either a `Raxx.Response`, which must be complete or 69 | a list of message parts and a new state. 70 | 71 | ## Streaming 72 | 73 | `Raxx.Server` defines an interface to stream the body of request and responses. 74 | 75 | This has several advantages: 76 | 77 | - Large payloads do not need to be help in memory 78 | - Server can push information as it becomes available, using Server Sent Events. 79 | - If a request has invalid headers then a reply can be set without handling the body. 80 | - Content can be generated as requested using HTTP/2 flow control 81 | 82 | The body of a Raxx message (Raxx.Request or `Raxx.Response`) may be one of three types: 83 | 84 | - `iodata` - This is the complete body for the message. 85 | - `:false` - There **is no** body, for example `:GET` requests never have a body. 86 | - `:true` - There **is** a body, it can be processed as it is received 87 | 88 | ## Server Isolation 89 | 90 | To start an exchange a client sends a request. 91 | The server, upon receiving this message, sends a reply. 92 | A logical HTTP exchange consists of a single request and response. 93 | 94 | Methods such as [pipelining](https://en.wikipedia.org/wiki/HTTP_pipelining) 95 | and [multiplexing](http://qnimate.com/what-is-multiplexing-in-http2/) 96 | combine multiple logical exchanges onto a single connection. 97 | This is done to improve performance and is a detail not exposed a server. 98 | 99 | A Raxx server handles a single HTTP exchange. 100 | Therefore a single connection my have multiple servers each isolated in their own process. 101 | 102 | ## Termination 103 | 104 | An exchange can be stopped early by terminating the server process. 105 | Support for early termination is not consistent between versions of HTTP. 106 | 107 | - HTTP/2: server exit with reason `:normal`, stream reset with error `CANCEL`. 108 | - HTTP/2: server exit any other reason, stream reset with error `INTERNAL_ERROR`. 109 | - HTTP/1.x: server exit with any reason, connection is closed. 110 | 111 | `Raxx.Server` does not provide a terminate callback. 112 | Any cleanup that needs to be done from an aborted exchange should be handled by monitoring the server process. 113 | """ 114 | 115 | @typedoc """ 116 | The behaviour and state of a raxx server 117 | """ 118 | @type t :: {module, state} 119 | 120 | @typedoc """ 121 | State of application server. 122 | 123 | Original value is the configuration given when starting the raxx application. 124 | """ 125 | @type state :: any() 126 | 127 | @typedoc """ 128 | Possible return values instructing server to send client data and update state if appropriate. 129 | """ 130 | @type next :: {[Raxx.part()], state} | Raxx.Response.t() 131 | 132 | @doc """ 133 | Called once when a client starts a stream, 134 | 135 | Passed a `Raxx.Request` and server configuration. 136 | Note the value of the request body will be a boolean. 137 | 138 | This callback can be relied upon to execute before any other callbacks 139 | """ 140 | @callback handle_head(Raxx.Request.t(), state()) :: next 141 | 142 | @doc """ 143 | Called every time data from the request body is received 144 | """ 145 | @callback handle_data(binary(), state()) :: next 146 | 147 | @doc """ 148 | Called once when a request finishes. 149 | 150 | This will be called with an empty list of headers is request is completed without trailers. 151 | 152 | Will not be called at all if the `t:Raxx.Request.t/0` struct passed to `c:handle_head/2` had `body: false`. 153 | """ 154 | @callback handle_tail([{binary(), binary()}], state()) :: next 155 | 156 | @doc """ 157 | Called for all other messages the server may recieve 158 | """ 159 | @callback handle_info(any(), state()) :: next 160 | 161 | defmacro __using__(_options) do 162 | quote do 163 | @behaviour unquote(__MODULE__) 164 | import Raxx 165 | 166 | @impl unquote(__MODULE__) 167 | def handle_data(data, state) do 168 | import Logger 169 | Logger.warn("Received unexpected data: #{inspect(data)}") 170 | {[], state} 171 | end 172 | 173 | @impl unquote(__MODULE__) 174 | def handle_tail(trailers, state) do 175 | import Logger 176 | Logger.warn("Received unexpected trailers: #{inspect(trailers)}") 177 | {[], state} 178 | end 179 | 180 | @impl unquote(__MODULE__) 181 | def handle_info(message, state) do 182 | import Logger 183 | Logger.warn("Received unexpected message: #{inspect(message)}") 184 | {[], state} 185 | end 186 | 187 | defoverridable unquote(__MODULE__) 188 | end 189 | end 190 | 191 | @doc """ 192 | Execute a server module and current state in response to a new message 193 | """ 194 | @spec handle(t, term) :: {[Raxx.part()], state()} 195 | def handle({module, state}, request = %Raxx.Request{}) do 196 | normalize_reaction(module.handle_head(request, state), state) 197 | end 198 | 199 | def handle({module, state}, %Raxx.Data{data: data}) do 200 | normalize_reaction(module.handle_data(data, state), state) 201 | end 202 | 203 | def handle({module, state}, %Raxx.Tail{headers: headers}) do 204 | normalize_reaction(module.handle_tail(headers, state), state) 205 | end 206 | 207 | def handle({module, state}, other) do 208 | normalize_reaction(module.handle_info(other, state), state) 209 | end 210 | 211 | @doc """ 212 | Similar to `Raxx.Server.handle/2`, except it only accepts `t:Raxx.Request.t/0` 213 | and returns the whole server, not just its state. 214 | 215 | `Raxx.Server.handle/2` uses the data structures sent from the http server, 216 | whereas `Raxx.Server.handle_*` use the "unpacked" data, in the shape defined 217 | by callbacks. 218 | """ 219 | @spec handle_head(t(), Raxx.Request.t()) :: {[Raxx.part()], t()} 220 | def handle_head({module, state}, request = %Raxx.Request{}) do 221 | {parts, new_state} = normalize_reaction(module.handle_head(request, state), state) 222 | {parts, {module, new_state}} 223 | end 224 | 225 | @doc """ 226 | Similar to `Raxx.Server.handle/2`, except it only accepts the "unpacked", binary data 227 | and returns the whole server, not just its state. 228 | 229 | `Raxx.Server.handle/2` uses the data structures sent from the http server, 230 | whereas `Raxx.Server.handle_*` use the "unpacked" data, in the shape defined 231 | by callbacks. 232 | """ 233 | @spec handle_data(t(), binary()) :: {[Raxx.part()], t()} 234 | def handle_data({module, state}, data) do 235 | {parts, new_state} = normalize_reaction(module.handle_data(data, state), state) 236 | {parts, {module, new_state}} 237 | end 238 | 239 | @doc """ 240 | Similar to `Raxx.Server.handle/2`, except it only accepts the "unpacked", trailers 241 | and returns the whole server, not just its state. 242 | 243 | `Raxx.Server.handle/2` uses the data structures sent from the http server, 244 | whereas `Raxx.Server.handle_*` use the "unpacked" data, in the shape defined 245 | by callbacks. 246 | """ 247 | @spec handle_tail(t(), [{binary(), binary()}]) :: {[Raxx.part()], t()} 248 | def handle_tail({module, state}, tail) do 249 | {parts, new_state} = normalize_reaction(module.handle_tail(tail, state), state) 250 | {parts, {module, new_state}} 251 | end 252 | 253 | @doc """ 254 | Similar to `Raxx.Server.handle/2`, except it only accepts the "unpacked", trailers 255 | and returns the whole server, not just its state. 256 | 257 | `Raxx.Server.handle/2` uses the data structures sent from the http server, 258 | whereas `Raxx.Server.handle_*` use the "unpacked" data, in the shape defined 259 | by callbacks. 260 | """ 261 | @spec handle_info(t(), any()) :: {[Raxx.part()], t()} 262 | def handle_info({module, state}, info) do 263 | {parts, new_state} = normalize_reaction(module.handle_info(info, state), state) 264 | {parts, {module, new_state}} 265 | end 266 | 267 | @doc false 268 | @spec normalize_reaction(next(), state() | module()) :: 269 | {[Raxx.part()], state() | module()} | no_return 270 | def normalize_reaction(response = %Raxx.Response{body: true}, _initial_state) do 271 | raise %ReturnError{return: response} 272 | end 273 | 274 | def normalize_reaction(response = %Raxx.Response{}, initial_state) do 275 | {[response], initial_state} 276 | end 277 | 278 | def normalize_reaction({parts, new_state}, _initial_state) when is_list(parts) do 279 | {parts, new_state} 280 | end 281 | 282 | def normalize_reaction(other, _initial_state) do 283 | raise %ReturnError{return: other} 284 | end 285 | 286 | @doc """ 287 | Verify server can be run? 288 | 289 | A runnable server consists of a tuple of server module and initial state. 290 | The server module must implement this modules behaviour. 291 | The initial state can be any term 292 | 293 | ## Examples 294 | 295 | # Could just call verify 296 | iex> Raxx.Server.verify_server({Raxx.ServerTest.DefaultServer, %{}}) 297 | {:ok, {Raxx.ServerTest.DefaultServer, %{}}} 298 | 299 | iex> Raxx.Server.verify_server({GenServer, %{}}) 300 | {:error, {:not_a_server_module, GenServer}} 301 | 302 | iex> Raxx.Server.verify_server({NotAModule, %{}}) 303 | {:error, {:not_a_module, NotAModule}} 304 | """ 305 | def verify_server({module, term}) do 306 | case verify_implementation(module) do 307 | {:ok, _} -> 308 | {:ok, {module, term}} 309 | 310 | {:error, reason} -> 311 | {:error, reason} 312 | end 313 | end 314 | 315 | @doc false 316 | def verify_implementation!(module) do 317 | case Raxx.Server.verify_implementation(module) do 318 | {:ok, _} -> 319 | :no_op 320 | 321 | {:error, {:not_a_server_module, module}} -> 322 | raise ArgumentError, "module `#{module}` does not implement `Raxx.Server` behaviour." 323 | 324 | {:error, {:not_a_module, module}} -> 325 | raise ArgumentError, "module `#{module}` could not be loaded." 326 | end 327 | end 328 | 329 | @doc false 330 | def verify_implementation(module) do 331 | case fetch_behaviours(module) do 332 | {:ok, behaviours} -> 333 | if Enum.member?(behaviours, __MODULE__) do 334 | {:ok, module} 335 | else 336 | {:error, {:not_a_server_module, module}} 337 | end 338 | 339 | {:error, reason} -> 340 | {:error, reason} 341 | end 342 | end 343 | 344 | defp fetch_behaviours(module) do 345 | case Code.ensure_compiled(module) do 346 | {:module, _module} -> 347 | behaviours = 348 | module.module_info[:attributes] 349 | |> Keyword.take([:behaviour]) 350 | |> Keyword.values() 351 | |> List.flatten() 352 | 353 | {:ok, behaviours} 354 | 355 | _ -> 356 | {:error, {:not_a_module, module}} 357 | end 358 | end 359 | end 360 | -------------------------------------------------------------------------------- /lib/raxx/server/return_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ReturnError do 2 | @moduledoc """ 3 | Raise when a server module returns an invalid reaction 4 | """ 5 | 6 | # DEBT could be improved by including server module in message and if it implements behaviour. 7 | defexception [:return] 8 | 9 | def message(%{return: return}) do 10 | """ 11 | Invalid reaction from server module. Response must be complete or include update server state 12 | 13 | e.g. 14 | \# Complete 15 | Raxx.response(:ok) 16 | |> Raxx.set_body("Hello, World!") 17 | 18 | \# New server state 19 | response = Raxx.response(:ok) 20 | |> Raxx.set_body(true) 21 | {[response], new_state} 22 | 23 | Actual value returned was 24 | #{inspect(return)} 25 | """ 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/raxx/simple_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.SimpleServer do 2 | @moduledoc """ 3 | Server interface for simple `request -> response` interactions. 4 | 5 | *Modules that use Raxx.SimpleServer implement the Raxx.Server behaviour. 6 | Default implementations are provided for the streaming interface to buffer the request before a single call to `handle_request/2`.* 7 | 8 | ## Example 9 | 10 | Echo the body of a request to the client 11 | 12 | defmodule EchoServer do 13 | use Raxx.SimpleServer, maximum_body_length: 12 * 1024 * 1024 14 | 15 | def handle_request(%Raxx.Request{method: :POST, path: [], body: body}, _state) do 16 | response(:ok) 17 | |> set_header("content-type", "text/plain") 18 | |> set_body(body) 19 | end 20 | end 21 | 22 | ## Options 23 | 24 | - **maximum_body_length** (default 8MB) the maximum sized body that will be automatically buffered. 25 | For large requests, e.g. file uploads, consider implementing a streaming server. 26 | 27 | """ 28 | 29 | @typedoc """ 30 | State of application server. 31 | 32 | Original value is the configuration given when starting the raxx application. 33 | """ 34 | @type state :: any() 35 | 36 | @doc """ 37 | Called with a complete request once all the data parts of a body are received. 38 | 39 | Passed a `Raxx.Request` and server configuration. 40 | Note the value of the request body will be a string. 41 | """ 42 | @callback handle_request(Raxx.Request.t(), state()) :: Raxx.Response.t() 43 | 44 | @eight_MB 8 * 1024 * 1024 45 | 46 | defmacro __using__(options) do 47 | {options, []} = Module.eval_quoted(__CALLER__, options) 48 | maximum_body_length = Keyword.get(options, :maximum_body_length, @eight_MB) 49 | 50 | quote do 51 | @behaviour unquote(__MODULE__) 52 | import Raxx 53 | 54 | @behaviour Raxx.Server 55 | 56 | def handle_head(request = %{body: false}, state) do 57 | response = __MODULE__.handle_request(%{request | body: ""}, state) 58 | 59 | case response do 60 | %{body: true} -> raise "Incomplete response" 61 | _ -> response 62 | end 63 | end 64 | 65 | def handle_head(request = %{body: true}, state) do 66 | {[], {request, [], state}} 67 | end 68 | 69 | def handle_data(data, {request, iodata_buffer, state}) do 70 | iodata_buffer = [data | iodata_buffer] 71 | 72 | if :erlang.iolist_size(iodata_buffer) <= unquote(maximum_body_length) do 73 | {[], {request, iodata_buffer, state}} 74 | else 75 | Raxx.error_response(:payload_too_large) 76 | end 77 | end 78 | 79 | def handle_tail([], {request, iodata_buffer, state}) do 80 | body = :erlang.iolist_to_binary(Enum.reverse(iodata_buffer)) 81 | response = __MODULE__.handle_request(%{request | body: body}, state) 82 | 83 | case response do 84 | %{body: true} -> raise "Incomplete response" 85 | _ -> response 86 | end 87 | end 88 | 89 | def handle_info(message, state) do 90 | require Logger 91 | 92 | Logger.warn( 93 | "#{inspect(self())} received unexpected message in handle_info/2: #{inspect(message)}" 94 | ) 95 | 96 | {[], state} 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/raxx/stack.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Stack do 2 | alias Raxx.Server 3 | alias Raxx.Middleware 4 | 5 | @behaviour Server 6 | 7 | @moduledoc """ 8 | A `Raxx.Stack` is a list of `Raxx.Middleware`s attached to a `Raxx.Server`. 9 | It implements the `Raxx.Server` interface itself so it can be used anywhere 10 | "normal" server can be. 11 | """ 12 | 13 | defmodule State do 14 | @moduledoc false 15 | 16 | @enforce_keys [:middlewares, :server] 17 | defstruct @enforce_keys 18 | 19 | # DEBT: compare struct t() performance to a (tagged) tuple implementation 20 | @type t :: %__MODULE__{ 21 | middlewares: [Middleware.t()], 22 | server: Server.t() 23 | } 24 | 25 | def new(middlewares \\ [], server) when is_list(middlewares) do 26 | %__MODULE__{ 27 | middlewares: middlewares, 28 | server: server 29 | } 30 | end 31 | 32 | def get_server(%__MODULE__{server: server}) do 33 | server 34 | end 35 | 36 | def set_server(state = %__MODULE__{}, {_, _} = server) do 37 | %__MODULE__{state | server: server} 38 | end 39 | 40 | def get_middlewares(%__MODULE__{middlewares: middlewares}) do 41 | middlewares 42 | end 43 | 44 | def set_middlewares(state = %__MODULE__{}, middlewares) when is_list(middlewares) do 45 | %__MODULE__{state | middlewares: middlewares} 46 | end 47 | 48 | @spec push_middleware(t(), Middleware.t()) :: t() 49 | def push_middleware(state = %__MODULE__{middlewares: middlewares}, middleware) do 50 | %__MODULE__{state | middlewares: [middleware | middlewares]} 51 | end 52 | 53 | @spec pop_middleware(t()) :: {Middleware.t() | nil, t()} 54 | def pop_middleware(state = %__MODULE__{middlewares: middlewares}) do 55 | case middlewares do 56 | [] -> 57 | {nil, state} 58 | 59 | [topmost | rest] -> 60 | {topmost, %__MODULE__{state | middlewares: rest}} 61 | end 62 | end 63 | end 64 | 65 | @typedoc """ 66 | The internal state of the `Raxx.Stack`. 67 | 68 | Its structure shouldn't be relied on, it is subject to change without warning. 69 | """ 70 | @opaque state :: State.t() 71 | 72 | @typedoc """ 73 | Represents a pipeline of middlewares attached to a server. 74 | 75 | Can be used exactly as any `t:Raxx.Server.t/0` could be. 76 | """ 77 | @type t :: {__MODULE__, state()} 78 | 79 | ## Public API 80 | 81 | @doc """ 82 | Creates a new stack from a list of middlewares and a server. 83 | """ 84 | @spec new([Middleware.t()], Server.t()) :: t() 85 | def new(middlewares \\ [], server) when is_list(middlewares) do 86 | {__MODULE__, State.new(middlewares, server)} 87 | end 88 | 89 | @doc """ 90 | Replaces the server in the stack. 91 | """ 92 | @spec set_server(t(), Server.t()) :: t() 93 | def set_server({__MODULE__, state}, server) do 94 | {__MODULE__, State.set_server(state, server)} 95 | end 96 | 97 | @doc """ 98 | Returns the server contained in the stack. 99 | """ 100 | @spec get_server(t()) :: Server.t() 101 | def get_server({__MODULE__, state}) do 102 | State.get_server(state) 103 | end 104 | 105 | @doc """ 106 | Replaces the middlewares in the stack. 107 | """ 108 | @spec set_middlewares(t(), [Middleware.t()]) :: t() 109 | def set_middlewares({__MODULE__, state}, middlewares) do 110 | {__MODULE__, State.set_middlewares(state, middlewares)} 111 | end 112 | 113 | @doc """ 114 | Returns the server contained in the stack. 115 | """ 116 | @spec get_middlewares(t()) :: [Middleware.t()] 117 | def get_middlewares({__MODULE__, state}) do 118 | State.get_middlewares(state) 119 | end 120 | 121 | ## Raxx.Server callbacks 122 | 123 | # NOTE those 4 can be rewritten using macros instead of apply for a minor performance increase 124 | @impl Server 125 | def handle_head(request, state) do 126 | handle_anything(request, state, :handle_head, :process_head) 127 | end 128 | 129 | @impl Server 130 | def handle_data(data, state) do 131 | handle_anything(data, state, :handle_data, :process_data) 132 | end 133 | 134 | @impl Server 135 | def handle_tail(tail, state) do 136 | handle_anything(tail, state, :handle_tail, :process_tail) 137 | end 138 | 139 | @impl Server 140 | def handle_info(message, state) do 141 | handle_anything(message, state, :handle_info, :process_info) 142 | end 143 | 144 | defp handle_anything(input, state, server_function, middleware_function) do 145 | case State.pop_middleware(state) do 146 | {nil, ^state} -> 147 | # time for the inner server to handle input 148 | server = State.get_server(state) 149 | {parts, new_server} = apply(Server, server_function, [server, input]) 150 | 151 | state = State.set_server(state, new_server) 152 | {parts, state} 153 | 154 | {middleware, state} -> 155 | # the top middleware was popped off the stack 156 | {middleware_module, middleware_state} = middleware 157 | 158 | {parts, middleware_state, {__MODULE__, state}} = 159 | apply(middleware_module, middleware_function, [ 160 | input, 161 | middleware_state, 162 | {__MODULE__, state} 163 | ]) 164 | 165 | state = State.push_middleware(state, {middleware_module, middleware_state}) 166 | {parts, state} 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/raxx/tail.ex: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Tail do 2 | @moduledoc """ 3 | A trailer allows the sender to include additional fields at the end of a streamed message. 4 | """ 5 | @typedoc """ 6 | Container for optional trailers of an HTTP message. 7 | """ 8 | @type t :: %__MODULE__{ 9 | headers: [{String.t(), String.t()}] 10 | } 11 | 12 | @enforce_keys [:headers] 13 | defstruct @enforce_keys 14 | end 15 | -------------------------------------------------------------------------------- /lib/status.rfc7231: -------------------------------------------------------------------------------- 1 | 100 Continue 2 | 101 Switching Protocols 3 | 200 OK 4 | 201 Created 5 | 202 Accepted 6 | 203 Non-Authoritative Information 7 | 204 No Content 8 | 205 Reset Content 9 | 206 Partial Content 10 | 300 Multiple Choices 11 | 301 Moved Permanently 12 | 302 Found 13 | 303 See Other 14 | 304 Not Modified 15 | 305 Use Proxy 16 | 307 Temporary Redirect 17 | 400 Bad Request 18 | 401 Unauthorized 19 | 402 Payment Required 20 | 403 Forbidden 21 | 404 Not Found 22 | 405 Method Not Allowed 23 | 406 Not Acceptable 24 | 407 Proxy Authentication Required 25 | 408 Request Timeout 26 | 409 Conflict 27 | 410 Gone 28 | 411 Length Required 29 | 412 Precondition Failed 30 | 413 Payload Too Large 31 | 414 URI Too Long 32 | 415 Unsupported Media Type 33 | 416 Range Not Satisfiable 34 | 417 Expectation Failed 35 | 426 Upgrade Required 36 | 500 Internal Server Error 37 | 501 Not Implemented 38 | 502 Bad Gateway 39 | 503 Service Unavailable 40 | 504 Gateway Timeout 41 | 505 HTTP Version Not Supported 42 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :raxx, 7 | version: "1.1.0", 8 | elixir: "~> 1.6", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | elixirc_options: [ 13 | warnings_as_errors: true 14 | ], 15 | description: description(), 16 | docs: [extras: ["README.md"], main: "readme", assets: ["assets"]], 17 | package: package(), 18 | aliases: aliases() 19 | ] 20 | end 21 | 22 | def application do 23 | [extra_applications: [:logger, :ssl, :eex]] 24 | end 25 | 26 | defp deps do 27 | [ 28 | {:dialyxir, "~> 0.5", only: [:dev, :test], runtime: false}, 29 | {:ex_doc, ">= 0.0.0", only: :dev}, 30 | {:benchee, "~> 0.13.2", only: [:dev, :test]} 31 | ] 32 | end 33 | 34 | defp description do 35 | """ 36 | Interface for HTTP webservers, frameworks and clients. 37 | """ 38 | end 39 | 40 | defp package do 41 | [ 42 | maintainers: ["Peter Saxton"], 43 | licenses: ["Apache 2.0"], 44 | links: %{"GitHub" => "https://github.com/crowdhailer/raxx"} 45 | ] 46 | end 47 | 48 | defp aliases do 49 | [ 50 | test: ["test --exclude deprecations --exclude benchmarks"] 51 | ] 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "0.13.2", "30cd4ff5f593fdd218a9b26f3c24d580274f297d88ad43383afe525b1543b165", [:mix], [{:deep_merge, "~> 0.1", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "deep_merge": {:hex, :deep_merge, "0.2.0", "c1050fa2edf4848b9f556fba1b75afc66608a4219659e3311d9c9427b5b680b3", [:mix], [], "hexpm"}, 4 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 5 | "earmark": {:hex, :earmark, "1.3.6", "ce1d0675e10a5bb46b007549362bd3f5f08908843957687d8484fe7f37466b19", [:mix], [], "hexpm"}, 6 | "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, 10 | } 11 | -------------------------------------------------------------------------------- /test/raxx/benchmarks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.BenchmarksTest do 2 | use ExUnit.Case 3 | 4 | @moduledoc """ 5 | To run the benchmarks do 6 | 7 | mix test --only benchmarks 8 | """ 9 | @moduletag :benchmarks 10 | 11 | # some benchmarks take more than the default timeout of minute 12 | @moduletag timeout: 10 * 60 * 1_000 13 | 14 | test "calling modules via functions" do 15 | list = Enum.to_list(1..10) 16 | 17 | enum = Enum 18 | lambda = fn l -> Enum.reverse(l) end 19 | 20 | Benchee.run(%{ 21 | "directly" => fn -> Enum.reverse(list) end, 22 | "module in a variable" => fn -> enum.reverse(list) end, 23 | "apply on a variable" => fn -> apply(enum, :reverse, [list]) end, 24 | "apply on a Module" => fn -> apply(Enum, :reverse, [list]) end, 25 | "lambda" => fn -> lambda.(list) end 26 | }) 27 | end 28 | 29 | test "passing state around" do 30 | big_data = %{ 31 | foo: [:bar, :baz, 1.5, "this is a medium size string"], 32 | bar: Enum.to_list(1..100) 33 | } 34 | 35 | inputs = %{ 36 | "1 item" => 1, 37 | "10 items" => 10, 38 | "100 items" => 100, 39 | "1000 items" => 1000 40 | } 41 | 42 | # updating state is here to make sure no smart optimisation kicks in 43 | update_state = fn state, value -> Map.put(state, :ban, value) end 44 | 45 | Benchee.run( 46 | %{ 47 | "directly" => fn count -> 48 | 1..count 49 | |> Enum.map(&update_state.(big_data, &1)) 50 | |> Enum.map(& &1) 51 | end, 52 | "sending messages to self" => fn count -> 53 | 1..count 54 | |> Enum.each(fn number -> 55 | send(self(), {:whoa, update_state.(big_data, number)}) 56 | end) 57 | 58 | 1..count 59 | |> Enum.map(fn _ -> 60 | receive do 61 | {:whoa, a} -> a 62 | after 63 | 0 -> raise "this shouldn't happen" 64 | end 65 | end) 66 | end, 67 | "passing through the process dictionary" => fn count -> 68 | 1..count 69 | |> Enum.each(fn number -> 70 | Process.put({:whoa, number}, update_state.(big_data, number)) 71 | end) 72 | 73 | 1..count 74 | |> Enum.map(fn number -> 75 | Process.get({:whoa, number}) 76 | end) 77 | end 78 | }, 79 | inputs: inputs 80 | ) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/raxx/context_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.ContextTest do 2 | use ExUnit.Case 3 | 4 | alias Raxx.Context 5 | 6 | @moduletag :context 7 | 8 | test "retrieve returns default values if the value is not present" do 9 | assert :default == Context.retrieve(:foo, :default) 10 | assert nil == Context.retrieve(:foo, nil) 11 | end 12 | 13 | test "retrieve returns nil as the default default value" do 14 | assert nil == Context.retrieve(:foo) 15 | end 16 | 17 | test "retrieve returns the most recently set section value" do 18 | Context.set(:foo, 1) 19 | assert 1 == Context.retrieve(:foo) 20 | Context.set(:foo, 2) 21 | assert 2 == Context.retrieve(:foo) 22 | end 23 | 24 | test "set returns the previous section value" do 25 | assert nil == Context.set(:foo, 1) 26 | assert 1 == Context.set(:foo, 2) 27 | end 28 | 29 | test "get_snapshot/0 gets all context values, but none of the other process dictionary values" do 30 | Process.put("this", "that") 31 | assert %{} == Context.get_snapshot() 32 | 33 | Context.set(:foo, 1) 34 | Context.set(:bar, 2) 35 | 36 | Process.put(:bar, 10) 37 | Process.put(:baz, 11) 38 | 39 | assert %{foo: 1, bar: 2} == Context.get_snapshot() 40 | end 41 | 42 | test "restore_snapshot/1 doesn't affect 'normal' process dictionary values" do 43 | empty_snapshot = Context.get_snapshot() 44 | Process.put("this", "that") 45 | 46 | assert :ok = Context.restore_snapshot(empty_snapshot) 47 | assert "that" == Process.get("this") 48 | end 49 | 50 | test "delete/1 deletes the given section from the context (but nothing else)" do 51 | Context.set(:foo, 1) 52 | Context.set(:bar, 2) 53 | 54 | assert 1 == Context.delete(:foo) 55 | assert nil == Context.retrieve(:foo) 56 | 57 | # this makes sure the value wasn't just set to nil and the other values are untouched 58 | assert %{bar: 2} == Context.get_snapshot() 59 | end 60 | 61 | test "restore_snapshot/1 restores the snapshot to the process dictionary" do 62 | Context.set(:foo, 1) 63 | Context.set(:bar, 2) 64 | 65 | snapshot = Context.get_snapshot() 66 | 67 | Context.delete(:foo) 68 | Context.delete(:bar) 69 | 70 | assert %{} == Context.get_snapshot() 71 | 72 | Context.restore_snapshot(snapshot) 73 | 74 | assert %{foo: 1, bar: 2} == Context.get_snapshot() 75 | end 76 | 77 | test "restore_snapshot/1 doesn't leave behind any section values from before the restore operation" do 78 | Context.set(:foo, 1) 79 | Context.set(:bar, 2) 80 | 81 | snapshot = Context.get_snapshot() 82 | 83 | Context.set(:bar, 22) 84 | Context.set(:baz, 3) 85 | 86 | assert :ok = Context.restore_snapshot(snapshot) 87 | 88 | assert %{foo: 1, bar: 2} == Context.get_snapshot() 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/raxx/http1_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.HTTP1Test do 2 | use ExUnit.Case 3 | doctest Raxx.HTTP1 4 | end 5 | -------------------------------------------------------------------------------- /test/raxx/request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.RequestTest do 2 | use ExUnit.Case 3 | alias Raxx.Request 4 | 5 | describe "uri/1" do 6 | test "handles a basic https case correctly" do 7 | request = Raxx.request(:GET, "https://example.com/") 8 | uri = Request.uri(request) 9 | 10 | assert %URI{ 11 | authority: "example.com", 12 | fragment: nil, 13 | host: "example.com", 14 | path: "/", 15 | port: 443, 16 | query: nil, 17 | scheme: "https", 18 | userinfo: nil 19 | } == uri 20 | end 21 | 22 | test "handles a basic http case correctly" do 23 | request = Raxx.request(:GET, "http://example.com/") 24 | uri = Request.uri(request) 25 | 26 | assert %URI{ 27 | authority: "example.com", 28 | fragment: nil, 29 | host: "example.com", 30 | path: "/", 31 | port: 80, 32 | query: nil, 33 | scheme: "http", 34 | userinfo: nil 35 | } == uri 36 | end 37 | 38 | test "handles the case with normal path" do 39 | request = Raxx.request(:GET, "https://example.com/foo/bar") 40 | uri = Request.uri(request) 41 | assert uri.path == "/foo/bar" 42 | end 43 | 44 | test "handles the case with duplicate slashes" do 45 | request = Raxx.request(:GET, "https://example.com/foo//bar") 46 | uri = Request.uri(request) 47 | assert uri.path == "/foo//bar" 48 | end 49 | 50 | test "passes through the query" do 51 | request = Raxx.request(:GET, "https://example.com?foo=bar&baz=ban") 52 | uri = Request.uri(request) 53 | assert uri.query == "foo=bar&baz=ban" 54 | end 55 | 56 | test "if there's a port number in the request, it is contained in the authority, but not the host" do 57 | url = "https://example.com:4321/foo/bar" 58 | request = Raxx.request(:GET, url) 59 | uri = Request.uri(request) 60 | assert uri.host == "example.com" 61 | assert uri.authority == "example.com:4321" 62 | assert uri.port == 4321 63 | end 64 | 65 | test "the uri won't contain userinfo" do 66 | url = "https://example.com/" 67 | 68 | request = 69 | Raxx.request(:GET, url) 70 | |> Raxx.set_header("authorization", "Basic YWxhZGRpbjpvcGVuc2VzYW1l") 71 | 72 | uri = Request.uri(request) 73 | assert uri.userinfo == nil 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/raxx/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.RouterTest do 2 | use ExUnit.Case 3 | 4 | defmodule HomePage do 5 | use Raxx.SimpleServer 6 | 7 | @impl Raxx.SimpleServer 8 | def handle_request(_request, _state) do 9 | response(:ok) 10 | |> set_body("Home page") 11 | end 12 | end 13 | 14 | defmodule UsersPage do 15 | use Raxx.SimpleServer 16 | 17 | @impl Raxx.SimpleServer 18 | def handle_request(_request, _state) do 19 | response(:ok) 20 | |> set_body("Users page") 21 | end 22 | end 23 | 24 | defmodule UserPage do 25 | use Raxx.SimpleServer 26 | 27 | @impl Raxx.SimpleServer 28 | def handle_request(%{path: ["users", id]}, _state) do 29 | response(:ok) 30 | |> set_body("User page #{id}") 31 | end 32 | end 33 | 34 | defmodule CreateUser do 35 | use Raxx.SimpleServer 36 | 37 | @impl Raxx.SimpleServer 38 | def handle_request(%{body: body}, _state) do 39 | response(:created) 40 | |> set_body("User created #{body}") 41 | end 42 | end 43 | 44 | defmodule NotFoundPage do 45 | use Raxx.SimpleServer 46 | 47 | @impl Raxx.SimpleServer 48 | def handle_request(_request, _state) do 49 | response(:not_found) 50 | |> set_body("Not found") 51 | end 52 | end 53 | 54 | defmodule InvalidReturn do 55 | use Raxx.SimpleServer 56 | 57 | @impl Raxx.SimpleServer 58 | def handle_request(_request, _state) do 59 | :foo 60 | end 61 | end 62 | 63 | defmodule AuthorizationMiddleware do 64 | use Raxx.Middleware 65 | alias Raxx.Server 66 | 67 | @impl Raxx.Middleware 68 | def process_head(request, :pass, next) do 69 | {parts, next} = Server.handle_head(next, request) 70 | {parts, :pass, next} 71 | end 72 | 73 | def process_head(_request, :stop, next) do 74 | {[Raxx.response(:forbidden)], :stop, next} 75 | end 76 | end 77 | 78 | defmodule TestHeaderMiddleware do 79 | use Raxx.Middleware 80 | alias Raxx.Server 81 | 82 | @impl Raxx.Middleware 83 | def process_head(request, state, inner_server) do 84 | {parts, inner_server} = Server.handle_head(inner_server, request) 85 | parts = add_header(parts, state) 86 | {parts, state, inner_server} 87 | end 88 | 89 | @impl Raxx.Middleware 90 | def process_data(data, state, inner_server) do 91 | {parts, inner_server} = Server.handle_data(inner_server, data) 92 | parts = add_header(parts, state) 93 | {parts, state, inner_server} 94 | end 95 | 96 | @impl Raxx.Middleware 97 | def process_tail(tail, state, inner_server) do 98 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 99 | parts = add_header(parts, state) 100 | {parts, state, inner_server} 101 | end 102 | 103 | @impl Raxx.Middleware 104 | def process_info(message, state, inner_server) do 105 | {parts, inner_server} = Server.handle_info(inner_server, message) 106 | parts = add_header(parts, state) 107 | {parts, state, inner_server} 108 | end 109 | 110 | def add_header([response = %Raxx.Response{} | rest], state) do 111 | response = Raxx.set_header(response, "x-test", state) 112 | [response | rest] 113 | end 114 | 115 | def add_header(parts, _state) when is_list(parts) do 116 | parts 117 | end 118 | end 119 | 120 | describe "custom route function in router" do 121 | defmodule CustomRouter do 122 | use Raxx.Router 123 | 124 | @impl Raxx.Router 125 | def route(%{path: []}, config) do 126 | Raxx.Stack.new([{TestHeaderMiddleware, "run-time"}], {HomePage, config}) 127 | end 128 | 129 | def route(_request, _config) do 130 | {NotFound, :state} 131 | end 132 | end 133 | 134 | test "will route to homepage" do 135 | request = Raxx.request(:GET, "/") 136 | {[response], _state} = CustomRouter.handle_head(request, %{authorization: :pass}) 137 | assert "Home page" == response.body 138 | assert "run-time" == Raxx.get_header(response, "x-test") 139 | end 140 | end 141 | 142 | describe "new routing api with middleware" do 143 | defmodule SectionRouter do 144 | use Raxx.Router 145 | 146 | # Test with HEAD middleware 147 | section([{TestHeaderMiddleware, "compile-time"}], [ 148 | {%{method: :GET, path: []}, HomePage} 149 | ]) 150 | 151 | section(&private/1, [ 152 | {%{method: :GET, path: ["users"]}, UsersPage}, 153 | {%{method: :GET, path: ["users", _id]}, UserPage}, 154 | {%{method: :POST, path: ["users"]}, CreateUser}, 155 | {%{method: :GET, path: ["invalid"]}, InvalidReturn}, 156 | {%{method: :POST, path: ["invalid"]}, InvalidReturn}, 157 | {_, NotFoundPage} 158 | ]) 159 | 160 | def private(state) do 161 | send(self(), :i_just_ran) 162 | 163 | [ 164 | {TestHeaderMiddleware, "run-time"}, 165 | {AuthorizationMiddleware, state.authorization} 166 | ] 167 | end 168 | end 169 | 170 | test "will route to homepage" do 171 | request = Raxx.request(:GET, "/") 172 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 173 | assert "Home page" == response.body 174 | end 175 | 176 | test "will route to fixed segment" do 177 | request = Raxx.request(:GET, "/users") 178 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 179 | assert "Users page" == response.body 180 | end 181 | 182 | test "will route to variable segment path" do 183 | request = Raxx.request(:GET, "/users/34") 184 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 185 | assert "User page 34" == response.body 186 | end 187 | 188 | test "will route on method" do 189 | request = Raxx.request(:POST, "/users") 190 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 191 | assert "User created " == response.body 192 | end 193 | 194 | test "will forward whole request to controller" do 195 | request = 196 | Raxx.request(:POST, "/users") 197 | |> Raxx.set_body(true) 198 | 199 | {[], state} = SectionRouter.handle_head(request, %{authorization: :pass}) 200 | {[], state} = SectionRouter.handle_data("Bob", state) 201 | {[response], _state} = SectionRouter.handle_tail([], state) 202 | assert "User created Bob" == response.body 203 | end 204 | 205 | test "will route on catch all" do 206 | request = Raxx.request(:GET, "/random") 207 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 208 | assert "Not found" == response.body 209 | end 210 | 211 | test "will raise return error if fails to route simple request" do 212 | request = Raxx.request(:GET, "/invalid") 213 | 214 | assert_raise ReturnError, fn -> 215 | SectionRouter.handle_head(request, %{authorization: :pass}) 216 | end 217 | end 218 | 219 | test "will raise return error if fails to route streamed request" do 220 | request = 221 | Raxx.request(:POST, "/invalid") 222 | |> Raxx.set_body(true) 223 | 224 | {[], state} = SectionRouter.handle_head(request, %{authorization: :pass}) 225 | {[], state} = SectionRouter.handle_data("Bob", state) 226 | 227 | assert_raise ReturnError, fn -> 228 | SectionRouter.handle_tail([], state) 229 | end 230 | end 231 | 232 | test "middleware as list is applied" do 233 | request = Raxx.request(:GET, "/") 234 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 235 | assert "compile-time" == Raxx.get_header(response, "x-test") 236 | end 237 | 238 | test "middleware as function is applied" do 239 | request = Raxx.request(:GET, "/users") 240 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :pass}) 241 | assert 200 == response.status 242 | assert "run-time" == Raxx.get_header(response, "x-test") 243 | assert_receive :i_just_ran 244 | 245 | request = Raxx.request(:GET, "/users") 246 | {[response], _state} = SectionRouter.handle_head(request, %{authorization: :stop}) 247 | assert 403 == response.status 248 | assert "run-time" == Raxx.get_header(response, "x-test") 249 | assert_receive :i_just_ran 250 | end 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /test/raxx/server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.ServerTest do 2 | use ExUnit.Case 3 | doctest Raxx.Server 4 | import ExUnit.CaptureLog 5 | 6 | defmodule EchoServer do 7 | use Raxx.SimpleServer 8 | 9 | @impl Raxx.SimpleServer 10 | def handle_request(%{body: body}, _) do 11 | response(:ok) 12 | |> set_body(inspect(body)) 13 | end 14 | end 15 | 16 | test "body is concatenated to single string" do 17 | request = 18 | Raxx.request(:POST, "/") 19 | |> Raxx.set_body(true) 20 | 21 | state = %{} 22 | 23 | assert {[], state} = EchoServer.handle_head(request, state) 24 | assert {[], state} = EchoServer.handle_data("a", state) 25 | assert {[], state} = EchoServer.handle_data("b", state) 26 | assert {[], state} = EchoServer.handle_data("c", state) 27 | assert %{body: body} = EchoServer.handle_tail([], state) 28 | assert "\"abc\"" == body 29 | end 30 | 31 | defmodule DefaultServer do 32 | use Raxx.SimpleServer 33 | 34 | @impl Raxx.SimpleServer 35 | def handle_request(_, _) do 36 | response(:no_content) 37 | end 38 | end 39 | 40 | test "handle_info logs error" do 41 | logs = 42 | capture_log(fn -> 43 | DefaultServer.handle_info(:foo, :state) 44 | end) 45 | 46 | assert String.contains?(logs, "unexpected message") 47 | assert String.contains?(logs, ":foo") 48 | end 49 | 50 | test "default server will not buffer more than 8MB into one request" do 51 | request = 52 | Raxx.request(:POST, "/") 53 | |> Raxx.set_body(true) 54 | 55 | state = %{} 56 | 57 | assert {[], state} = DefaultServer.handle_head(request, state) 58 | four_Mb = String.duplicate("1234", round(:math.pow(2, 20))) 59 | assert {[], state} = DefaultServer.handle_data(four_Mb, state) 60 | assert {[], state} = DefaultServer.handle_data(four_Mb, state) 61 | assert response = %{status: 413} = DefaultServer.handle_data("straw", state) 62 | end 63 | 64 | defmodule BigServer do 65 | use Raxx.SimpleServer, maximum_body_length: 12 * 1024 * 1024 66 | 67 | @impl Raxx.SimpleServer 68 | def handle_request(_, _) do 69 | response(:no_content) 70 | end 71 | end 72 | 73 | test "Server max body size can be configured" do 74 | request = 75 | Raxx.request(:POST, "/") 76 | |> Raxx.set_body(true) 77 | 78 | state = %{} 79 | 80 | assert {[], state} = BigServer.handle_head(request, state) 81 | four_Mb = String.duplicate("1234", round(:math.pow(2, 20))) 82 | assert {[], state} = BigServer.handle_data(four_Mb, state) 83 | assert {[], state} = BigServer.handle_data(four_Mb, state) 84 | assert {[], state} = BigServer.handle_data(four_Mb, state) 85 | assert response = %{status: 413} = BigServer.handle_data("straw", state) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/raxx/stack_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Raxx.StackTest do 2 | use ExUnit.Case 3 | 4 | alias Raxx.Middleware 5 | alias Raxx.Stack 6 | alias Raxx.Server 7 | 8 | defmodule HomePage do 9 | use Raxx.SimpleServer 10 | 11 | @impl Raxx.SimpleServer 12 | def handle_request(_request, _state) do 13 | response(:ok) 14 | |> set_body("Home page") 15 | end 16 | end 17 | 18 | defmodule TrackStages do 19 | @behaviour Middleware 20 | 21 | @impl Middleware 22 | def process_head(request, config, inner_server) do 23 | {parts, inner_server} = Server.handle_head(inner_server, request) 24 | {parts, {config, :head}, inner_server} 25 | end 26 | 27 | @impl Middleware 28 | def process_data(data, {_, prev}, inner_server) do 29 | {parts, inner_server} = Server.handle_data(inner_server, data) 30 | {parts, {prev, :data}, inner_server} 31 | end 32 | 33 | @impl Middleware 34 | def process_tail(tail, {_, prev}, inner_server) do 35 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 36 | {parts, {prev, :tail}, inner_server} 37 | end 38 | 39 | @impl Middleware 40 | def process_info(message, {_, prev}, inner_server) do 41 | {parts, inner_server} = Server.handle_info(inner_server, message) 42 | {parts, {prev, :info}, inner_server} 43 | end 44 | end 45 | 46 | defmodule DefaultMiddleware do 47 | use Middleware 48 | end 49 | 50 | test "Default middleware callbacks leave the request and response unmodified" do 51 | middlewares = [{DefaultMiddleware, :irrelevant}, {DefaultMiddleware, 42}] 52 | stack = make_stack(middlewares, HomePage, :controller_initial) 53 | 54 | request = 55 | Raxx.request(:POST, "/") 56 | |> Raxx.set_content_length(3) 57 | |> Raxx.set_body(true) 58 | 59 | assert {[], stack} = Server.handle_head(stack, request) 60 | assert {[], stack} = Server.handle_data(stack, "abc") 61 | 62 | assert {[response], _stack} = Server.handle_tail(stack, []) 63 | 64 | assert %Raxx.Response{ 65 | body: "Home page", 66 | headers: [{"content-length", "9"}], 67 | status: 200 68 | } = response 69 | end 70 | 71 | defmodule Meddler do 72 | @behaviour Middleware 73 | @impl Middleware 74 | def process_head(request, config, inner_server) do 75 | request = 76 | case Keyword.get(config, :request_header) do 77 | nil -> 78 | request 79 | 80 | value -> 81 | request 82 | |> Raxx.delete_header("x-request-header") 83 | |> Raxx.set_header("x-request-header", value) 84 | end 85 | 86 | {parts, inner_server} = Server.handle_head(inner_server, request) 87 | parts = modify_parts(parts, config) 88 | {parts, config, inner_server} 89 | end 90 | 91 | @impl Middleware 92 | def process_data(data, config, inner_server) do 93 | {parts, inner_server} = Server.handle_data(inner_server, data) 94 | parts = modify_parts(parts, config) 95 | {parts, config, inner_server} 96 | end 97 | 98 | @impl Middleware 99 | def process_tail(tail, config, inner_server) do 100 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 101 | parts = modify_parts(parts, config) 102 | {parts, config, inner_server} 103 | end 104 | 105 | @impl Middleware 106 | def process_info(message, config, inner_server) do 107 | {parts, inner_server} = Server.handle_info(inner_server, message) 108 | parts = modify_parts(parts, config) 109 | {parts, config, inner_server} 110 | end 111 | 112 | defp modify_parts(parts, config) do 113 | Enum.map(parts, &modify_part(&1, config)) 114 | end 115 | 116 | defp modify_part(data = %Raxx.Data{data: contents}, config) do 117 | new_contents = modify_contents(contents, config) 118 | %Raxx.Data{data | data: new_contents} 119 | end 120 | 121 | defp modify_part(response = %Raxx.Response{body: contents}, config) 122 | when is_binary(contents) do 123 | new_contents = modify_contents(contents, config) 124 | %Raxx.Response{response | body: new_contents} 125 | end 126 | 127 | defp modify_part(part, _state) do 128 | part 129 | end 130 | 131 | defp modify_contents(contents, config) do 132 | case Keyword.get(config, :response_body) do 133 | nil -> 134 | contents 135 | 136 | replacement when is_binary(replacement) -> 137 | String.replace(contents, ~r/./, replacement) 138 | # make sure it's the same length 139 | |> String.slice(0, String.length(contents)) 140 | end 141 | end 142 | end 143 | 144 | defmodule SpyServer do 145 | use Raxx.Server 146 | # this server is deliberately weird to trip up any assumptions 147 | @impl Raxx.Server 148 | def handle_head(request = %{body: false}, state) do 149 | send(self(), {__MODULE__, :handle_head, request, state}) 150 | 151 | response = 152 | Raxx.response(:ok) |> Raxx.set_body("SpyServer responds to a request with no body") 153 | 154 | {[response], state} 155 | end 156 | 157 | def handle_head(request, state) do 158 | send(self(), {__MODULE__, :handle_head, request, state}) 159 | {[], 1} 160 | end 161 | 162 | def handle_data(data, state) do 163 | send(self(), {__MODULE__, :handle_data, data, state}) 164 | 165 | headers = 166 | response(:ok) 167 | |> set_content_length(10) 168 | |> set_body(true) 169 | 170 | {[headers], state + 1} 171 | end 172 | 173 | def handle_tail(tail, state) do 174 | send(self(), {__MODULE__, :handle_tail, tail, state}) 175 | {[data("spy server"), tail([{"x-response-trailer", "spy-trailer"}])], -1 * state} 176 | end 177 | end 178 | 179 | test "middlewares can modify the request" do 180 | middlewares = [{Meddler, [request_header: "foo"]}, {Meddler, [request_header: "bar"]}] 181 | stack = make_stack(middlewares, SpyServer, :controller_initial) 182 | 183 | request = 184 | Raxx.request(:POST, "/") 185 | |> Raxx.set_content_length(3) 186 | |> Raxx.set_body(true) 187 | 188 | assert {[], stack} = Server.handle_head(stack, request) 189 | 190 | assert_receive {SpyServer, :handle_head, server_request, :controller_initial} 191 | assert %Raxx.Request{} = server_request 192 | assert "bar" == Raxx.get_header(server_request, "x-request-header") 193 | assert 3 == Raxx.get_content_length(server_request) 194 | 195 | assert {[headers], stack} = Server.handle_data(stack, "abc") 196 | assert_receive {SpyServer, :handle_data, "abc", 1} 197 | assert %Raxx.Response{body: true, status: 200} = headers 198 | 199 | assert {[data, tail], stack} = Server.handle_tail(stack, []) 200 | assert_receive {SpyServer, :handle_tail, [], 2} 201 | assert %Raxx.Data{data: "spy server"} = data 202 | assert %Raxx.Tail{headers: [{"x-response-trailer", "spy-trailer"}]} == tail 203 | end 204 | 205 | test "middlewares can modify the response" do 206 | middlewares = [{Meddler, [response_body: "foo"]}, {Meddler, [response_body: "bar"]}] 207 | stack = make_stack(middlewares, SpyServer, :controller_initial) 208 | 209 | request = 210 | Raxx.request(:POST, "/") 211 | |> Raxx.set_content_length(3) 212 | |> Raxx.set_body(true) 213 | 214 | assert {[], stack} = Server.handle_head(stack, request) 215 | 216 | assert_receive {SpyServer, :handle_head, server_request, :controller_initial} 217 | assert %Raxx.Request{} = server_request 218 | assert nil == Raxx.get_header(server_request, "x-request-header") 219 | assert 3 == Raxx.get_content_length(server_request) 220 | 221 | assert {[headers], stack} = Server.handle_data(stack, "abc") 222 | assert_receive {SpyServer, :handle_data, "abc", 1} 223 | assert %Raxx.Response{body: true, status: 200} = headers 224 | 225 | assert {[data, tail], stack} = Server.handle_tail(stack, []) 226 | assert_receive {SpyServer, :handle_tail, [], 2} 227 | assert %Raxx.Data{data: "foofoofoof"} = data 228 | assert %Raxx.Tail{headers: [{"x-response-trailer", "spy-trailer"}]} == tail 229 | end 230 | 231 | test "middlewares' state is correctly updated" do 232 | middlewares = [{Meddler, [response_body: "foo"]}, {TrackStages, :config}] 233 | stack = make_stack(middlewares, SpyServer, :controller_initial) 234 | 235 | request = 236 | Raxx.request(:POST, "/") 237 | |> Raxx.set_content_length(3) 238 | |> Raxx.set_body(true) 239 | 240 | assert {_parts, stack} = Server.handle_head(stack, request) 241 | 242 | assert [{Meddler, [response_body: "foo"]}, {TrackStages, {:config, :head}}] == 243 | Stack.get_middlewares(stack) 244 | 245 | assert {SpyServer, 1} == Stack.get_server(stack) 246 | 247 | {_parts, stack} = Server.handle_data(stack, "z") 248 | 249 | assert [{Meddler, [response_body: "foo"]}, {TrackStages, {:head, :data}}] == 250 | Stack.get_middlewares(stack) 251 | 252 | assert {SpyServer, 2} == Stack.get_server(stack) 253 | 254 | {_parts, stack} = Server.handle_data(stack, "zz") 255 | 256 | assert [{Meddler, [response_body: "foo"]}, {TrackStages, {:data, :data}}] == 257 | Stack.get_middlewares(stack) 258 | 259 | assert {SpyServer, 3} == Stack.get_server(stack) 260 | 261 | {_parts, stack} = Server.handle_tail(stack, [{"x-foo", "bar"}]) 262 | assert [{Meddler, _}, {TrackStages, {:data, :tail}}] = Stack.get_middlewares(stack) 263 | assert {SpyServer, -3} == Stack.get_server(stack) 264 | end 265 | 266 | test "a stack with no middlewares is functional" do 267 | stack = make_stack([], SpyServer, :controller_initial) 268 | 269 | request = 270 | Raxx.request(:POST, "/") 271 | |> Raxx.set_content_length(3) 272 | |> Raxx.set_body(true) 273 | 274 | {stack_result_1, stack} = Server.handle_head(stack, request) 275 | {stack_result_2, stack} = Server.handle_data(stack, "xxx") 276 | {stack_result_3, _stack} = Server.handle_tail(stack, []) 277 | 278 | {server_result_1, state} = SpyServer.handle_head(request, :controller_initial) 279 | {server_result_2, state} = SpyServer.handle_data("xxx", state) 280 | {server_result_3, _state} = SpyServer.handle_tail([], state) 281 | 282 | assert stack_result_1 == server_result_1 283 | assert stack_result_2 == server_result_2 284 | assert stack_result_3 == server_result_3 285 | end 286 | 287 | defmodule AlwaysForbidden do 288 | use Middleware 289 | 290 | @impl Middleware 291 | def process_head(_request, _config, inner_server) do 292 | response = 293 | Raxx.response(:forbidden) 294 | |> Raxx.set_body("Forbidden!") 295 | 296 | {[response], nil, inner_server} 297 | end 298 | end 299 | 300 | # This test also checks that the default callbacks from `use` macro can be overridden. 301 | test "middlewares can \"short circuit\" processing (not call through)" do 302 | middlewares = [{TrackStages, nil}, {AlwaysForbidden, nil}] 303 | stack = make_stack(middlewares, SpyServer, :whatever) 304 | request = Raxx.request(:GET, "/") 305 | 306 | assert {[response], _stack} = Server.handle_head(stack, request) 307 | assert %Raxx.Response{body: "Forbidden!"} = response 308 | 309 | refute_receive _ 310 | 311 | stack = make_stack([{TrackStages, nil}], SpyServer, :whatever) 312 | assert {[response], _stack} = Server.handle_head(stack, request) 313 | assert response.body =~ "SpyServer" 314 | 315 | assert_receive {SpyServer, _, _, _} 316 | end 317 | 318 | defmodule CustomReturn do 319 | use Raxx.Server 320 | @impl Raxx.Server 321 | def handle_head(_request, state) do 322 | {[:response], state} 323 | end 324 | 325 | def handle_data(_data, state) do 326 | {[], state} 327 | end 328 | 329 | def handle_tail(_tail, state) do 330 | {[], state} 331 | end 332 | end 333 | 334 | defmodule CustomReturnMiddleware do 335 | @behaviour Middleware 336 | 337 | @impl Middleware 338 | def process_head(request, _config, inner_server) do 339 | {parts, inner_server} = Server.handle_head(inner_server, request) 340 | {process_parts(parts), nil, inner_server} 341 | end 342 | 343 | @impl Middleware 344 | def process_data(data, _state, inner_server) do 345 | {parts, inner_server} = Server.handle_data(inner_server, data) 346 | {process_parts(parts), nil, inner_server} 347 | end 348 | 349 | @impl Middleware 350 | def process_tail(tail, _state, inner_server) do 351 | {parts, inner_server} = Server.handle_tail(inner_server, tail) 352 | {process_parts(parts), nil, inner_server} 353 | end 354 | 355 | @impl Middleware 356 | def process_info(message, _state, inner_server) do 357 | {parts, inner_server} = Server.handle_info(inner_server, message) 358 | {process_parts(parts), nil, inner_server} 359 | end 360 | 361 | defp process_parts(parts) do 362 | parts 363 | |> Raxx.separate_parts() 364 | |> Enum.map(&process_part/1) 365 | end 366 | 367 | defp process_part(:response) do 368 | %Raxx.Response{ 369 | body: "custom", 370 | headers: [{"content-length", "6"}], 371 | status: 200 372 | } 373 | end 374 | 375 | defp process_part(other) do 376 | other 377 | end 378 | end 379 | 380 | test "servers can, in principle, return custom values to the middleware" do 381 | stack = make_stack([{CustomReturnMiddleware, nil}], CustomReturn, nil) 382 | request = Raxx.request(:GET, "/") 383 | assert {response, _stack} = Server.handle_head(stack, request) 384 | assert [%Raxx.Response{body: "custom"}] = response 385 | end 386 | 387 | defp make_stack(middlewares, server_module, server_state) do 388 | Stack.new(middlewares, {server_module, server_state}) 389 | end 390 | end 391 | -------------------------------------------------------------------------------- /test/raxx_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RaxxTest do 2 | use ExUnit.Case 3 | import Raxx 4 | doctest Raxx 5 | 6 | test "cannot set an uppercase header" do 7 | assert_raise ArgumentError, "Header keys must be lowercase", fn -> 8 | Raxx.response(:ok) 9 | |> Raxx.set_header("Foo", "Bar") 10 | end 11 | end 12 | 13 | test "header values cannot contain control feed charachter" do 14 | assert_raise ArgumentError, 15 | "Header values must not contain control feed (\\r) or newline (\\n)", 16 | fn -> 17 | Raxx.response(:ok) 18 | |> Raxx.set_header("foo", "Bar\r") 19 | end 20 | end 21 | 22 | test "header values cannot contain newline charachter" do 23 | assert_raise ArgumentError, 24 | "Header values must not contain control feed (\\r) or newline (\\n)", 25 | fn -> 26 | Raxx.response(:ok) 27 | |> Raxx.set_header("foo", "Bar\n") 28 | end 29 | end 30 | 31 | test "cannot set a host header" do 32 | assert_raise ArgumentError, 33 | "Cannot set host header, see documentation for details", 34 | fn -> 35 | Raxx.response(:ok) 36 | |> Raxx.set_header("host", "raxx.dev") 37 | end 38 | end 39 | 40 | test "cannot set a connection header" do 41 | assert_raise ArgumentError, 42 | "Cannot set a connection specific header, see documentation for details", 43 | fn -> 44 | Raxx.response(:ok) 45 | |> Raxx.set_header("connection", "keep-alive") 46 | end 47 | end 48 | 49 | test "cannot set a header twice" do 50 | assert_raise ArgumentError, "Headers should not be duplicated", fn -> 51 | Raxx.response(:ok) 52 | |> Raxx.set_header("x-foo", "one") 53 | |> Raxx.set_header("x-foo", "two") 54 | end 55 | end 56 | 57 | test "cannot get an uppercase header" do 58 | assert_raise ArgumentError, "Header keys must be lowercase", fn -> 59 | Raxx.response(:ok) 60 | |> Raxx.get_header("Foo") 61 | end 62 | end 63 | 64 | test "Cannot set the body of a GET request" do 65 | assert_raise ArgumentError, fn -> 66 | Raxx.request(:GET, "raxx.dev") 67 | |> Raxx.set_body("Hello, World!") 68 | end 69 | end 70 | 71 | test "Cannot set the body of a HEAD request" do 72 | assert_raise ArgumentError, fn -> 73 | Raxx.request(:HEAD, "raxx.dev") 74 | |> Raxx.set_body("Hello, World!") 75 | end 76 | end 77 | 78 | test "Cannot set the body of an informational (1xx) response" do 79 | assert_raise ArgumentError, fn -> 80 | Raxx.response(:continue) 81 | |> Raxx.set_body("Hello, World!") 82 | end 83 | end 84 | 85 | test "Cannot set the body of an no content response" do 86 | assert_raise ArgumentError, fn -> 87 | Raxx.response(:no_content) 88 | |> Raxx.set_body("Hello, World!") 89 | end 90 | end 91 | 92 | test "Cannot set the body of an not modified response" do 93 | assert_raise ArgumentError, fn -> 94 | Raxx.response(:not_modified) 95 | |> Raxx.set_body("Hello, World!") 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------