defmodule Raxx do @moduledoc """ Tooling to work with HTTP. Several data structures are defined to model parts of the communication between client and server. - `Raxx.Request`: metadata sent by a client before sending content. - `Raxx.Response`: metadata sent by a server before sending content. - `Raxx.Data`: A part of a messages content. - `Raxx.Tail`: metadata set by client or server to conclude communication. This module contains functions to create and manipulate these structures. **See `Raxx.Server` for implementing a web application.** """ @http_methods [ :GET, :POST, :PUT, :PATCH, :DELETE, :HEAD, :OPTIONS ] @typedoc """ Attribute value pair that can be serialized to an HTTP request or response """ @type header :: {String.t(), String.t()} @typedoc """ List of HTTP headers. """ @type headers :: [header()] @typedoc """ The body of a Raxx message. The body can be: - part of the message (`String.t()`). - empty (`false`). - present but unknown (`true`). """ @type body :: boolean | String.t() @typedoc """ Either a `Raxx.Request.t` or a `Raxx.Response.t` """ @type message :: Raxx.Request.t() | Raxx.Response.t() @doc """ Construct a `Raxx.Request`. An HTTP request must have a method and path. If the location argument is a relative path the scheme and authority values will be unset. When these values can be inferred from the location they will be set. The method must be an atom for one of the HTTP methods `#{inspect(@http_methods)}` The request will have no body or headers. These can be added with `set_header/3` and `set_body/2`. ## Examples iex> request(:HEAD, "/").method :HEAD iex> request(:GET, "/").path [] iex> request(:GET, "/foo/bar").path ["foo", "bar"] iex> request(:GET, "https:///").scheme :https iex> request(:GET, "https://example.com").authority "example.com" iex> request(:GET, "/?foo=bar").query %{"foo" => "bar"} iex> request(:GET, "/?foo[bob]=bar").query %{"foo" => %{"bob" => "bar"}} iex> request(:GET, "/").headers [] iex> request(:GET, "/").body false """ @spec request(Raxx.Request.method(), String.t() | URI.t()) :: Raxx.Request.t() def request(method, url) when is_binary(url) do url = URI.parse(url) if url.query do {:ok, query} = URI2.Query.decode(url.query) request(method, %{url | query: query}) else request(method, url) end end def request(method, url) when method in @http_methods do scheme = if url.scheme do url.scheme |> String.to_existing_atom() end segments = split_path(url.path || "/") struct( Raxx.Request, scheme: scheme, authority: url.authority, method: method, path: segments, query: url.query, headers: [], body: false ) end @doc """ Construct a `Raxx.Response`. The responses HTTP status code can be provided as an integer, or will be translated from a known atom. The response will have no body or headers. These can be added with `set_header/3` and `set_body/2`. ## Examples iex> response(200).status 200 iex> response(:no_content).status 204 iex> response(200).headers [] iex> response(200).body false """ @spec response(Raxx.Response.status_code() | atom) :: Raxx.Response.t() def response(status_code) when is_integer(status_code) do struct(Raxx.Response, status: status_code, headers: [], body: false) end def response(status) when is_atom(status) do response(status_code(status)) end filepath = Path.join(__DIR__, "status.rfc7231") @external_resource filepath {:ok, file} = File.read(filepath) status_lines = String.split(String.trim(file), ~r/\R/) statuses = status_lines |> Enum.map(fn status_line -> {code, " " <> reason_phrase} = Integer.parse(status_line) {code, reason_phrase} end) statuses = statuses ++ Application.get_env(:raxx, :extra_statuses, []) for {status_code, reason_phrase} <- statuses do reason = reason_phrase |> String.downcase() |> String.replace(" ", "_") |> String.to_atom() defp status_code(unquote(reason)) do unquote(status_code) end end @doc """ The RFC7231 specified reason phrase for each known HTTP status code. Extra reason phrases can be defined in raxx config under extra_status. For example. config :raxx, :extra_statuses, ["422", "Unprocessable Entity"] ## Examples iex> reason_phrase(200) "OK" iex> reason_phrase(500) "Internal Server Error" iex> reason_phrase(422) "Unprocessable Entity" """ @spec reason_phrase(integer) :: String.t() for {status_code, reason_phrase} <- statuses do def reason_phrase(unquote(status_code)) do unquote(reason_phrase) end end @doc """ Construct a `Raxx.Data` Wrap a piece of data as being part of a message's body. ## Examples iex> data("Hi").data "Hi" """ @spec data(String.t()) :: Raxx.Data.t() def data(data) do %Raxx.Data{data: data} end @doc """ Construct a `Raxx.Tail` ## Examples iex> tail([{"digest", "opaque-data"}]).headers [{"digest", "opaque-data"}] iex> tail().headers [] """ @spec tail([{String.t(), String.t()}]) :: Raxx.Tail.t() def tail(headers \\ []) do %Raxx.Tail{headers: headers} end @doc """ Does the message struct contain all the data to be sent. ## Examples iex> request(:GET, "/") ...> |> complete?() true iex> response(:ok) ...> |> set_body("Hello, World!") ...> |> complete?() true iex> response(:ok) ...> |> set_body(true) ...> |> complete?() false """ @spec complete?(message()) :: boolean def complete?(%{body: body}) when is_binary(body) do true end def complete?(%{body: body}) do !body end @doc """ Add a query value to a request ## Examples iex> request(:GET, "/") ...> |> set_query(%{"value" => "1"}) ...> |> Map.get(:query) %{"value" => "1"} """ @spec set_query(Raxx.Request.t(), %{binary => binary}) :: Raxx.Request.t() def set_query(request = %Raxx.Request{query: nil}, query) do %{request | query: query} end @doc """ Set the value of a header field. ## Examples iex> request(:GET, "/") ...> |> set_header("referer", "example.com") ...> |> set_header("accept", "text/html") ...> |> Map.get(:headers) [{"referer", "example.com"}, {"accept", "text/html"}] """ @spec set_header(Raxx.Request.t(), String.t(), String.t()) :: Raxx.Request.t() @spec set_header(Raxx.Response.t(), String.t(), String.t()) :: Raxx.Response.t() def set_header(message = %{headers: headers}, name, value) do if String.downcase(name) != name do raise "Header keys must be lowercase" end if :proplists.is_defined(name, headers) do raise "Headers should not be duplicated" end case :binary.match(value, ["\n", "\r"]) do {_, _} -> raise "Header values must not contain control feed (\\r) or newline (\\n)" :nomatch -> value end %{message | headers: headers ++ [{name, value}]} end @doc """ Get the value of a header field. ## Examples iex> response(:ok) ...> |> set_header("content-type", "text/html") ...> |> get_header("content-type") "text/html" iex> response(:ok) ...> |> set_header("content-type", "text/html") ...> |> get_header("location") nil iex> response(:ok) ...> |> set_header("content-type", "text/html") ...> |> get_header("content-type", "text/plain") "text/html" iex> response(:ok) ...> |> set_header("content-type", "text/html") ...> |> get_header("location", "/") "/" """ @spec get_header(Raxx.Request.t(), String.t(), String.t() | nil) :: String.t() | nil @spec get_header(Raxx.Response.t(), String.t(), String.t() | nil) :: String.t() | nil def get_header(%{headers: headers}, name, fallback \\ nil) do if String.downcase(name) != name do raise "Header keys must be lowercase" end case :proplists.get_all_values(name, headers) do [] -> fallback [value] -> value _ -> raise "More than one header found for `#{name}`" end end @doc """ Add a complete body to a message. ## Examples iex> request(:GET, "/") ...> |> set_body("Hello") ...> |> Map.get(:body) "Hello" """ @spec set_body(Raxx.Request.t(), body) :: Raxx.Request.t() @spec set_body(Raxx.Response.t(), body) :: Raxx.Response.t() def set_body(message = %{body: false}, body) do %{message | body: body} end @doc """ Create a response to redirect client to the given url. Response status can be set using the `:status` option. ## Examples iex> redirect("/foo") ...> |> get_header("location") "/foo" iex> redirect("/foo") ...> |> get_header("content-type") "text/html" iex> redirect("/foo") ...> |> Map.get(:body) ~s(
This resource has moved here.) iex> redirect("/foo") ...> |> Map.get(:status) 303 iex> redirect("/foo", status: 301) ...> |> Map.get(:status) 301 iex> redirect("/foo", status: :moved_permanently) ...> |> Map.get(:status) 301 ## Notes This implementation was lifted from the [sugar framework](https://github.com/sugar-framework/sugar/blob/405256747ce8c446c504e4dc533b24c76d864a5a/lib/sugar/controller/helpers.ex#L253-L259) and is sufficient for many usecases. I would like to implement a `back` function. Complication with such functionality are discussed here - https://github.com/phoenixframework/phoenix/pull/1402 Sinatra has a very complete test suite including a back implementation - https://github.com/sinatra/sinatra/blob/9bd0d40229f76ff60d81c01ad2f4b1a8e6f31e05/test/helpers_test.rb#L183 """ def redirect(url, opts \\ []) do status = Keyword.get(opts, :status, :see_other) response(status) |> set_header("location", url) |> set_header("content-type", "text/html") |> set_body(redirect_page(url)) end defp redirect_page(url) do html = html_escape(url) "This resource has moved here." end @doc """ Split a path on forward slashes. ## Examples iex> split_path("/foo/bar") ["foo", "bar"] """ @spec split_path(String.t()) :: [String.t()] def split_path(path_string) do path_string |> String.split("/", trim: true) end def normalized_path(request) do query_string = case URI.encode_query(request.query || %{}) do "" -> "" encoded -> "?" <> encoded end "/" <> Enum.join(request.path, "/") <> query_string end @doc """ Can application be run by compatable server? ## Examples iex> is_application?({Raxx.ServerTest.DefaultServer, %{}}) true iex> is_application?({GenServer, %{}}) false iex> is_application?({NotAModule, %{}}) false """ @spec is_application?({module(), any()}) :: boolean() def is_application?({module, _initial_state}) do Raxx.Server.is_implemented?(module) end @doc """ Verify application can be run by compatable server? ## Examples iex> verify_application({Raxx.ServerTest.DefaultServer, %{}}) {:ok, {Raxx.ServerTest.DefaultServer, %{}}} iex> verify_application({GenServer, %{}}) {:error, "module `GenServer` does not implement `Raxx.Server` behaviour."} iex> verify_application({NotAModule, %{}}) {:error, "module `NotAModule` is not available."} """ @spec verify_application({module(), any()}) :: {:ok, {module(), any()}} | {:error, String.t()} def verify_application({module, initial_state}) do case Code.ensure_compiled?(module) do true -> module.module_info[:attributes] |> Keyword.get(:behaviour, []) |> Enum.member?(Raxx.Server) |> case do true -> {:ok, {module, initial_state}} false -> { :error, "module `#{Macro.to_string(module)}` does not implement `Raxx.Server` behaviour." } end false -> {:error, "module `#{Macro.to_string(module)}` is not available."} end end ######## COPIED FROM PLUG ######## @doc ~S""" Escapes the given HTML to string. iex> html_escape("foo") "foo" iex> html_escape("