diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..bca9e1d --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,52 @@ +locals_without_parens = [ + resource: 4, + resource: 3, + defh: 2, + pass: 2, + + # Handlers + allowed_methods: 1, + allow_missing_post: 1, + base_uri: 1, + charsets_provided: 1, + content_types_accepted: 1, + content_types_provided: 1, + create_path: 1, + delete_completed: 1, + delete_resource: 1, + encodings_provided: 1, + expires: 1, + forbidden: 1, + generate_etag: 1, + is_authorized: 1, + is_conflict: 1, + known_content_type: 1, + known_methods: 1, + language_available: 1, + last_modified: 1, + malformed_request: 1, + moved_permanently: 1, + moved_temporarily: 1, + multiple_choices: 1, + options: 1, + ping: 1, + post_is_create: 1, + previously_existed: 1, + process_post: 1, + resource_exists: 1, + service_available: 1, + uri_too_long: 1, + validate_content_checksum: 1, + valid_content_headers: 1, + valid_entity_length: 1, + variances: 1 +] + +[ + import_deps: [ + :plug + ], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + locals_without_parens: locals_without_parens ++ [decision: 2], + export: [locals_without_parens: locals_without_parens] +] diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..51086f7 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1 @@ +ad87df770d2be55ab1becbc05de5d9edf961bad2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc9c2a..5b84bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## Unreleased + +## [2.4.0] (2025-12-08) + +### Added + +- documentation for the handler given to the handlers `content_types_provided` + and `content_types_accepted` + +### Changed + +- Project documentation was rewrote +- `Ewebmachine.Plug.Debug`, `Ewebmachine.Plug.ErrorAsException`, + `Ewebmachine.Plug.ErrorAsForward`, `Ewebmachine.Plug.Run`, and + `Ewebmachine.Plug.Send` now implement the `Plug` behaviour. Nothing changed in + their implementation. +- `Ewebmachine.Builder.Resources.resource_quote/4` is now a private function +- Default `Ewebmachine.Handlers.to_html/2` handler now respond with an empty + string instead of with `"

Hello World

"` + +### Removed + +- Publicly exposed function and macro intended for testing purposes: + - `Ewebmachine.Handlers.ping/2` (function) + - `Ewebmachine.Builder.Handlers.ping/1` (macro) +- Ewebmachine no longer depends on `plug_cowboy` + +### Fixed + +- Crash when creating an exception `Ewebmachine.Plug.ErrorAsException` with a + `resp_body` which isn't a `bitstring`. + ## [2.3.3] (2025-09-22) ### Changed diff --git a/README.md b/README.md index 44dd016..bffa6dd 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,159 @@ # Ewebmachine [![Build Status](https://github.com/kbrw/ewebmachine/actions/workflows/.github/workflows/build-and-test.yml/badge.svg)](https://github.com/kbrw/ewebmachine/actions/workflows/build-and-test.yml) [![Hex.pm](https://img.shields.io/hexpm/v/ewebmachine.svg)](https://hex.pm/packages/ewebmachine) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/ewebmachine) ![Hex.pm License](https://img.shields.io/hexpm/l/ewebmachine) Ewebmachine is a full rewrite with clean DSL and plug integration -based on Webmachine from basho. This version is not backward compatible with -the previous one that was only a thin wrapper around webmachine, use the branch -1.0-legacy to use the old one. - -The principle is to go through the [HTTP decision tree](./assets/http_diagram.png) -and make decisions according to response of some callbacks called "handlers". - -To do that, the library gives you 5 plugs and 2 plug pipeline builders : - -- `Ewebmachine.Plug.Run` go through the HTTP decision tree and fill - the `conn` response according to it -- `Ewebmachine.Plug.Send` is used to send a conn set with `Ewebmachine.Plug.Run` -- `Ewebmachine.Plug.Debug` gives you a debugging web UI to see the - HTTP decision path taken by each request. -- `Ewebmachine.Plug.ErrorAsException` take a conn with a response set but not - send, and throw an exception is the status code is an exception -- `Ewebmachine.Plug.ErrorAsForward` take a conn with a response set but not - send, and forward it changing the request to `GET /error/pattern/:status` -- `Ewebmachine.Builder.Handlers` gives you helpers macros and a - `:add_handler` plug to add `handlers` as defined in - `Ewebmachine.Handlers` to your conn, and set the initial user state. -- `Ewebmachine.Builder.Resources` gives you a `resource` macro to - define at the same time an `Ewebmachine.Builder.Handlers` and the - matching spec to use it, and a plug `:resource_match` to do the - match and execute the associated plug. The macro `resources_plugs` helps you - to define common plug pipeline - -## Example usage +based on [Webmachine from basho](https://github.com/webmachine/webmachine). + +[Documentation for Ewebmachine is available on +hexdocs](https://hexdocs.pm/ewebmachine/readme.html). + +## Installation ```elixir -defmodule MyJSONApi do - use Ewebmachine.Builder.Handlers - plug :cors - plug :add_handlers, init: %{} + def deps do + [ + {:ewebmachine, "~> 2.3"} + ] + end +``` - content_types_provided do: ["application/json": :to_json] - defh to_json, do: Poison.encode!(state[:json_obj]) +> [!WARNING] +> This version is not backward compatible with the previous one that was only a +> thin wrapper around webmachine, use the branch 1.0-legacy to use the old one. - defp cors(conn,_), do: - put_resp_header(conn,"Access-Control-Allow-Origin","*") -end +## Overview +Ewebmachine is an application layer that adds HTTP semantic awareness on top of +the excellent `Plug` library. Ewebmachine's DSL provides an easy way to work +with your application's data. -defmodule ErrorRoutes do - use Ewebmachine.Builder.Resources ; resources_plugs - resource "/error/:status" do %{s: elem(Integer.parse(status),0)} after - content_types_provided do: ['text/html': :to_html, 'application/json': :to_json] - defh to_html, do: "

Error ! : '#{Ewebmachine.Core.Utils.http_label(state.s)}'

" - defh to_json, do: ~s/{"error": #{state.s}, "label": "#{Ewebmachine.Core.Utils.http_label(state.s)}"}/ - finish_request do: {:halt,state.s} - end -end +## Core concepts + +Ewebmachine comes with a few core concepts: +* **Resource** (a.k.a. any information that can be named): is declared via the + `Ewebmachine.Builder.Resources.resource/2` macro which expands into a plug + module inside of which handlers are declared. -defmodule FullApi do +* **Handler**: an handler is a function which return value is used by the + decision tree. Each handler has its own purpose and helps you make the + appropriate response. + +* **(HTTP) Decision tree**: process an HTTP connection by routing it through the + decision tree where decisions are taken based on a resource's handlers. Its + processing flow is described on [this schema](./assets/http_diagram.png)[^1]. + +With the core concepts in mind let's go through a simple example: +```elixir +defmodule App.Api do use Ewebmachine.Builder.Resources - if Mix.env == :dev, do: plug Ewebmachine.Plug.Debug - resources_plugs error_forwarding: "/error/:status", nomatch_404: true - plug ErrorRoutes - resource "/hello/:name" do %{name: name} after - content_types_provided do: ['application/xml': :to_xml] - defh to_xml, do: "#{state.name}" - end + plug :resource_match + plug Ewebmachine.Plug.Run + plug Ewebmachine.Plug.Send + + resource "/api/users/:id" do + %{user_id: id} + after + plug App.Api.CommonHandlers - resource "/hello/json/:name" do %{name: name} after - plug MyJSONApi #this is also a plug pipeline - allowed_methods do: ["GET","DELETE"] - delete_resource do: DB.delete(state.name) + allowed_methods do: ["GET"] - defh resource_exists do - user = DB.get(state.name) - pass(user !== nil, json_obj: user) + resource_exists do + case App.User.fetch(state.user_id) do + {:ok, user} -> pass(true, json_obj: user) + {:error, :not_found} -> false + end end + + defh to_json, do: JSON.encode!(state.user) end - resource "/static/*path" do %{path: Enum.join(path,"/")} after - resource_exists do: - File.regular?(path state.path) - content_types_provided do: - [{state.path|>Plug.MIME.path|>default_plain,:to_content}] - defh to_content, do: - File.stream!(path(state.path),[],300_000_000) - defp path(relative), do: "#{:code.priv_dir :ewebmachine_example}/web/#{relative}" - defp default_plain("application/octet-stream"), do: "text/plain" - defp default_plain(type), do: type + resource "/api/orders/:id" do + %{order_id: id} + after + plug App.Api.CommonHandlers + + allowed_methods do: ["GET"] + + resource_exists do + case App.Order.fetch(state.order_id) do + {:ok, order} -> pass(true, json_obj: order) + {:error, :not_found} -> false + end + end end end + +defmodule App.Api.CommonHandlers do + use Ewebmachine.Builder.Handlers + + plug :add_handlers, init: %{} + + content_types_provided do: ["application/json": :to_json] + + defh to_json, do: JSON.encode!(state.json_obj) +end ``` -## Debug UI +Let's go through this example by calling the `App.Api` plug module with a fake +connection: +```elixir +conn = Plug.Test.conn("GET", "/api/users/1") +conn = App.Api.call(conn, []) +``` -Go to `/wm_debug` to see precedent requests and debug there HTTP -decision path. The debug UI can be updated automatically on the -requests. +The `App.Api` module is a `Plug.Router` composed of the routes `/api/users/:id` +and `/api/orders/:id` which are defined via the `resource` macro. When our +connection enter this plug pipeline it first goes through the `:resource_match` +plug which calls the plugs `:match` and `:dispatch` defined by `Plug.Router`. +The `/api/users/:id` path being matched, the connection is dispatched to this +`resource`. -![Debug UI example](./assets/debug_ui.png) +*The resource macro underneath makes a plug module into which the +`:add_handlers` plug is added*. + +The connection goes through the resource's plug module which contains the +`App.Api.CommonHandlers` and `:add_handlers` plug. -## Use Cowboy to serve the plug +*The `:add_handlers` function plug sets inside the connection the module +implementing each handler. In this example the `allowed_methods` and +`resource_exists` handlers will be those of the resource module whereas the +module used for the definition of the `content_types_provided` handler will be +`App.Api.CommonHandlers`.* -Create a simple supervision tree with only the Cowboy server adapter spec. +The connection continues through the plug pipeline and then enters the +`Ewebmachine.Plug.Run` plug which executes the decision tree with the set +handlers. The response is then sent by the `Ewebmachine.Plug.Send` plug. +## Serve the plug + +Ewebmachine exposes plugs therefore it can be served as any other. As referenced +by [`Plug` itself](https://hexdocs.pm/plug/readme.html#installation) there is +two options either `plug_cowboy` or `bandit`. + +For instance to serve the previous `App.Api` plug with the `plug_cowboy`: ```elixir -defmodule MyApp do +defmodule App do use Application - def start(_type, _args), do: - Supervisor.start_link([ - Plug.Cowboy.child_spec(:http,FullApi,[], port: 4000) - ], strategy: :one_for_one) -end -``` -And add it as your application entry point in your `mix.exs` + def start(_type, _args) do + children = [Plug.Cowboy.child_spec(:http, App.Api, [], port: 80)] -```elixir -def application do - [applications: [:logger,:ewebmachine,:cowboy], mod: {MyApp,[]}] + Supervisor.start_link(children, strategy: :one_for_one) + end end -defp deps, do: - [{:ewebmachine, "2.3.2"}, {:cowboy, "~> 1.0"}] ``` +## Debug UI + +By adding the `Ewebmachine.Plug.Debug` plug to the plug pipeline of your +application you can have access to a debug UI exposed on the `/wm_debug` route. + +![Debug UI example](./assets/debug_ui.png) + # CONTRIBUTING Hi, and thank you for wanting to contribute. Please refer to the centralized informations available at: https://github.com/kbrw#contributing +[^1]: All credits for this schema goes to Alan Dean and Justin Sheehy. See + [here](https://github.com/webmachine/webmachine/wiki/Diagram). diff --git a/examples/hello/.gitignore b/examples/hello/.gitignore deleted file mode 100644 index cbc15fd..0000000 --- a/examples/hello/.gitignore +++ /dev/null @@ -1,19 +0,0 @@ -# The directory Mix will write compiled artifacts to. -/_build - -# If you run "mix test --cover", coverage assets end up here. -/cover - -# The directory Mix downloads your dependencies sources to. -/deps - -# Where 3rd-party dependencies like ExDoc output generated docs. -/doc - -# If the VM crashes, it generates a dump, let's ignore it too. -erl_crash.dump - -# Also ignore archive artifacts (built via "mix archive.build"). -*.ez - -/mix.lock \ No newline at end of file diff --git a/examples/hello/README.md b/examples/hello/README.md deleted file mode 100644 index 89c6d67..0000000 --- a/examples/hello/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Example application for ewebmachine - -## URLs - -* `/hello/:name` -* `/hello/json/:name` -* `/static/*path` - - diff --git a/examples/hello/config/config.exs b/examples/hello/config/config.exs deleted file mode 100644 index d92d716..0000000 --- a/examples/hello/config/config.exs +++ /dev/null @@ -1,30 +0,0 @@ -# This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. -import Config - -# This configuration is loaded before any dependency and is restricted -# to this project. If another project depends on this project, this -# file won't be loaded nor affect the parent project. For this reason, -# if you want to provide default values for your application for -# 3rd-party users, it should be done in your "mix.exs" file. - -# You can configure for your application as: -# -# config :hello, key: :value -# -# And access this configuration in your application as: -# -# Application.get_env(:hello, :key) -# -# Or configure a 3rd-party app: -# -# config :logger, level: :info -# - -# It is also possible to import configuration files, relative to this -# directory. For example, you can emulate configuration per environment -# by uncommenting the line below and defining dev.exs, test.exs and such. -# Configuration from the imported file will override the ones defined -# here (which is why it is important to import them last). -# -# import_config "#{Mix.env}.exs" diff --git a/examples/hello/lib/hello.ex b/examples/hello/lib/hello.ex deleted file mode 100644 index f418055..0000000 --- a/examples/hello/lib/hello.ex +++ /dev/null @@ -1,123 +0,0 @@ -defmodule Hello do - defmodule App do - use Application - - def start(_type, _args) do - Supervisor.start_link([ - Plug.Cowboy.child_spec(scheme: :http, plug: Hello.Api, options: [port: 4000]), - Hello.Db - ], strategy: :one_for_one) - end - end - - defmodule Db do - def child_spec(_), do: %{ - id: __MODULE__, - start: {__MODULE__, :start_link, []}, - type: :worker, - restart: :permanent, - shutdown: 500 - } - def start_link, do: Agent.start_link(&Map.new/0, name: __MODULE__) - def get(id), do: Agent.get(__MODULE__, &(Map.get(&1, id, nil))) - def put(id, val), do: Agent.update(__MODULE__, &(Map.put(&1, id, val))) - def delete(id), do: Agent.update(__MODULE__, &(Map.delete(&1, id))) - end - - defmodule ApiCommon do - use Ewebmachine.Builder.Handlers - plug :cors - plug :add_handlers - - content_types_provided do: ["application/json": :to_json] - defh to_json(conn, state), do: {Poison.encode!(state[:json_obj]), conn, state} - - defp cors(conn, _) do - put_resp_header(conn, "Access-Control-Allow-Origin", "*") - end - end - - defmodule Api do - use Ewebmachine.Builder.Resources - plug Ewebmachine.Plug.Debug - - resources_plugs nomatch_404: true - - resource "/hello/:name" do %{name: name} after - content_types_provided do: ['application/xml': :to_xml] - defh to_xml(conn, state), do: {"#{state.name}", conn, state} - end - - resource "/hello/json/:name" do %{name: name} after - plug ApiCommon # this is also a plug pipeline - - allowed_methods do: ["GET", "PUT", "DELETE"] - content_types_accepted do: ['application/json': :from_json] - - defh resource_exists(conn, state) do - case Hello.Db.get(state.name) do - nil -> {false, conn, state} - user -> {true, conn, Map.put(state, :json_obj, user)} - end - end - - defh delete_resource(conn, state), do: {Hello.Db.delete(state.name), conn, state} - - defh from_json(conn, state) do - value = conn |> Ewebmachine.fetch_req_body([]) |> Ewebmachine.req_body |> Poison.decode! - _ = Hello.Db.put(state.name, value) - {true, conn, state} - end - end - - resource "/new" do %{} after - plug ApiCommon #this is also a plug pipeline - - allowed_methods do: ["POST"] - content_types_accepted do: ['application/json': :from_json] - post_is_create do: true - - defh create_path(conn, state), do: {state.newpath, conn, state} - - defh from_json(conn, state) do - value = conn |> Ewebmachine.fetch_req_body([]) |> Ewebmachine.req_body() |> Poison.decode!() - newpath = "#{:io_lib.format("~9..0b", [:rand.uniform(999999999)])}" - _ = Hello.Db.put(value["id"], value) - {true, conn, Map.put(state, :newpath, newpath)} - end - end - - resource "/new_with_redirect" do %{} after - plug ApiCommon #this is also a plug pipeline - - allowed_methods do: ["POST"] - content_types_accepted do: ['application/json': :from_json] - post_is_create do: true - - defh create_path(conn, state), do: {state.newpath, conn, state} - - defh from_json(conn, state) do - value = conn |> Ewebmachine.fetch_req_body([]) |> Ewebmachine.req_body() |> Poison.decode!() - newpath = "#{:io_lib.format("~9..0b", [:rand.uniform(999999999)])}" - _ = Hello.Db.put(value["id"], value) - conn = Plug.Conn.put_private(conn, :resp_redirect, true) - {true, conn, Map.put(state, :newpath, newpath)} - end - end - - resource "/static/*path" do %{path: Enum.join(path, "/")} after - resource_exists do: File.regular?(path(state.path)) - content_types_provided do: [ {state.path |> MIME.from_path() |> default_plain, :to_content} ] - - defh to_content(conn, state) do - body = File.stream!( path(state.path), [], 300_000_000) - {body, conn, state} - end - - defp path(relative), do: "#{:code.priv_dir(:ewebmachine)}/static/#{relative}" - - defp default_plain("application/octet-stream"), do: "text/plain" - defp default_plain(type), do: type - end - end -end diff --git a/examples/hello/mix.exs b/examples/hello/mix.exs deleted file mode 100644 index 463144e..0000000 --- a/examples/hello/mix.exs +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Hello.Mixfile do - use Mix.Project - - def project do - [app: :hello, - version: "0.1.0", - elixir: "~> 1.3", - build_embedded: Mix.env == :prod, - start_permanent: Mix.env == :prod, - deps: deps()] - end - - def application do - [ - applications: [:logger, :ewebmachine, :cowboy, :poison], - mod: {Hello.App, []} - ] - end - - defp deps do - [ - {:ewebmachine, path: "../.."}, - {:cowboy, ">= 1.0.4"}, - {:poison, "~> 3.0.0"} - ] - end -end diff --git a/lib/ewebmachine.ex b/lib/ewebmachine.ex index c4b998b..bcfab33 100644 --- a/lib/ewebmachine.ex +++ b/lib/ewebmachine.ex @@ -1,35 +1,38 @@ defmodule Ewebmachine do - @moduledoc (File.read!("README.md") |> - String.replace(~r/^See the \[generated.*$/m, "") |> - String.replace(~r/^.*Build Status.*$/m, "") |> - String.replace("https://raw.githubusercontent.com/kbrw/ewebmachine/master/doc/", "")) + @moduledoc """ + Contains utility functions to work with Ewebmachine private fields set inside `conn` + """ alias Plug.Conn @doc """ - Set :resp_redirect to `true` + Sets `:resp_redirect` to `true` """ - @spec do_redirect(Plug.Conn.t) :: Plug.Conn.t + @spec do_redirect(Plug.Conn.t()) :: Plug.Conn.t() def do_redirect(conn) do Conn.put_private(conn, :resp_redirect, true) end @doc """ - Returns request body from request (requires fetching body first) + Returns request body from request (requires fetching body first). + + See `fetch_req_body/2` to fetch the body. """ - @spec req_body(Plug.Conn.t) :: binary + @spec req_body(Plug.Conn.t()) :: binary def req_body(conn), do: conn.private[:req_body] @doc """ - Fetch request body + Fetches the request body. - Options: - * max_length: maximum bytes to fetch (default: 1_000_000) + Options: + * `max_length` - maximum bytes to fetch. Defaults to: `1_000_000`. """ - @spec fetch_req_body(Plug.Conn.t, Enumerable.t) :: Plug.Conn.t + @spec fetch_req_body(Plug.Conn.t(), Enumerable.t()) :: Plug.Conn.t() def fetch_req_body(conn, opts) do - if conn.private[:req_body] do conn else - {:ok, body, conn} = Conn.read_body(conn, length: (opts[:max_length] || 1_000_000)) + if conn.private[:req_body] do + conn + else + {:ok, body, conn} = Conn.read_body(conn, length: opts[:max_length] || 1_000_000) Conn.put_private(conn, :req_body, body) end end diff --git a/lib/ewebmachine/app.ex b/lib/ewebmachine/app.ex index 9d6e7dc..bd63fac 100644 --- a/lib/ewebmachine/app.ex +++ b/lib/ewebmachine/app.ex @@ -1,10 +1,14 @@ defmodule Ewebmachine.App do - @moduledoc false + @moduledoc false use Application - def start(_,_) do - Supervisor.start_link([ - Ewebmachine.Log, - Ewebmachine.Events - ], strategy: :one_for_one) + + def start(_, _) do + Supervisor.start_link( + [ + Ewebmachine.Log, + Ewebmachine.Events + ], + strategy: :one_for_one + ) end end diff --git a/lib/ewebmachine/builder.handlers.ex b/lib/ewebmachine/builder.handlers.ex index 833ad0b..e419a1d 100644 --- a/lib/ewebmachine/builder.handlers.ex +++ b/lib/ewebmachine/builder.handlers.ex @@ -1,125 +1,138 @@ defmodule Ewebmachine.Builder.Handlers do @moduledoc """ - `use` this module will `use Plug.Builder` (so a plug pipeline - described with the `plug module_or_function_plug` macro), but gives - you an `:add_handler` local function plug which adds to the conn - the locally defined ewebmachine handlers (see `Ewebmachine.Handlers`). - - So : - - - Construct your automate decision handler through multiple `:add_handler` plugs - - Pipe the plug `Ewebmachine.Plug.Run` to run the HTTP automate which - will call these handlers to take decisions. - - Pipe the plug `Ewebmachine.Plug.Send` to send and halt any conn previsously passed - through an automate run. - - To define handlers, use the following helpers : - - - the handler specific macros (like `Ewebmachine.Builder.Handlers.resource_exists/1`) - - the macro `defh/2` to define any helpers, usefull for body - producing handlers or to have multiple function clauses - - in handler implementation `conn` and `state` binding are available - - the response of the handler implementation is wrapped, so that - returning `:my_response` is the same as returning `{:my_response,conn,state}` - - Below a full example : - - ``` - defmodule MyJSONApi do - use Ewebmachine.Builder.Handlers - plug :cors - plug :add_handlers, init: %{} - - content_types_provided do: ["application/json": :to_json] - defh to_json, do: Poison.encode!(state[:json_obj]) - - defp cors(conn,_), do: - put_resp_header(conn,"Access-Control-Allow-Origin","*") - end - - defmodule GetUser do - use Ewebmachine.Builder.Handlers - plug MyJSONApi - plug :add_handlers - plug Ewebmachine.Plug.Run - plug Ewebmachine.Plug.Send - resource_exists do: - pass( !is_nil(user=DB.User.get(conn.params["q"])), json_obj: user) - end - defmodule GetOrder do - use Ewebmachine.Builder.Handlers - plug MyJSONApi - plug :add_handlers - plug Ewebmachine.Plug.Run - plug Ewebmachine.Plug.Send - resource_exists do: - pass(!is_nil(order=DB.Order.get(conn.params["q"])), json_obj: order) - end - - defmodule API do - use Plug.Router - plug :match - plug :dispatch - - get "/get/user", do: GetUser.call(conn,[]) - get "/get/order", do: GetOrder.call(conn,[]) - end - ``` + Provides macros to define your handlers, usually inside a + `Ewebmachine.Builder.Resources.resource/2`. + + This module defines: + * a specific macro for each handler defined in `Ewebmachine.Builder.Handlers` + see the [Handlers](#handlers) section. + * the `defh/2` macro to define handler + * the `pass/2` macro to ease handler response + + > #### `use Ewebmachine.Builder.Handlers` {: .info} + > + > When you `use Ewebmachine.Builder.Handlers`, it will `use Plug.Builder` + > and define a function plug `:add_handlers`. This plug will register every + > defined handlers inside the calling module into `conn`. Those registered + > handler will then be used by the `Ewebmachine.Plug.Run` plug. + + > #### Declare and use common handlers {: .tip} + > + > If you want to add common handlers that should be used for several + > `Ewebmachine.Builder.Resources.resource/2`s, you can do so by making a plug + > module with your common handlers and use the `:add_handlers` plug. + > The module implementing an handler is saved inside the `conn` and is then + > used by the `Ewebmachine.Plug.Run` plug to call the appropriate one. + > For instance if you define the following plug module: + > ```elixir + > defmodule CommonHandlers + > use Ewebmachine.Builder.Handlers + > plug :add_handlers + > + > content_types_provided ["application/json": :to_json] + > defh to_json do + > JSON.encode!(state.response) + > end + > end + > ``` + > and add it to your application's plug pipeline then the module implementing + > the handler `content_types_provided` will be `CommonHandlers` instead of + > the default `Ewebmachine.Handlers`. """ defmacro __before_compile__(_env) do quote do defp add_handlers(conn, opts) do - conn = case Access.fetch(opts, :init) do - {:ok, init} when not (init in [false, nil]) -> put_private(conn, :machine_init, init) - _ -> conn - end - Plug.Conn.put_private(conn, :resource_handlers, - Enum.into(@resource_handlers, conn.private[:resource_handlers] || %{})) + conn = + case Access.fetch(opts, :init) do + {:ok, init} when init not in [false, nil] -> put_private(conn, :machine_init, init) + _ -> conn + end + + Plug.Conn.put_private( + conn, + :resource_handlers, + Enum.into(@resource_handlers, conn.private[:resource_handlers] || %{}) + ) end end end + defmacro __using__(_opts) do quote location: :keep do use Plug.Builder import Ewebmachine.Builder.Handlers @before_compile Ewebmachine.Builder.Handlers @resource_handlers %{} - ping do: :pong + + if Mix.env() == :test do + defh ping(_, _), do: :pong + end end end @resource_fun_names [ - :resource_exists,:service_available,:is_authorized,:forbidden,:allow_missing_post,:malformed_request,:known_methods, - :base_uri,:uri_too_long,:known_content_type,:valid_content_headers,:valid_entity_length,:options,:allowed_methods, - :delete_resource,:delete_completed,:post_is_create,:create_path,:process_post,:content_types_provided, - :content_types_accepted,:charsets_provided,:encodings_provided,:variances,:is_conflict,:multiple_choices, - :previously_existed,:moved_permanently,:moved_temporarily,:last_modified,:expires,:generate_etag, :ping, :finish_request + :resource_exists, + :service_available, + :is_authorized, + :forbidden, + :allow_missing_post, + :malformed_request, + :known_methods, + :base_uri, + :uri_too_long, + :known_content_type, + :valid_content_headers, + :valid_entity_length, + :options, + :allowed_methods, + :delete_resource, + :delete_completed, + :post_is_create, + :create_path, + :process_post, + :content_types_provided, + :content_types_accepted, + :charsets_provided, + :encodings_provided, + :variances, + :is_conflict, + :multiple_choices, + :previously_existed, + :moved_permanently, + :moved_temporarily, + :last_modified, + :expires, + :generate_etag, + :finish_request ] - defp sig_to_sigwhen({:when,_,[{name,_,params},guard]}), do: {name,params,guard} - defp sig_to_sigwhen({name,_,params}) when is_list(params), do: {name,params,true} - defp sig_to_sigwhen({name,_,_}), do: {name,[quote(do: _),quote(do: _)],true} - - defp handler_quote(name,body,guard,conn_match,state_match) do - conn_match = case var_in_patterns?(conn_match, :conn) do - true -> conn_match - false -> quote(do: unquote(conn_match) = var!(conn)) - end + defp sig_to_sigwhen({:when, _, [{name, _, params}, guard]}), do: {name, params, guard} + defp sig_to_sigwhen({name, _, params}) when is_list(params), do: {name, params, true} + defp sig_to_sigwhen({name, _, _}), do: {name, [quote(do: _), quote(do: _)], true} + + defp handler_quote(name, body, guard, conn_match, state_match) do + conn_match = + case var_in_patterns?(conn_match, :conn) do + true -> conn_match + false -> quote(do: unquote(conn_match) = var!(conn)) + end - state_match = case var_in_patterns?(state_match, :state) do - true -> state_match - false -> quote(do: unquote(state_match) = var!(state)) - end + state_match = + case var_in_patterns?(state_match, :state) do + true -> state_match + false -> quote(do: unquote(state_match) = var!(state)) + end quote do - @resource_handlers Map.put(@resource_handlers,unquote(name),__MODULE__) + @resource_handlers Map.put(@resource_handlers, unquote(name), __MODULE__) def unquote(name)(unquote(conn_match), unquote(state_match)) when unquote(guard) do res = unquote(body) - wrap_response(res,var!(conn),var!(state)) + wrap_response(res, var!(conn), var!(state)) end end end - defp handler_quote(name,body) do - handler_quote(name,body,true,quote(do: _),quote(do: _)) + + defp handler_quote(name, body) do + handler_quote(name, body, true, quote(do: _), quote(do: _)) end defp var_in_patterns?(ast, name) do @@ -135,67 +148,99 @@ defmodule Ewebmachine.Builder.Handlers do end end - @doc """ - define a resource handler function as described at - `Ewebmachine.Handlers`. - - Since there is a specific macro in this module for each handler, - this macro is useful : - - - to define body producing and body processing handlers (the one - referenced in the response of `Ewebmachine.Handlers.content_types_provided/2` or - `Ewebmachine.Handlers.content_types_accepted/2`) - - to explicitly take the `conn` and the `state` parameter, which - allows you to add guards and pattern matching for instance to - define multiple clauses for the handler - - ``` - defh to_html, do: "hello you" - defh from_json, do: pass(:ok, json: Poison.decode!(read_body conn)) - ``` + @doc false + def wrap_response({_, %Plug.Conn{}, _} = tuple, _, _), do: tuple + def wrap_response(r, conn, state), do: {r, conn, state} - ``` - defh resources_exists(conn,%{obj: obj}) when obj !== nil, do: true - defh resources_exists(conn,_), do: false - ``` + @doc """ + Defines a resource handler function as described by the `Ewebmachine.Handlers` + module. + + Handlers defined with this macro can either return the complete tuple + `{response, conn, state}` or only the `response`. The returned value is then + wrapped if needed. + + Use this macro when you want to: + * define a handler which processes and produces body (the one given to + `Ewebmachine.Handlers.content_types_provided/2` or + `Ewebmachine.Handlers.content_types_accepted/2`) + + ```elixir + content_types_accepted do: ["text/heml": :to_html] + content_types_provided do: ["application/json": :from_json] + + defh to_html, do: "hello you" + defh from_json, do: pass(:ok, json: Poison.decode!(read_body conn)) + ``` + + * use pattern matching via the function's head or a guard + ```elixir + defh resources_exists(conn, %{obj: obj}) when obj != nil, do: true + defh resources_exists(conn, _), do: false + ``` + + > #### Predefined handler {: .info} + > + > If you don't need pattern matching you can directly use the predefined + > handlers from this module. For instance instead of `defh + > resource_exists(conn, state) do ... end` use `resource_exist do ... end`. """ defmacro defh(signature, do_block) do - {name, [conn_match,state_match], guard} = sig_to_sigwhen(signature) + {name, [conn_match, state_match], guard} = sig_to_sigwhen(signature) handler_quote(name, do_block[:do], guard, conn_match, state_match) end - for resource_fun_name<-@resource_fun_names do - Code.eval_quoted(quote do - @doc "see `Ewebmachine.Handlers.#{unquote(resource_fun_name)}/2`" - defmacro unquote(resource_fun_name)(do_block) do - name = unquote(resource_fun_name) - handler_quote(name,do_block[:do]) - end - end, [], __ENV__) + for resource_fun_name <- @resource_fun_names do + Code.eval_quoted( + quote do + @doc group: :handler + @doc """ + Same as `defh #{unquote(resource_fun_name)}(conn, state) do ... end` + + See `Ewebmachine.Handlers.#{unquote(resource_fun_name)}/2` for more + information on the handler's purpose. + """ + defmacro unquote(resource_fun_name)(do_block) do + name = unquote(resource_fun_name) + handler_quote(name, do_block[:do]) + end + end, + [], + __ENV__ + ) end - @doc false - def wrap_response({_,%Plug.Conn{},_}=tuple,_,_), do: tuple - def wrap_response(r,conn,state), do: {r,conn,state} - @doc """ - Shortcut macro for : - {response,var!(conn),Enum.into(update_state,var!(state))} + Passes the handler response as well as updating the state. + + It expands to + ```elixir + {response, var!(conn), Enum.into(update_state, var!(state))} + ``` - use it if your handler wants to add some value to a collectable - state (a map for instance), but using default "conn" current - binding. + Use it when you need to add some value to an `t:Enumerable.t/0` state. - for instance a resources_exists implementation "caching" the result - in the state could be : + For instance for a `Ewebmachine.Handlers.resource_exists/2` handler which + caches the result: + ```elixir + resource_exists do + user = DB.get(state.id) - pass (user=DB.get(state.id)) != nil, current_user: user - # same as returning : - {true,conn,%{id: "arnaud", current_user: %User{id: "arnaud"}}} + pass is_map(user), current_user: user + end + ``` + instead of defining the whole response tuple of the handler + ```elixir + resource_exists do + user = DB.get(state.id) + + {is_map(user), conn, Map.put(state, :current_user, user)} + end + ``` """ - defmacro pass(response,update_state) do + defmacro pass(response, update_state) do quote do - {unquote(response),var!(conn),Enum.into(unquote(update_state),var!(state))} + {unquote(response), var!(conn), Enum.into(unquote(update_state), var!(state))} end end end diff --git a/lib/ewebmachine/builder.resources.ex b/lib/ewebmachine/builder.resources.ex index 24b8adc..b736c00 100644 --- a/lib/ewebmachine/builder.resources.ex +++ b/lib/ewebmachine/builder.resources.ex @@ -1,26 +1,33 @@ defmodule Ewebmachine.Builder.Resources do @moduledoc ~S""" - `use` this module will `use Plug.Builder` (so a plug pipeline - described with the `plug module_or_function_plug` macro), but gives - you a `:resource_match` local function plug which matches routes declared - with the `resource/2` macro and execute the plug defined by its body. + Provides macros to make endpoints for resources. - See `Ewebmachine.Builder.Handlers` documentation to see how to - contruct these modules (in the `after` block) + > #### `use Ewebmachine.Builder.Resources` {: .info} + > + > When you `use Ewebmachine.Builder.Resources`, it will `use Plug.Router`, + > `import Ewebmachine.Builder.Resources`, declare a private function plug + > `resource_match` which passes the connection through the `:match` and + > `:dispatch` plugs from `Plug`. - Below a full example : + `use Ewebmachine.Builder.Resources` also accept the following option: + * `:default_plugs` - which when set to `true`, also add the plugs + `:resource_match`, `Ewebmachine.Plug.Run` and `Ewebmachine.Plug.Send` - ``` + ## Example + Multiple resources can be declared inside a single module and will be matched + accordingly. + ```elixir defmodule FullApi do use Ewebmachine.Builder.Resources + if Mix.env == :dev, do: plug Ewebmachine.Plug.Debug - # pre plug, for instance you can put plugs defining common handlers + # Pre plug, for instance you can put plugs defining common handlers plug :resource_match plug Ewebmachine.Plug.Run - # customize ewebmachine result, for instance make an error page handler plug + # Customize Ewebmachine result, for instance make an error page handler plug plug Ewebmachine.Plug.Send - # plug after that will be executed only if no ewebmachine resources has matched + # Plug after that will be executed only if no ewebmachine resources has matched resource "/hello/:name" do %{name: name} after plug SomeAdditionnalPlug @@ -28,55 +35,22 @@ defmodule Ewebmachine.Builder.Resources do defh to_xml, do: "#{state.name}" end - resource "/*path" do %{path: Enum.join(path,"/")} after - resource_exists do: - File.regular?(path state.path) - content_types_provided do: - [{state.path|>Plug.MIME.path|>default_plain,:to_content}] - defh to_content, do: - File.stream!(path(state.path),[],300_000_000) - defp path(relative), do: "#{:code.priv_dir :ewebmachine_example}/web/#{relative}" - defp default_plain("application/octet-stream"), do: "text/plain" - defp default_plain(type), do: type + resource "/hello/foo/:bar" do %{bar: bar} after + content_types_provided do: ['text/plain': :to_text] + defh to_text, do: "Foo #{state.bar}" end end ``` - ## Common Plugs macro helper - - As the most common use case is to match resources, run the webmachine - automate, then set a 404 if no resource match, then handle error code, then - send the response, the `resources_plugs/1` macro allows you to do that. - - For example, if you want to convert all HTTP errors as Exceptions, and - consider that all path must be handled and so any non matching path should - return a 404 : - - resources_plugs error_as_exception: true, nomatch_404: true - - is equivalent to - - plug :resource_match - plug Ewebmachine.Plug.Run - plug :wm_notset_404 - plug Ewebmachine.Plug.ErrorAsException - plug Ewebmachine.Plug.Send - - defp wm_notset_404(%{state: :unset}=conn,_), do: resp(conn,404,"") - defp wm_notset_404(conn,_), do: conn - - Another example, following plugs must handle non matching paths and errors - should be converted into `GET /error/:status` that must be handled by - following plugs : - - resources_plugs error_forwarding: "/error/:status" - - is equivalent to - - plug :resource_match - plug Ewebmachine.Plug.Run - plug Ewebmachine.Plug.ErrorAsForward, forward_pattern: "/error/:status" - plug Ewebmachine.Plug.Send + > #### Increasing compilation time {: .warning} + > + > Using many `resource/2` macros inside the same module can significantly + > raise the compilation time since it makes a module underneath and if one + > depends on another module at compilte time it will need to recompile the + > whole file, which the compiler does in a linear maner. + > + > Splitting a module which uses many `resource/2` will decrease the + > compilation time. """ defmacro __using__(opts) do quote location: :keep do @@ -100,68 +74,93 @@ defmodule Ewebmachine.Builder.Resources do end defmacro __before_compile__(_env) do - wm_routes = Module.get_attribute __CALLER__.module, :wm_routes - route_matches = for {route,wm_module,init_block}<-Enum.reverse(wm_routes) do - quote do - Plug.Router.match unquote(route) do - init = unquote(init_block) - var!(conn) = put_private(var!(conn),:machine_init,init) - unquote(wm_module).call(var!(conn),[]) + wm_routes = Module.get_attribute(__CALLER__.module, :wm_routes) + + route_matches = + for {route, wm_module, init_block} <- Enum.reverse(wm_routes) do + quote do + Plug.Router.match unquote(route) do + init = unquote(init_block) + var!(conn) = put_private(var!(conn), :machine_init, init) + unquote(wm_module).call(var!(conn), []) + end end end - end - final_match = if !match?({"/*"<>_,_,_},hd(wm_routes)), - do: quote(do: Plug.Router.match _ do var!(conn) end) + + final_match = + if !match?({"/*" <> _, _, _}, hd(wm_routes)) do + quote( + do: + Plug.Router.match _ do + var!(conn) + end + ) + end + quote do unquote_splicing(route_matches) unquote(final_match) end end - defp remove_first(":"<>e), do: e - defp remove_first("*"<>e), do: e + defp remove_first(":" <> e), do: e + defp remove_first("*" <> e), do: e defp remove_first(e), do: e - defp route_as_mod(route), do: - (route |> String.split("/") |> Enum.map(& &1 |> remove_first |> String.capitalize) |> Enum.join) - - @doc ~S""" - Create a webmachine handler plug and use it on `:resource_match` when path matches + defp route_as_mod(route) do + route + |> String.split("/") + |> Enum.map(&(&1 |> remove_first |> String.capitalize())) + |> Enum.join() + end - - the route will be the matching spec (see Plug.Router.match, string spec only) - - do_block will be called on match (so matching bindings will be - available) and should return the initial state - - after_block will be the webmachine handler plug module body - (wrapped with `use Ewebmachine.Builder.Handlers` and `plug - :add_handlers` to clean the declaration. + @doc """ + Same as `resource/2` but you can set the module's name. + ```elixir + resource ShortenedRouteName, "/my/route/that/would/generate/a/long/module/name/:id" do + # ... + end ``` - resource "/my/route/:commaid" do - id = string.split(commaid,",") - %{foo: id} - after - plug someadditionnalplug - resource_exists do: state.id == ["hello"] + """ + defmacro resource({:__aliases__, _, route_aliases}, route, do: init_block, after: body) do + resource_quote(Module.concat([__CALLER__.module | route_aliases]), route, init_block, body) end - resource ShortenedRouteName, "/my/route/that/would/generate/a/long/module/name/:commaid" do - id = String.split(commaid,",") + @doc ~S""" + Creates a plug module which is executed on path match. + + The created module will use the `Ewebmachine.Builder.Handlers` module and add + the plug `:add_handlers` at the end of it. + + See `Plug.Router` for the path specification. + + The macro takes a `do` and an `after`. Both are executed on match. The `do` + block is executed to make the initial state. In its scope path bindings are + available. The handlers declared in the `after` block are executed by the + `Ewebmachine.Plug.Run` plug. + + ## Example + + ```elixir + resource "/my/route/:commaid" do + id = String.split(commaid, ",") %{foo: id} after - plug SomeAdditionnalPlug resource_exists do: state.id == ["hello"] end ``` - """ - defmacro resource({:__aliases__, _, route_aliases},route,do: init_block, after: body) do - resource_quote(Module.concat([__CALLER__.module|route_aliases]),route,init_block,body) - end - defmacro resource(route,do: init_block, after: body) do - resource_quote(Module.concat(__CALLER__.module,"EWM"<>route_as_mod(route)),route,init_block,body) + defmacro resource(route, do: init_block, after: body) do + resource_quote( + Module.concat(__CALLER__.module, "EWM" <> route_as_mod(route)), + route, + init_block, + body + ) end - def resource_quote(wm_module,route,init_block,body) do + defp resource_quote(wm_module, route, init_block, body) do quote do @wm_routes {unquote(route), unquote(wm_module), unquote(Macro.escape(init_block))} @@ -173,23 +172,55 @@ defmodule Ewebmachine.Builder.Resources do end end - alias Ewebmachine.Plug.ErrorAsException - alias Ewebmachine.Plug.ErrorAsForward + @doc """ + Adds common plugs to match resources with Ewebmachine. + + The macro's intent is to cover the most common use case to match resources. To + do so it adds the following plug by default: + * `:resource_match` which passes the connection through the `:match` and + `:dispatch` plugs of `Plug`. + * `Ewebmachine.Plug.Run` + * `Ewebmachine.Plug.Send` + + Additional processing can be provided for connection which did not match any + route or which returns an erroneous http code via the following options: + * `:nomatch_404` - if sets to `true` adds a function plug `wm_notset_404` + which is case of an `:unset` state respond with a `404` http code. Defaults + to `false`. + * `:error_as_exception` - if sets to `true` adds the + `Ewebmachine.Plug.ErrorAsException` plug. Defaults to `false`. + * `:error_forwarding` - when a pattern is given and `:error_as_exception` is + not set, it adds the `Ewebmachine.Plug.ErrorAsForward` plug with as + `:forward_pattern` the given pattern. Defaults to `nil`. + """ defmacro resources_plugs(opts \\ []) do - {errorplug,errorplug_params} = cond do - opts[:error_as_exception]->{ErrorAsException,[]} - (forward_pattern=opts[:error_forwarding])->{ErrorAsForward,[forward_pattern: forward_pattern]} - true -> {false,[]} - end + alias Ewebmachine.Plug.ErrorAsException + alias Ewebmachine.Plug.ErrorAsForward + + {error_as_exception?, opts} = Keyword.pop(opts, :error_as_exception, false) + {forward_pattern, opts} = Keyword.pop(opts, :error_as_exception) + {nomatch_404?, _} = Keyword.pop(opts, :nomatch_404, false) + + true = is_boolean(error_as_exception?) + true = is_binary(forward_pattern) || is_nil(forward_pattern) + true = is_boolean(nomatch_404?) + + {errorplug, errorplug_params} = + cond do + error_as_exception? -> {ErrorAsException, []} + is_binary(forward_pattern) -> {ErrorAsForward, [forward_pattern: forward_pattern]} + true -> {false, []} + end + quote do plug :resource_match plug Ewebmachine.Plug.Run - if unquote(opts[:nomatch_404]), do: plug :wm_notset_404 - if unquote(errorplug), do: plug(unquote(errorplug),unquote(errorplug_params)) + if unquote(nomatch_404?), do: plug(:wm_notset_404) + if unquote(errorplug), do: plug(unquote(errorplug), unquote(errorplug_params)) plug Ewebmachine.Plug.Send - defp wm_notset_404(%{state: :unset}=conn,_), do: resp(conn,404,"") - defp wm_notset_404(conn,_), do: conn + defp wm_notset_404(%{state: :unset} = conn, _), do: resp(conn, 404, "") + defp wm_notset_404(conn, _), do: conn end end end diff --git a/lib/ewebmachine/compat.ex b/lib/ewebmachine/compat.ex deleted file mode 100644 index 1d41d75..0000000 --- a/lib/ewebmachine/compat.ex +++ /dev/null @@ -1,14 +0,0 @@ -defmodule Ewebmachine.Compat do - @moduledoc false -end - -defmodule Ewebmachine.Compat.Enum do - @moduledoc false - - case Version.compare(System.version(), "1.4.0") do - :gt -> - defdelegate split_with(arg0, arg1), to: Enum - _ -> - defdelegate split_with(arg0, arg1), to: Enum, as: :partition - end -end diff --git a/lib/ewebmachine/core.dsl.ex b/lib/ewebmachine/core.dsl.ex index 139d432..a721802 100644 --- a/lib/ewebmachine/core.dsl.ex +++ b/lib/ewebmachine/core.dsl.ex @@ -1,34 +1,19 @@ defmodule Ewebmachine.Core.DSL do + @moduledoc false + ## Macros and helpers defining the DSL for the Ewebmachine decision ## core : for legacy reasons, the module is called 'DSL' while they are ## are mostly helper functions. - ## - ## Changes: - ## Macros hiding `conn` and `user_state` variables have been removed - ## as they can produce unsafe use of these variables if used in - ## structures like if/cond/... which is deprecated in elixir 1.3 - - ## Usage : - - ## decision mydecision(conn, user_state, args...) do # def mydecision(conn, user_state, arg...) - ## ...debug_decision - ## ...exec body - ## end - @moduledoc false alias Plug.Conn - defmacro __using__(_opts) do quote do - import Ewebmachine.Core.DSL - import Ewebmachine.Core.Utils - end end - - def sig_to_sigwhen({:when, _, [{name,_,params}, guard]}), do: {name, params, guard} + def sig_to_sigwhen({:when, _, [{name, _, params}, guard]}), do: {name, params, guard} def sig_to_sigwhen({name, _, params}) when is_list(params), do: {name, params, true} def sig_to_sigwhen({name, _, _}), do: {name, [], true} defmacro decision(sig, do: body) do {name, [conn, state], guard} = sig_to_sigwhen(sig) + quote do def unquote(name)(unquote(conn), unquote(state)) when unquote(guard) do var!(conn) = Ewebmachine.Log.debug_decision(unquote(conn), unquote(name)) @@ -41,11 +26,13 @@ defmodule Ewebmachine.Core.DSL do handler = conn.private[:resource_handlers][fun] || Ewebmachine.Handlers {reply, conn, state} = term = apply(handler, fun, [conn, state]) conn = Ewebmachine.Log.debug_call(conn, handler, fun, [conn, state], term) + case reply do {:halt, code} -> - throw {:halt, set_response_code(conn, code)} + throw({:halt, set_response_code(conn, code)}) + _ -> - {reply, conn, state} + {reply, conn, state} end end @@ -60,50 +47,54 @@ defmodule Ewebmachine.Core.DSL do def get_header_val(conn, name), do: first_or_nil(Conn.get_req_header(conn, name)) def set_response_code(conn, code) do - conn = conn # halt machine when set response code, on respond - |> Conn.put_status(code) - |> Ewebmachine.Log.debug_enddecision + # halt machine when set response code, on respond + conn = + conn + |> Conn.put_status(code) + |> Ewebmachine.Log.debug_enddecision() + conn = if !conn.resp_body, do: %{conn | resp_body: ""}, else: conn %{conn | state: :set} end def set_resp_header(conn, k, v), do: Conn.put_resp_header(conn, k, v) - + def set_resp_headers(conn, kvs) do - Enum.reduce(kvs, conn, - fn {k,v}, acc -> - Conn.put_resp_header(acc, k, v) - end) + Enum.reduce(kvs, conn, fn {k, v}, acc -> + Conn.put_resp_header(acc, k, v) + end) end def remove_resp_header(conn, k) do Conn.delete_resp_header(conn, k) end - def set_disp_path(conn, path), do: %{conn | script_name: String.split("#{path}","/")} + def set_disp_path(conn, path), do: %{conn | script_name: String.split("#{path}", "/")} def resp_body(conn), do: conn.private[:machine_body_stream] || conn.resp_body def set_resp_body(conn, body) when is_binary(body) or is_list(body) do %{conn | resp_body: body} end - def set_resp_body(conn, body) do #if not an IO List, then it should be an enumerable + + # if not an IO List, then it should be an enumerable + def set_resp_body(conn, body) do Conn.put_private(conn, :machine_body_stream, body) end def has_resp_body(conn) do - (!is_nil(conn.resp_body) or !is_nil(conn.private[:machine_body_stream])) + !is_nil(conn.resp_body) or !is_nil(conn.private[:machine_body_stream]) end - + def get_metadata(conn, key), do: conn.private[key] def set_metadata(conn, k, v), do: Conn.put_private(conn, k, v) - + def compute_body_md5(conn) do conn = Ewebmachine.fetch_req_body(conn, []) :crypto.hash(:md5, Ewebmachine.req_body(conn)) end - def first_or_nil([v|_]), do: v + def first_or_nil([v | _]), do: v def first_or_nil(_), do: nil end diff --git a/lib/ewebmachine/core.ex b/lib/ewebmachine/core.ex index 5682fbd..f8418cd 100644 --- a/lib/ewebmachine/core.ex +++ b/lib/ewebmachine/core.ex @@ -1,34 +1,42 @@ defmodule Ewebmachine.Core do - use Ewebmachine.Core.DSL + @moduledoc false ## Basho webmachine Core rewrited in a systematic way to make the ## conversion as reliable as possible. The Ewebmachine.Core.DSL ## allows this conversion to be clean imitating the DSL of Basho. - @moduledoc false - @spec v3(Plug.Conn.t, any) :: Plug.Conn.t + import Ewebmachine.Core.DSL + import Ewebmachine.Core.Utils + + initial_decision = + if Mix.env() == :test, + do: :v3b13_test, + else: :v3b13 + + @spec v3(Plug.Conn.t(), any) :: Plug.Conn.t() def v3(conn, user_state) do try do - {_, conn, _} = v3b13(Ewebmachine.Log.debug_init(conn), user_state) + {_, conn, _} = unquote(initial_decision)(Ewebmachine.Log.debug_init(conn), user_state) conn catch - :throw, {:halt,conn} -> conn + :throw, {:halt, conn} -> conn end end - ## "Service Available" - decision v3b13(conn, state) do + decision v3b13_test(conn, state) do case resource_call(conn, state, :ping) do {:pong, conn, state} -> - v3b13b(conn, state); + v3b13(conn, state) + {_, conn, state} -> - respond(conn, state, 503) + respond(conn, state, 503) end end - ## "see `v3b13/2`" - decision v3b13b(conn, state) do + ## "Service Available" + decision v3b13(conn, state) do {reply, conn, state} = resource_call(conn, state, :service_available) + if reply do v3b12(conn, state) else @@ -39,6 +47,7 @@ defmodule Ewebmachine.Core do ## "Known method?" decision v3b12(conn, state) do {methods, conn, state} = resource_call(conn, state, :known_methods) + if method(conn) in methods do v3b11(conn, state) else @@ -49,7 +58,8 @@ defmodule Ewebmachine.Core do ## "URI too long?" decision v3b11(conn, state) do {reply, conn, state} = resource_call(conn, state, :uri_too_long) - if reply do + + if reply do respond(conn, state, 414) else v3b10(conn, state) @@ -59,10 +69,11 @@ defmodule Ewebmachine.Core do ## "Method allowed?" decision v3b10(conn, state) do {methods, conn, state} = resource_call(conn, state, :allowed_methods) + if method(conn) in methods do v3b9(conn, state) else - conn = set_resp_headers(conn, %{"allow" => Enum.join(methods,",")}) + conn = set_resp_headers(conn, %{"allow" => Enum.join(methods, ",")}) respond(conn, state, 405) end end @@ -82,19 +93,23 @@ defmodule Ewebmachine.Core do {:not_validated, conn, state} -> case Base.decode64(get_header_val(conn, "content-md5")) do {:ok, checksum} -> - case compute_body_md5(conn) do - ^checksum -> - v3b9b(conn, state); - _ -> - respond(conn, state, 400) - end + case compute_body_md5(conn) do + ^checksum -> + v3b9b(conn, state) + + _ -> + respond(conn, state, 400) + end + _ -> - respond(conn, state, 400) + respond(conn, state, 400) end + {false, conn, state} -> - respond(conn, state, 400) + respond(conn, state, 400) + {_, conn, state} -> - v3b9b(conn, state) + v3b9b(conn, state) end end @@ -102,9 +117,10 @@ defmodule Ewebmachine.Core do decision v3b9b(conn, state) do case resource_call(conn, state, :malformed_request) do {true, conn, state} -> - respond(conn, state, 400) + respond(conn, state, 400) + {false, conn, state} -> - v3b8(conn, state) + v3b8(conn, state) end end @@ -112,10 +128,11 @@ defmodule Ewebmachine.Core do decision v3b8(conn, state) do case resource_call(conn, state, :is_authorized) do {true, conn, state} -> - v3b7(conn, state) + v3b7(conn, state) + {auth_head, conn, state} -> conn = set_resp_header(conn, "www-authenticate", to_string(auth_head)) - respond(conn, state, 401) + respond(conn, state, 401) end end @@ -123,15 +140,17 @@ defmodule Ewebmachine.Core do decision v3b7(conn, state) do case resource_call(conn, state, :forbidden) do {true, conn, state} -> - respond(conn, state, 403); + respond(conn, state, 403) + {false, conn, state} -> - v3b6(conn, state) + v3b6(conn, state) end end ## "Okay Content-* Headers?" decision v3b6(conn, state) do {reply, conn, state} = resource_call(conn, state, :valid_content_headers) + if reply do v3b5(conn, state) else @@ -142,6 +161,7 @@ defmodule Ewebmachine.Core do ## "Known Content-Type?" decision v3b5(conn, state) do {reply, conn, state} = resource_call(conn, state, :known_content_type) + if reply do v3b4(conn, state) else @@ -152,6 +172,7 @@ defmodule Ewebmachine.Core do ## "Req Entity Too Large?" decision v3b4(conn, state) do {reply, conn, state} = resource_call(conn, state, :valid_entity_length) + if reply do v3b3(conn, state) else @@ -162,38 +183,43 @@ defmodule Ewebmachine.Core do ## "OPTIONS?" decision v3b3(conn, state) do case method(conn) do - "OPTIONS"-> + "OPTIONS" -> {hdrs, conn, state} = resource_call(conn, state, :options) conn = set_resp_headers(conn, hdrs) respond(conn, state, 200) + _ -> - v3c3(conn, state) + v3c3(conn, state) end end ## "Accept exists?" decision v3c3(conn, state) do {ct_provided, conn, state} = resource_call(conn, state, :content_types_provided) - p_types = for {type,_fun} <- ct_provided, do: normalize_mtype(type) + p_types = for {type, _fun} <- ct_provided, do: normalize_mtype(type) + case get_header_val(conn, "accept") do nil -> - conn = set_metadata(conn, :'content-type', hd(p_types)) - v3d4(conn, state) + conn = set_metadata(conn, :"content-type", hd(p_types)) + v3d4(conn, state) + _ -> - v3c4(conn, state) + v3c4(conn, state) end end ## "Acceptable media type available?" decision v3c4(conn, state) do {ct_provided, conn, state} = resource_call(conn, state, :content_types_provided) - p_types = for {type,_fun} <- ct_provided, do: normalize_mtype(type) + p_types = for {type, _fun} <- ct_provided, do: normalize_mtype(type) + case choose_media_type(p_types, get_header_val(conn, "accept")) do nil -> - respond(conn, state, 406) + respond(conn, state, 406) + type -> - conn = set_metadata(conn, :'content-type', type) - v3d4(conn, state) + conn = set_metadata(conn, :"content-type", type) + v3d4(conn, state) end end @@ -209,6 +235,7 @@ defmodule Ewebmachine.Core do ## "Acceptable Language available? %% WMACH-46 (do this as proper conneg)" decision v3d5(conn, state) do {reply, conn, state} = resource_call(conn, state, :language_available) + if reply do v3e5(conn, state) else @@ -220,12 +247,15 @@ defmodule Ewebmachine.Core do decision v3e5(conn, state) do case get_header_val(conn, "accept-charset") do nil -> - {charset, conn, state} = choose_charset(conn, state, "*") - case charset do - nil -> respond(conn, state, 406); - _ -> v3f6(conn, state) - end - _ -> v3e6(conn, state) + {charset, conn, state} = choose_charset(conn, state, "*") + + case charset do + nil -> respond(conn, state, 406) + _ -> v3f6(conn, state) + end + + _ -> + v3e6(conn, state) end end @@ -233,27 +263,32 @@ defmodule Ewebmachine.Core do decision v3e6(conn, state) do accept = get_header_val(conn, "accept-charset") {charset, conn, state} = choose_charset(conn, state, accept) + case charset do - nil -> respond(conn, state, 406); - _ -> v3f6(conn, state) + nil -> respond(conn, state, 406) + _ -> v3f6(conn, state) end end ## Accept-Encoding exists? ## also, set content-type header here, now that charset is chosen) decision v3f6(conn, state) do - {type, subtype, params} = get_metadata(conn, :'content-type') - char = get_metadata(conn, :'chosen-charset') - params = char && Map.put(params, :charset, char) || params - conn = set_resp_header(conn, "content-type", format_mtype({type,subtype,params})) + {type, subtype, params} = get_metadata(conn, :"content-type") + char = get_metadata(conn, :"chosen-charset") + params = (char && Map.put(params, :charset, char)) || params + conn = set_resp_header(conn, "content-type", format_mtype({type, subtype, params})) + case get_header_val(conn, "accept-encoding") do nil -> - {encoding, conn, state} = choose_encoding(conn, state, "identity;q=1.0,*;q=0.5") - case encoding do - nil -> respond(conn, state, 406); - _ -> v3g7(conn, state) - end - _ -> v3f7(conn, state) + {encoding, conn, state} = choose_encoding(conn, state, "identity;q=1.0,*;q=0.5") + + case encoding do + nil -> respond(conn, state, 406) + _ -> v3g7(conn, state) + end + + _ -> + v3f7(conn, state) end end @@ -261,9 +296,10 @@ defmodule Ewebmachine.Core do decision v3f7(conn, state) do accept = get_header_val(conn, "accept-encoding") {encoding, conn, state} = choose_encoding(conn, state, accept) + case encoding do - nil -> respond(conn, state, 406); - _ -> v3g7(conn, state) + nil -> respond(conn, state, 406) + _ -> v3g7(conn, state) end end @@ -271,13 +307,16 @@ defmodule Ewebmachine.Core do decision v3g7(conn, state) do ## his is the first place after all conneg, so set Vary here {vars, conn, state} = variances(conn, state) - conn = if length(vars) > 0 do - set_resp_header(conn, "vary", Enum.join(vars, ",")) - else - conn - end + + conn = + if length(vars) > 0 do + set_resp_header(conn, "vary", Enum.join(vars, ",")) + else + conn + end {reply, conn, state} = resource_call(conn, state, :resource_exists) + if reply do v3g8(conn, state) else @@ -307,6 +346,7 @@ defmodule Ewebmachine.Core do decision v3g11(conn, state) do etags = split_quoted_strings(get_header_val(conn, "if-match")) {reply, conn, state} = resource_call(conn, state, :generate_etag) + if reply in etags do v3h10(conn, state) else @@ -335,6 +375,7 @@ defmodule Ewebmachine.Core do ## "I-UM-S is valid date?" decision v3h11(conn, state) do iums_date = get_header_val(conn, "if-unmodified-since") + if convert_request_date(iums_date) == :bad_date do v3i12(conn, state) else @@ -347,6 +388,7 @@ defmodule Ewebmachine.Core do req_date = get_header_val(conn, "if-unmodified-since") req_erl_date = convert_request_date(req_date) {res_erl_date, conn, state} = resource_call(conn, state, :last_modified) + if res_erl_date > req_erl_date do respond(conn, state, 412) else @@ -357,12 +399,14 @@ defmodule Ewebmachine.Core do ## "Moved permanently? (apply PUT to different URI)" decision v3i4(conn, state) do {reply, conn, state} = resource_call(conn, state, :moved_permanently) + case reply do {true, moved_uri} -> - conn = set_resp_header(conn, "location", moved_uri) - respond(conn, state, 301) + conn = set_resp_header(conn, "location", moved_uri) + respond(conn, state, 301) + false -> - v3p3(conn, state) + v3p3(conn, state) end end @@ -395,7 +439,7 @@ defmodule Ewebmachine.Core do ## "GET or HEAD?" decision v3j18(conn, state) do - if method(conn) in ["GET","HEAD"] do + if method(conn) in ["GET", "HEAD"] do respond(conn, state, 304) else respond(conn, state, 412) @@ -406,16 +450,18 @@ defmodule Ewebmachine.Core do decision v3k5(conn, state) do case resource_call(conn, state, :moved_permanently) do {{true, moved_uri}, conn, state} -> - conn = set_resp_header(conn, "location", moved_uri) - respond(conn, state, 301) + conn = set_resp_header(conn, "location", moved_uri) + respond(conn, state, 301) + {false, conn, state} -> - v3l5(conn, state) + v3l5(conn, state) end end ## "Previously existed?" decision v3k7(conn, state) do {reply, conn, state} = resource_call(conn, state, :previously_existed) + if reply do v3k5(conn, state) else @@ -430,6 +476,7 @@ defmodule Ewebmachine.Core do ## provided ETag is a member, we follow the error case out ## via v3j18. {etag, conn, state} = resource_call(conn, state, :generate_etag) + if etag in etags do v3j18(conn, state) else @@ -441,10 +488,11 @@ defmodule Ewebmachine.Core do decision v3l5(conn, state) do case resource_call(conn, state, :moved_temporarily) do {{true, moved_uri}, conn, state} -> - conn = set_resp_header(conn, "location", moved_uri) - respond(conn, state, 307) + conn = set_resp_header(conn, "location", moved_uri) + respond(conn, state, 307) + {false, conn, state} -> - v3m5(conn, state) + v3m5(conn, state) end end @@ -469,6 +517,7 @@ defmodule Ewebmachine.Core do ## "IMS is valid date?" decision v3l14(conn, state) do ims_date = get_header_val(conn, "if-modified-since") + if convert_request_date(ims_date) == :bad_date do v3m16(conn, state) else @@ -478,9 +527,10 @@ defmodule Ewebmachine.Core do ## "IMS > Now?" decision v3l15(conn, state) do - now_date_time = :calendar.universal_time + now_date_time = :calendar.universal_time() req_date = get_header_val(conn, "if-modified-since") req_erl_date = convert_request_date(req_date) + if req_erl_date > now_date_time do v3m16(conn, state) else @@ -493,6 +543,7 @@ defmodule Ewebmachine.Core do req_date = get_header_val(conn, "if-modified-since") req_erl_date = convert_request_date(req_date) {res_erl_date, conn, state} = resource_call(conn, state, :last_modified) + if !res_erl_date or res_erl_date > req_erl_date do v3m16(conn, state) else @@ -512,6 +563,7 @@ defmodule Ewebmachine.Core do ## "Server allows POST to missing resource?" decision v3m7(conn, state) do {amp, conn, state} = resource_call(conn, state, :allow_missing_post) + if amp do v3n11(conn, state) else @@ -543,6 +595,7 @@ defmodule Ewebmachine.Core do decision v3m20b(conn, state) do {reply, conn, state} = resource_call(conn, state, :delete_completed) + if reply do v3o20(conn, state) else @@ -553,6 +606,7 @@ defmodule Ewebmachine.Core do ## "Server allows POST to missing resource?" decision v3n5(conn, state) do {reply, conn, state} = resource_call(conn, state, :allow_missing_post) + if reply do v3n11(conn, state) else @@ -563,30 +617,37 @@ defmodule Ewebmachine.Core do ## "Redirect?" decision v3n11(conn, state) do {reply, conn, state} = resource_call(conn, state, :post_is_create) + if reply do {_, conn, state} = accept_helper(conn, state) {new_path, conn, state} = resource_call(conn, state, :create_path) - if is_nil(new_path), do: raise "post_is_create w/o create_path" - if !is_binary(new_path), do: raise "create_path not a string (#{inspect new_path})" + if is_nil(new_path), do: raise("post_is_create w/o create_path") + if !is_binary(new_path), do: raise("create_path not a string (#{inspect(new_path)})") {base_uri, conn, state} = resource_call(conn, state, :base_uri) - base_uri = if String.last(base_uri) == "/" do - String.slice(base_uri, 0..-2//1) - else - base_uri - end - new_path = if !match?("/"<>_, new_path) do - "#{path(conn)}/#{new_path}" - else - new_path - end - conn = if !get_resp_header(conn, "location") do - set_resp_header(conn, "location", base_uri <> new_path) - else - conn - end + base_uri = + if String.last(base_uri) == "/" do + String.slice(base_uri, 0..-2//1) + else + base_uri + end + + new_path = + if !match?("/" <> _, new_path) do + "#{path(conn)}/#{new_path}" + else + new_path + end + + conn = + if !get_resp_header(conn, "location") do + set_resp_header(conn, "location", base_uri <> new_path) + else + conn + end + redirect_helper(conn, state) else {true, conn, state} = resource_call(conn, state, :process_post) @@ -608,7 +669,8 @@ defmodule Ewebmachine.Core do decision v3o14(conn, state) do case resource_call(conn, state, :is_conflict) do {true, conn, state} -> - respond(conn, state, 409) + respond(conn, state, 409) + {_, conn, state} -> {_, conn, state} = accept_helper(conn, state) v3p11(conn, state) @@ -627,18 +689,18 @@ defmodule Ewebmachine.Core do ## Multiple representations? ## also where body generation for GET and HEAD is done) decision v3o18(conn, state) do - if method(conn) in ["GET","HEAD"] do + if method(conn) in ["GET", "HEAD"] do {etag, conn, state} = resource_call(conn, state, :generate_etag) conn = if etag, do: set_resp_header(conn, "etag", quoted_string(etag)), else: conn - ct = get_metadata(conn, :'content-type') + ct = get_metadata(conn, :"content-type") {lm, conn, state} = resource_call(conn, state, :last_modified) conn = if lm, do: set_resp_header(conn, "last-modified", rfc1123_date(lm)), else: conn {exp, conn, state} = resource_call(conn, state, :expires) conn = if exp, do: set_resp_header(conn, "expires", rfc1123_date(exp)), else: conn {ct_provided, conn, state} = resource_call(conn, state, :content_types_provided) - f = Enum.find_value(ct_provided, fn {t,f} -> normalize_mtype(t) == ct && f end) + f = Enum.find_value(ct_provided, fn {t, f} -> normalize_mtype(t) == ct && f end) {body, conn, state} = resource_call(conn, state, f) {body, conn, state} = encode_body(conn, state, body) conn = set_resp_body(conn, body) @@ -650,6 +712,7 @@ defmodule Ewebmachine.Core do decision v3o18b(conn, state) do {mc, conn, state} = resource_call(conn, state, :multiple_choices) + if mc do respond(conn, state, 300) else @@ -669,6 +732,7 @@ defmodule Ewebmachine.Core do ## "Conflict?" decision v3p3(conn, state) do {reply, conn, state} = resource_call(conn, state, :is_conflict) + if reply do respond(conn, state, 409) else @@ -694,16 +758,20 @@ defmodule Ewebmachine.Core do {enc_provided, conn, state} = resource_call(conn, state, :encodings_provided) accept = if length(ct_provided) < 2, do: [], else: ["Accept"] accept_enc = if length(enc_provided) < 2, do: [], else: ["Accept-Encoding"] - {accept_char, conn, state} = case resource_call(conn, state, :charsets_provided) do - {:no_charset, c, s} -> - {[], c, s} - {charset, c, s} -> - if length(charset) < 2 do - {[], c, s} - else - {["Accept-Charset"], c, s} - end - end + + {accept_char, conn, state} = + case resource_call(conn, state, :charsets_provided) do + {:no_charset, c, s} -> + {[], c, s} + + {charset, c, s} -> + if length(charset) < 2 do + {[], c, s} + else + {["Accept-Charset"], c, s} + end + end + {variances, conn, state} = resource_call(conn, state, :variances) {accept ++ accept_enc ++ accept_char ++ variances, conn, state} end @@ -714,16 +782,17 @@ defmodule Ewebmachine.Core do conn = set_metadata(conn, :mediaparams, h_params) {ct_accepted, conn, state} = resource_call(conn, state, :content_types_accepted) - mtfun = Enum.find_value(ct_accepted, fn {accept,f} -> - fuzzy_mt_match(ct,normalize_mtype(accept)) && f - end) + mtfun = + Enum.find_value(ct_accepted, fn {accept, f} -> + fuzzy_mt_match(ct, normalize_mtype(accept)) && f + end) if mtfun do {_reply, conn, state} = resource_call(conn, state, mtfun) encode_body_if_set(conn, state) else {_, conn, _} = respond(conn, state, 415) - throw {:halt, conn} + throw({:halt, conn}) end end @@ -739,26 +808,42 @@ defmodule Ewebmachine.Core do end def encode_body(conn, state, body) do - chosen_cset = get_metadata(conn, :'chosen-charset') - {charsetter, conn, state} = case resource_call(conn, state, :charsets_provided) do - {:no_charset, c, s} -> - {&(&1), c, s} - {cp, c, s} -> - cs = Enum.find_value(cp, fn {c, f} -> - (to_string(c) == chosen_cset) && f - end) || &(&1) - {cs, c, s} - end - chosen_enc = get_metadata(conn, :'content-encoding') + chosen_cset = get_metadata(conn, :"chosen-charset") + + {charsetter, conn, state} = + case resource_call(conn, state, :charsets_provided) do + {:no_charset, c, s} -> + {& &1, c, s} + + {cp, c, s} -> + cs = + Enum.find_value(cp, &Function.identity/1, fn {c, f} -> + to_string(c) == chosen_cset && f + end) + + {cs, c, s} + end + + chosen_enc = get_metadata(conn, :"content-encoding") {enc_provided, conn, state} = resource_call(conn, state, :encodings_provided) - encoder = Enum.find_value(enc_provided, - fn {enc,f} -> (to_string(enc) == chosen_enc) && f end) || &(&1) - body = case body do - body when is_binary(body) or is_list(body) -> - body |> IO.iodata_to_binary |> charsetter.() |> encoder.() - _-> - body |> Stream.map(&IO.iodata_to_binary/1) |> Stream.map(charsetter) |> Stream.map(encoder) - end + + encoder = + Enum.find_value(enc_provided, &Function.identity/1, fn {enc, f} -> + to_string(enc) == chosen_enc && f + end) + + body = + case body do + body when is_binary(body) or is_list(body) -> + body |> IO.iodata_to_binary() |> charsetter.() |> encoder.() + + _ -> + body + |> Stream.map(&IO.iodata_to_binary/1) + |> Stream.map(charsetter) + |> Stream.map(encoder) + end + {body, conn, state} end @@ -766,28 +851,35 @@ defmodule Ewebmachine.Core do {enc_provided, conn, state} = resource_call(conn, state, :encodings_provided) encs = for {enc, _} <- enc_provided, do: to_string(enc) chosen_enc = choose_encoding(encs, acc_enc_hdr) - conn = if chosen_enc !== "identity" do - set_resp_header(conn, "content-encoding", chosen_enc) - else - conn - end - conn = set_metadata(conn, :'content-encoding', chosen_enc) + + conn = + if chosen_enc !== "identity" do + set_resp_header(conn, "content-encoding", chosen_enc) + else + conn + end + + conn = set_metadata(conn, :"content-encoding", chosen_enc) {chosen_enc, conn, state} end def choose_charset(conn, state, acc_char_hdr) do case resource_call(conn, state, :charsets_provided) do {:no_charset, conn, state} -> - {:no_charset, conn, state} + {:no_charset, conn, state} + {cl, conn, state} -> - charsets = for {cset,_f} <- cl, do: to_string(cset) - charset = choose_charset(charsets, acc_char_hdr) - conn = if (charset) do - set_metadata(conn, :'chosen-charset', charset) - else - conn - end - {charset, conn, state} + charsets = for {cset, _f} <- cl, do: to_string(cset) + charset = choose_charset(charsets, acc_char_hdr) + + conn = + if charset do + set_metadata(conn, :"chosen-charset", charset) + else + conn + end + + {charset, conn, state} end end @@ -796,25 +888,27 @@ defmodule Ewebmachine.Core do if !get_resp_header(conn, "location") do raise "Response had do_redirect but no Location" else - respond(conn, state, 303) + respond(conn, state, 303) end else - v3p11(conn, state) + v3p11(conn, state) end end def respond(conn, state, code) do - {conn, state} = if (code == 304) do - conn = remove_resp_header(conn, "content-type") - {etag, conn, state} = resource_call(conn, state, :generate_etag) - conn = if etag, do: set_resp_header(conn, "etag", quoted_string(etag)), else: conn + {conn, state} = + if code == 304 do + conn = remove_resp_header(conn, "content-type") + {etag, conn, state} = resource_call(conn, state, :generate_etag) + conn = if etag, do: set_resp_header(conn, "etag", quoted_string(etag)), else: conn + + {exp, conn, state} = resource_call(conn, state, :expires) + conn = if exp, do: set_resp_header(conn, "expires", rfc1123_date(exp)), else: conn + {conn, state} + else + {conn, state} + end - {exp, conn, state} = resource_call(conn, state, :expires) - conn = if exp, do: set_resp_header(conn, "expires", rfc1123_date(exp)), else: conn - {conn, state} - else - {conn, state} - end conn = set_response_code(conn, code) resource_call(conn, state, :finish_request) end diff --git a/lib/ewebmachine/core.utils.ex b/lib/ewebmachine/core.utils.ex index b8fa260..0201178 100644 --- a/lib/ewebmachine/core.utils.ex +++ b/lib/ewebmachine/core.utils.ex @@ -3,19 +3,25 @@ defmodule Ewebmachine.Core.Utils do HTTP utility module """ - @type norm_content_type :: {type :: String.t, subtype :: String.t, params :: map} + @type norm_content_type :: {type :: String.t(), subtype :: String.t(), params :: map} @doc """ Convert any content type representation (see spec) into a `norm_content_type` """ - @spec normalize_mtype({type :: String.t, params :: map} | (type :: String.t) | norm_content_type) :: norm_content_type + @spec normalize_mtype( + {type :: String.t(), params :: map} + | (type :: String.t()) + | norm_content_type + ) :: norm_content_type def normalize_mtype({type, params}) do case String.split(type, "/") do [type, subtype] -> {type, subtype, params} _ -> {"application", "octet-stream", %{}} end end - def normalize_mtype({_, _, %{}}=mtype), do: mtype + + def normalize_mtype({_, _, %{}} = mtype), do: mtype + def normalize_mtype(type) do case Plug.Conn.Utils.media_type(to_string(type)) do {:ok, type, subtype, params} -> {type, subtype, params} @@ -29,17 +35,17 @@ defmodule Ewebmachine.Core.Utils do """ @spec fuzzy_mt_match(norm_content_type, norm_content_type) :: boolean def fuzzy_mt_match({h_type, h_subtype, h_params}, {a_type, a_subtype, a_params}) do - (a_type == h_type or a_type == "*" ) and - (a_subtype == h_subtype or a_subtype=="*") and + (a_type == h_type or a_type == "*") and + (a_subtype == h_subtype or a_subtype == "*") and Enum.all?(a_params, fn {k, v} -> h_params[k] == v end) end @doc """ Format a `norm_content_type` into an HTTP content type header """ - @spec format_mtype(norm_content_type) :: String.t + @spec format_mtype(norm_content_type) :: String.t() def format_mtype({type, subtype, params}) do - params = params |> Enum.map(fn {k, v}->"; #{k}=#{v}" end) |> Enum.join() + params = params |> Enum.map(fn {k, v} -> "; #{k}=#{v}" end) |> Enum.join() "#{type}/#{subtype}#{params}" end @@ -49,26 +55,34 @@ defmodule Ewebmachine.Core.Utils do - `accept_header`, the HTTP header `Accept` - `ct_provided`, the list of provided content types """ - @spec choose_media_type([norm_content_type], String.t) :: norm_content_type | nil + @spec choose_media_type([norm_content_type], String.t()) :: norm_content_type | nil def choose_media_type(ct_provided, accept_header) do - accepts = accept_header |> - Plug.Conn.Utils.list() |> - Enum.map(fn - "*" -> "*/*" - e -> e - end) |> - Enum.map(&Plug.Conn.Utils.media_type/1) - accepts = for {:ok, type, subtype, params} <- accepts do - q = case Float.parse(params["q"] || "1") do - {q, _} -> q - _ -> 1 - end - {q, type, subtype, Map.delete(params, "q")} - end |> - Enum.sort() |> Enum.reverse() + accepts = + accept_header + |> Plug.Conn.Utils.list() + |> Enum.map(fn + "*" -> "*/*" + e -> e + end) + |> Enum.map(&Plug.Conn.Utils.media_type/1) + + accepts = + for {:ok, type, subtype, params} <- accepts do + q = + case Float.parse(params["q"] || "1") do + {q, _} -> q + _ -> 1 + end + + {q, type, subtype, Map.delete(params, "q")} + end + |> Enum.sort() + |> Enum.reverse() + Enum.find_value(accepts, fn {_, atype, asubtype, aparams} -> Enum.find(ct_provided, fn {type, subtype, params} -> - (atype=="*" or atype==type) and (asubtype=="*" or asubtype==subtype) and aparams==params + (atype == "*" or atype == type) and (asubtype == "*" or asubtype == subtype) and + aparams == params end) end) end @@ -76,37 +90,46 @@ defmodule Ewebmachine.Core.Utils do @doc """ Remove quotes from HTTP quoted string """ - @spec quoted_string(String.t) :: String.t + @spec quoted_string(String.t()) :: String.t() def quoted_string(value), do: Plug.Conn.Utils.token(value) @doc """ Get the string list from a comma separated list of HTTP quoted strings """ - @spec split_quoted_strings(String.t) :: [String.t] + @spec split_quoted_strings(String.t()) :: [String.t()] def split_quoted_strings(str) do - str |> - Plug.Conn.Utils.list() |> - Enum.map(&Plug.Conn.Utils.token/1) + str + |> Plug.Conn.Utils.list() + |> Enum.map(&Plug.Conn.Utils.token/1) end @doc """ Convert a calendar date to a rfc1123 date string """ - @spec rfc1123_date({{year :: integer, month :: integer, day :: integer}, {h :: integer, min :: integer, sec :: integer}}) :: String.t + @spec rfc1123_date( + {{year :: integer, month :: integer, day :: integer}, + {h :: integer, min :: integer, sec :: integer}} + ) :: String.t() def rfc1123_date({{yyyy, mm, dd}, {hour, min, sec}}) do day_number = :calendar.day_of_the_week({yyyy, mm, dd}) args = [:httpd_util.day(day_number), dd, :httpd_util.month(mm), yyyy, hour, min, sec] - :io_lib.format(~c"~s, ~2.2.0w ~3.s ~4.4.0w ~2.2.0w:~2.2.0w:~2.2.0w GMT", args) |> IO.iodata_to_binary() + + :io_lib.format(~c"~s, ~2.2.0w ~3.s ~4.4.0w ~2.2.0w:~2.2.0w:~2.2.0w GMT", args) + |> IO.iodata_to_binary() end @doc """ Convert rfc1123 or rfc850 to :calendar dates """ - @spec convert_request_date(String.t) :: {{year :: integer, month :: integer, day :: integer}, {h :: integer, min :: integer, sec :: integer}} | :bad_date + @spec convert_request_date(String.t()) :: + {{year :: integer, month :: integer, day :: integer}, + {h :: integer, min :: integer, sec :: integer}} + | :bad_date def convert_request_date(date) do try do :httpd_util.convert_request_date(~c"#{date}") - catch _, _ -> :bad_date + catch + _, _ -> :bad_date end end @@ -116,7 +139,7 @@ defmodule Ewebmachine.Core.Utils do - `acc_enc_hdr`, the HTTP header `Accept-Encoding` - `encs`, the list of supported encoding """ - @spec choose_encoding([String.t], String.t) :: String.t | nil + @spec choose_encoding([String.t()], String.t()) :: String.t() | nil def choose_encoding(encs, acc_enc_hdr) do choose(encs, acc_enc_hdr, "identity") end @@ -127,7 +150,7 @@ defmodule Ewebmachine.Core.Utils do - `acc_char_hdr`, the HTTP header `Accept-Charset` - `charsets`, the list of supported charsets """ - @spec choose_charset([String.t], String.t) :: String.t | nil + @spec choose_charset([String.t()], String.t()) :: String.t() | nil def choose_charset(charsets, acc_char_hdr) do choose(charsets, acc_char_hdr, "utf8") end @@ -135,7 +158,7 @@ defmodule Ewebmachine.Core.Utils do @doc """ Get HTTP status label from HTTP code """ - @spec http_label(code :: integer) :: String.t + @spec http_label(code :: integer) :: String.t() def http_label(100), do: "Continue" def http_label(101), do: "Switching Protocol" def http_label(200), do: "OK" @@ -179,38 +202,38 @@ defmodule Ewebmachine.Core.Utils do def http_label(504), do: "Gateway Timeout" def http_label(505), do: "HTTP Version Not Supported" - ### - ### Priv - ### - alias Ewebmachine.Compat - defp choose(choices, header, default) do + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding#weighted_accept-encoding_values ## sorted set of {prio,value} prios = prioritized_values(header) # determine if default is ok or any is ok if no match default_prio = Enum.find_value(prios, fn {prio, v} -> v == default && prio end) start_prio = Enum.find_value(prios, fn {prio, v} -> v == "*" && prio end) - default_ok = case default_prio do - nil -> start_prio !== +0.0 - +0.0 -> false - _ -> true - end + + default_ok = + case default_prio do + nil -> start_prio !== +0.0 + +0.0 -> false + _ -> true + end + any_ok = start_prio not in [nil, +0.0] # remove choices where prio == 0.0 - {zero_prios, prios} = Compat.Enum.split_with(prios, fn {prio, _} -> prio == +0.0 end) + {zero_prios, prios} = Enum.split_with(prios, fn {prio, _} -> prio == +0.0 end) + choices_to_remove = Enum.map(zero_prios, &elem(&1, 1)) - choices = Enum.filter(choices, &!(String.downcase(&1) in choices_to_remove)) + choices = Enum.filter(choices, &(!(String.downcase(&1) in choices_to_remove))) if choices !== [] do # find first match, if not found and any_ok, then first choice, else if default_ok, take it Enum.find_value(prios, fn {_, val} -> Enum.find(choices, &(val == String.downcase(&1))) - end) - || (any_ok && hd(choices) - || (default_ok && Enum.find(choices, &(&1 == default)) - || nil)) + end) || + ((any_ok && hd(choices)) || + ((default_ok && Enum.find(choices, &(&1 == default))) || + nil)) else # No proposed choice is acceptable by client nil @@ -219,19 +242,23 @@ defmodule Ewebmachine.Core.Utils do defp prioritized_values(header) do header - |> Plug.Conn.Utils.list - |> Enum.map(fn e-> - {q,v} = case String.split(e,~r"\s;\s", parts: 2) do - [value,params] -> - case Float.parse(Plug.Conn.Utils.params(params)["q"] || "1.0") do - {q,_}->{q,value} - :error -> {1.0,value} - end - [value] -> {1.0,value} + |> Plug.Conn.Utils.list() + |> Enum.map(fn e -> + {q, v} = + case String.split(e, ~r"\s;\s", parts: 2) do + [value, params] -> + case Float.parse(Plug.Conn.Utils.params(params)["q"] || "1.0") do + {q, _} -> {q, value} + :error -> {1.0, value} + end + + [value] -> + {1.0, value} end - {q,String.downcase(v)} - end) - |> Enum.sort - |> Enum.reverse + + {q, String.downcase(v)} + end) + |> Enum.sort() + |> Enum.reverse() end end diff --git a/lib/ewebmachine/events.ex b/lib/ewebmachine/events.ex index 90fe854..e2c8343 100644 --- a/lib/ewebmachine/events.ex +++ b/lib/ewebmachine/events.ex @@ -1,4 +1,6 @@ defmodule Ewebmachine.Events do + @moduledoc false + def child_spec(_) do Registry.child_spec(keys: :duplicate, name: __MODULE__) end @@ -6,21 +8,35 @@ defmodule Ewebmachine.Events do @dispatch_key :events def dispatch(log) do Registry.dispatch(__MODULE__, @dispatch_key, fn entries -> - for {pid, nil} <- entries, do: send(pid,{:log,log}) + for {pid, nil} <- entries, do: send(pid, {:log, log}) end) end import Plug.Conn + def stream_chunks(conn) do - conn = conn |> - put_resp_header("content-type", "text/event-stream") |> - send_chunked(200) - {:ok, _} = Registry.register(__MODULE__,@dispatch_key,nil) - conn = Stream.repeatedly(fn-> receive do {:log,log}-> log end end) + conn = + conn + |> put_resp_header("content-type", "text/event-stream") + |> send_chunked(200) + + {:ok, _} = Registry.register(__MODULE__, @dispatch_key, nil) + + conn = + Stream.repeatedly(fn -> + receive do + {:log, log} -> log + end + end) |> Enum.reduce_while(conn, fn log, conn -> io = "event: new_query\ndata: #{log}\n\n" - case chunk(conn,io) do {:ok,conn}->{:cont,conn};{:error,:closed}->{:halt,conn} end + + case chunk(conn, io) do + {:ok, conn} -> {:cont, conn} + {:error, :closed} -> {:halt, conn} + end end) + halt(conn) end end diff --git a/lib/ewebmachine/handlers.ex b/lib/ewebmachine/handlers.ex index 007cca4..182221d 100644 --- a/lib/ewebmachine/handlers.ex +++ b/lib/ewebmachine/handlers.ex @@ -1,98 +1,61 @@ defmodule Ewebmachine.Handlers do - @type conn :: Plug.Conn.t - @type state :: any - @type halt :: {:halt, 200..599} - @moduledoc """ - Implement the functions described below to make decisions in the - [HTTP decision tree](./assets/http_diagram.png) : - - - `service_available/2` - - `resource_exists/2` - - `is_authorized/2` - - `forbidden/2` - - `allow_missing_post/2` - - `malformed_request/2` - - `uri_too_long/2` - - `known_content_type/2` - - `valid_content_headers/2` - - `valid_entity_length/2` - - `options/2` - - `allowed_methods/2` - - `known_methods/2` - - `content_types_provided/2` - - `content_types_accepted/2` - - `delete_resource/2` - - `delete_completed/2` - - `post_is_create/2` - - `create_path/2` - - `base_uri/2` - - `process_post/2` - - `language_available/2` - - `charsets_provided/2` - - `encodings_provided/2` - - `variances/2` - - `is_conflict/2` - - `multiple_choices/2` - - `previously_existed/2` - - `moved_permanently/2` - - `moved_temporarily/2` - - `last_modified/2` - - `expires/2` - - `generate_etag/2` - - `validate_content_checksum/2` - - `ping/2` - - Body-producing function, see `to_html/2` (but any function name can be - used, as referenced by `content_types_provided/2` - - POST/PUT processing function, see `from_json/2` (but any function name can be - used, as referenced by `content_types_accepted/2` - - All the handlers have the same signature : + Provides the default implementation for each resource handler. + An handler is a function with the following signature: + ```elixir + @spec handler(conn, state) :: {response :: any | {:halt, 200..599}, conn, state} + when conn: Plug.Conn.t(), state: var() ``` - (conn :: Plug.Conn.t,state :: any)->{response :: any | {:halt,200..599},conn :: Plug.Conn.t, state :: any} - ``` - where every handler implementation : + An handler: + * can update the `conn` + + > ##### Halting the connection {: .top} + > + > Halting the `conn` will not stop the execution of the HTTP decision tree + > of Ewebmachine. - - can change or halt the plug `conn` passed as argument - - can change the user state object passed from on handler to another in its arguments - - returns something which will make decision in the HTTP decision tree (see - documentation of functions in this module to see expected results and effects) - - can return `{:halt,200..599}` to end the ewebmachine automate execution, - but do not `halt` the `conn`, so the plug pipeline can continue. + * can change the state - So each handler implementation is actually a "plug" returning a response giving - information allowing to make the good response code and path in the HTTP - specification. + > #### Ewebmachine and `state` {: .info} + > Ewebmachine never modifies the `state` is only pass it between handlers. - ## Usage ## + * should return a response which will influence the HTTP decision tree. If an + handler returns `{:halt, 200.599}`, the execution of the HTTP decision tree + is immediatly stopped (the `conn` is not halted though). - The following modules will help you to construct these handlers and use them : + Each handler has its own specific response type, see each handler below. - - `Ewebmachine.Builder.Handlers` gives you macros and helpers to define the - handlers and automatically create the plug to add them to your `conn.private[:resource_handlers]` - - `Ewebmachine.Plug.Run` run the HTTP decision tree executing the handler - implementations described in its `conn.private[:resource_handlers]`. The - initial user `state` is taken in `conn.private[:machine_init]` + Handler functions are usually defined inside a + `Ewebmachine.Builder.Resources.resource/2` via the macros defined by the + `Ewebmachine.Builder.Handlers` module. + A set of common handlers can also be defined see the documentation of + `Ewebmachine.Builder.Handlers`. """ + @type conn :: Plug.Conn.t() + @type state :: any + @type halt :: {:halt, 200..599} + @doc """ Returning non-true values will result in `503 Service Unavailable`. Default: `true` """ - @spec service_available(conn,state) :: {boolean | halt,conn,state} - def service_available(conn,state), do: {true,conn,state} + @doc group: :handler + @spec service_available(conn, state) :: {boolean | halt, conn, state} + def service_available(conn, state), do: {true, conn, state} @doc """ Returning non-true values will result in `404 Not Found`. Default: `true` """ - @spec resource_exists(conn,state) :: {boolean | halt,conn,state} - def resource_exists(conn,state), do: {true,conn,state} + @doc group: :handler + @spec resource_exists(conn, state) :: {boolean | halt, conn, state} + def resource_exists(conn, state), do: {true, conn, state} @doc """ If this returns anything other than `true`, the response will be @@ -102,78 +65,81 @@ defmodule Ewebmachine.Handlers do Default: `true` """ - @spec is_authorized(conn,state) :: {boolean | halt,conn,state} - def is_authorized(conn,state), do: {true,conn,state} + @doc group: :handler + @spec is_authorized(conn, state) :: {boolean | halt, conn, state} + def is_authorized(conn, state), do: {true, conn, state} @doc """ Returning true will result in 403 Forbidden. Default: `false` """ - @spec forbidden(conn,state) :: {boolean | halt,conn,state} - def forbidden(conn,state), do: {false,conn,state} + @doc group: :handler + @spec forbidden(conn, state) :: {boolean | halt, conn, state} + def forbidden(conn, state), do: {false, conn, state} @doc """ If the resource accepts POST requests to nonexistent resources, then this should return `true`. Default: `false` """ - @spec allow_missing_post(conn,state) :: {boolean | halt,conn,state} - def allow_missing_post(conn,state), do: {false,conn,state} + @doc group: :handler + @spec allow_missing_post(conn, state) :: {boolean | halt, conn, state} + def allow_missing_post(conn, state), do: {false, conn, state} @doc """ Returning true will result in 400 Bad Request. Default: `false` """ - @spec malformed_request(conn,state) :: {boolean | halt,conn,state} - def malformed_request(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec malformed_request(conn, state) :: {boolean | halt, conn, state} + def malformed_request(conn, state), do: {false, conn, state} @doc """ Returning true will result in 414 Request-URI Too Long. Default: `false` """ - @spec uri_too_long(conn,state) :: {boolean | halt,conn,state} - def uri_too_long(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec uri_too_long(conn, state) :: {boolean | halt, conn, state} + def uri_too_long(conn, state), do: {false, conn, state} @doc """ Returning false will result in 415 Unsupported Media Type. Default: `true` """ - @spec known_content_type(conn,state) :: {boolean | halt,conn,state} - def known_content_type(conn,state), do: - {true,conn,state} + @doc group: :handler + @spec known_content_type(conn, state) :: {boolean | halt, conn, state} + def known_content_type(conn, state), do: {true, conn, state} @doc """ Returning false will result in 501 Not Implemented. Default: `true` """ - @spec valid_content_headers(conn,state) :: {boolean | halt,conn,state} - def valid_content_headers(conn,state), do: - {true,conn,state} + @doc group: :handler + @spec valid_content_headers(conn, state) :: {boolean | halt, conn, state} + def valid_content_headers(conn, state), do: {true, conn, state} @doc """ Returning false will result in 413 Request Entity Too Large. Default: `false` """ - @spec valid_entity_length(conn,state) :: {boolean | halt,conn,state} - def valid_entity_length(conn,state), do: - {true,conn,state} + @doc group: :handler + @spec valid_entity_length(conn, state) :: {boolean | halt, conn, state} + def valid_entity_length(conn, state), do: {true, conn, state} @doc """ If the OPTIONS method is supported and is used, the return value of this function is expected to be a list of pairs representing header names and values that should appear in the response. """ - @spec options(conn,state) :: {[{String.t,String.t}] | halt,conn,state} - def options(conn,state), do: - {[],conn,state} + @doc group: :handler + @spec options(conn, state) :: {[{String.t(), String.t()}] | halt, conn, state} + def options(conn, state), do: {[], conn, state} @doc """ If a Method not in this list is requested, then a 405 Method Not @@ -181,116 +147,142 @@ defmodule Ewebmachine.Handlers do Default: `["GET", "HEAD"]` """ - @spec allowed_methods(conn,state) :: {[String.t] | halt,conn,state} - def allowed_methods(conn,state), do: - {["GET", "HEAD"],conn,state} + @doc group: :handler + @spec allowed_methods(conn, state) :: {[String.t()] | halt, conn, state} + def allowed_methods(conn, state), do: {["GET", "HEAD"], conn, state} @doc """ Override the known methods accepted by your automate Default: `["GET", "HEAD", "POST", "PUT", "DELETE", "TRACE", "CONNECT", "OPTIONS"]` """ - @spec known_methods(conn,state) :: {[String.t] | halt,conn,state} - def known_methods(conn,state), do: - {["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS"],conn,state} + @doc group: :handler + @spec known_methods(conn, state) :: {[String.t()] | halt, conn, state} + def known_methods(conn, state) do + {["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS"], conn, + state} + end @doc """ - This should return a key value tuple enumerable where the key is - the content-type format and the value is an atom naming the - function which can provide a resource representation in that media - type. Content negotiation is driven by this return value. For - example, if a client request includes an Accept header with a value - that does not appear as a first element in any of the return - tuples, then a 406 Not Acceptable will be - sent. + This should return a `{key, value}` tuple enumerable where the key is the + content-type format and the value is an atom naming the function which can + provide a resource representation in that media type. Content negotiation is + driven by this return value. For example, if a client request includes an + `Accept` header with a value that does not appear as a first element in any + of the return tuples, then a 406 Not Acceptable will be sent. + + ## Body producing function + + Body producing function should have the following signature + ```elixir + @spec handler(conn, state) :: {iodata() | binary() | Enumerable.t(Plug.Conn.body()) | halt(), conn, state} + ``` + + If the returned body is an `t:Enumerable.t/1` other than a list, the response + will be streamed by the `Ewebmachine.Plug.Send` plug, therefore the + enumarable's element needs to be `t:Plug.Conn.body/0`. Default: `[{"text/html", to_html}]` """ - @spec content_types_provided(conn,state) :: {[{String.Chars.t,atom}] | Enum.t | halt,conn,state} - def content_types_provided(conn,state), do: - {[{"text/html", :to_html}],conn,state} + @doc group: :handler + @spec content_types_provided(conn, state) :: + {[{String.t(), body_producing_fun}] | halt, conn, state} + when body_producing_fun: atom() + def content_types_provided(conn, state), do: {[{"text/html", :to_html}], conn, state} @doc """ - This is used similarly to content_types_provided, except that it is - for incoming resource representations -- for example, PUT requests. - Handler functions usually want to use `Plug.read_body(conn)` to - access the incoming request body. - + This is used similarly to `content_types_provided/2`, except that it is + for incoming resource representations. For instance, `PUT` requests. + + ## POST/PUT processing function + + Functions set inside the `content_types_accepted` handler must have the + following signature: + ```elixir + @spec handler(conn, state) :: {any() | halt, conn, state} + when conn: Plug.Conn.t(), state: var() + ``` + They will be called when the request is a `PUT` or when the request is a + `POST` and `post_is_create` returns `true`. + Default: `[]` """ - @spec content_types_accepted(conn,state) :: {[{String.Chars.t,atom}] | Enum.t | halt,conn,state} - def content_types_accepted(conn,state), do: - {[],conn,state} + @doc group: :handler + @spec content_types_accepted(conn, state) :: + {[{String.t(), body_processing_fun}] | halt, conn, state} + when body_processing_fun: atom() + def content_types_accepted(conn, state), do: {[], conn, state} @doc """ This is called when a DELETE request should be enacted, and should return `true` if the deletion succeeded. """ - @spec delete_resource(conn,state) :: {boolean | halt,conn,state} - def delete_resource(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec delete_resource(conn, state) :: {boolean | halt, conn, state} + def delete_resource(conn, state), do: {false, conn, state} @doc """ - This is only called after a successful `delete_resource` call, and + This is only called after a successful `delete_resource/2` call, and should return `false` if the deletion was accepted but cannot yet be guaranteed to have finished. """ - @spec delete_completed(conn,state) :: {boolean | halt,conn,state} - def delete_completed(conn,state), do: - {true,conn,state} + @doc group: :handler + @spec delete_completed(conn, state) :: {boolean | halt, conn, state} + def delete_completed(conn, state), do: {true, conn, state} @doc """ If POST requests should be treated as a request to put content into a (potentially new) resource as opposed to being a generic - submission for processing, then this function should return true. - If it does return `true`, then `create_path` will be called and the + submission for processing, then this function should return `true`. + If it does return `true`, then `create_path/2` will be called and the rest of the request will be treated much like a PUT to the Path entry returned by that call. Default: `false` """ - @spec post_is_create(conn,state) :: {boolean | halt,conn,state} - def post_is_create(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec post_is_create(conn, state) :: {boolean | halt, conn, state} + def post_is_create(conn, state), do: {false, conn, state} @doc """ - This will be called on a POST request if `post_is_create` returns - true. It is an error for this function not to produce a Path if - post_is_create returns true. The Path returned should be a valid + This will be called on a POST request if `post_is_create/2` returns + `true`. It is an error for this function not to produce a Path if + `post_is_create/2` returns `true`. The Path returned should be a valid URI part. """ - @spec create_path(conn,state) :: {nil | String.t | halt,conn,state} - def create_path(conn,state), do: - {nil,conn,state} + @doc group: :handler + @spec create_path(conn, state) :: {nil | String.t() | halt, conn, state} + def create_path(conn, state), do: {nil, conn, state} @doc """ The base URI used in the location header on resource creation (when - `post_is_create` is `true`), will be prepended to the `create_path` + `post_is_create/2` is `true`), will be prepended to the `create_path/2` """ - @spec base_uri(conn,state) :: {String.t | halt,conn,state} - def base_uri(conn,state), do: - {"#{conn.scheme}://#{conn.host}#{port_suffix(conn.scheme,conn.port)}",conn,state} + @doc group: :handler + @spec base_uri(conn, state) :: {String.t() | halt, conn, state} + def base_uri(conn, state), + do: {"#{conn.scheme}://#{conn.host}#{port_suffix(conn.scheme, conn.port)}", conn, state} - defp port_suffix(:http,80), do: "" - defp port_suffix(:https,443), do: "" - defp port_suffix(_,port), do: ":#{port}" + defp port_suffix(:http, 80), do: "" + defp port_suffix(:https, 443), do: "" + defp port_suffix(_, port), do: ":#{port}" @doc """ - If `post_is_create` returns `false`, then this will be called to + If `post_is_create/2` returns `false`, then this will be called to process any POST requests. If it succeeds, it should return `true`. """ - @spec process_post(conn,state) :: {boolean | halt,conn,state} - def process_post(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec process_post(conn, state) :: {boolean | halt, conn, state} + def process_post(conn, state), do: {false, conn, state} @doc """ return false if language in `Plug.Conn.get_resp_header(conn,"accept-language")` is not available. """ - @spec language_available(conn,state) :: {boolean | halt,conn,state} - def language_available(conn,state), do: - {true,conn,state} + @doc group: :handler + @spec language_available(conn, state) :: {boolean | halt, conn, state} + def language_available(conn, state), do: {true, conn, state} @doc """ If this is anything other than the atom `:no_charset`, it must be a @@ -300,31 +292,33 @@ defmodule Ewebmachine.Handlers do Default: `:no_charset` """ - @spec charsets_provided(conn,state) :: {:no_charset | [{String.Chars.t,(binary->binary)}] | Enum.t | halt,conn,state} - def charsets_provided(conn,state), do: - {:no_charset,conn,state} + @doc group: :handler + @spec charsets_provided(conn, state) :: + {:no_charset | [{String.Chars.t(), (binary -> binary)}] | Enum.t() | halt, conn, state} + def charsets_provided(conn, state), do: {:no_charset, conn, state} ## this atom causes charset-negotation to short-circuit ## the default setting is needed for non-charset responses such as image/png ## an example of how one might do actual negotiation ## [{"iso-8859-1", fun(X) -> X end}, {"utf-8", make_utf8}]; @doc """ - This must be a `{key,value}` Enumerable where `key` is a valid + This must be a `{key, value}` Enumerable where `key` is a valid content encoding and `value` is a callable function in the resource which will be called on the produced body in a GET and ensure that it is so encoded. One useful setting is to have the function check on method, and on GET requests return: - ``` + ```elixir [identity: &(&1), gzip: &:zlib.gzip/1] ``` as this is all that is needed to support gzip content encoding. Default: `[{"identity", fn X-> X end}]` """ - @spec encodings_provided(conn,state) :: {[{String.Chars.t,(binary->binary)}] | Enum.t | halt,conn,state} - def encodings_provided(conn,state), do: - {[{"identity", &(&1)}],conn,state} + @doc group: :handler + @spec encodings_provided(conn, state) :: + {[{String.Chars.t(), (binary -> binary)}] | Enum.t() | halt, conn, state} + def encodings_provided(conn, state), do: {[{"identity", & &1}], conn, state} # this is handy for auto-gzip of GET-only resources: # [{"identity", fun(X) -> X end}, {"gzip", fun(X) -> zlib:gzip(X) end}]; @@ -338,18 +332,18 @@ defmodule Ewebmachine.Handlers do Default : `[]` """ - @spec variances(conn,state) :: {[String.t] | halt,conn,state} - def variances(conn,state), do: - {[],conn,state} + @doc group: :handler + @spec variances(conn, state) :: {[String.t()] | halt, conn, state} + def variances(conn, state), do: {[], conn, state} @doc """ If this returns `true`, the client will receive a 409 Conflict. Default : `false` """ - @spec is_conflict(conn,state) :: {boolean | halt,conn,state} - def is_conflict(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec is_conflict(conn, state) :: {boolean | halt, conn, state} + def is_conflict(conn, state), do: {false, conn, state} @doc """ If this returns `true`, then it is assumed that multiple @@ -359,20 +353,20 @@ defmodule Ewebmachine.Handlers do Default: `false` """ - @spec multiple_choices(conn,state) :: {boolean | halt,conn,state} - def multiple_choices(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec multiple_choices(conn, state) :: {boolean | halt, conn, state} + def multiple_choices(conn, state), do: {false, conn, state} @doc """ - If this returns `true`, the `moved_permanently` and `moved_temporarily` + If this returns `true`, the `moved_permanently/2` and `moved_temporarily/2` callbacks will be invoked to determine whether the response should be `301 Moved Permanently`, `307 Temporary Redirect`, or `410 Gone`. Default: `false` """ - @spec previously_existed(conn,state) :: {boolean | halt,conn,state} - def previously_existed(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec previously_existed(conn, state) :: {boolean | halt, conn, state} + def previously_existed(conn, state), do: {false, conn, state} @doc """ If this returns `{true, uri}`, the client will receive a `301 Moved @@ -380,9 +374,9 @@ defmodule Ewebmachine.Handlers do Default: `false` """ - @spec moved_permanently(conn,state) :: {boolean | halt,conn,state} - def moved_permanently(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec moved_permanently(conn, state) :: {boolean | halt, conn, state} + def moved_permanently(conn, state), do: {false, conn, state} @doc """ If this returns `{true, uri}`, the client will receive a `307 @@ -390,9 +384,9 @@ defmodule Ewebmachine.Handlers do Default: `false` """ - @spec moved_temporarily(conn,state) :: {boolean | halt,conn,state} - def moved_temporarily(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec moved_temporarily(conn, state) :: {boolean | halt, conn, state} + def moved_temporarily(conn, state), do: {false, conn, state} @doc """ If this returns a `datetime()` (`{{day,month,year},{h,m,s}}`, it @@ -401,18 +395,26 @@ defmodule Ewebmachine.Handlers do Default: `nil` """ - @spec last_modified(conn,state) :: {nil | {{day::integer,month::integer,year::integer},{hour::integer,min::integer,sec::integer}} | halt,conn,state} - def last_modified(conn,state), do: - {nil,conn,state} + @doc group: :handler + @spec last_modified(conn, state) :: + {nil + | {{day :: integer, month :: integer, year :: integer}, + {hour :: integer, min :: integer, sec :: integer}} + | halt, conn, state} + def last_modified(conn, state), do: {nil, conn, state} @doc """ If not `nil`, set the expires header Default: `nil` """ - @spec expires(conn,state) :: {nil | {{day::integer,month::integer,year::integer},{hour::integer,min::integer,sec::integer}} | halt,conn,state} - def expires(conn,state), do: - {nil,conn,state} + @doc group: :handler + @spec expires(conn, state) :: + {nil + | {{day :: integer, month :: integer, year :: integer}, + {hour :: integer, min :: integer, sec :: integer}} + | halt, conn, state} + def expires(conn, state), do: {nil, conn, state} @doc """ If not `nil`, it will be used for the ETag header and @@ -420,55 +422,51 @@ defmodule Ewebmachine.Handlers do Default: `nil` """ - @spec generate_etag(conn,state) :: {nil | binary | halt,conn,state} - def generate_etag(conn,state), do: - {nil,conn,state} + @doc group: :handler + @spec generate_etag(conn, state) :: {nil | binary | halt, conn, state} + def generate_etag(conn, state), do: {nil, conn, state} @doc """ - if `content-md5` header exists: + if `content-md5` header exists: - If `:not_validated`, test if input body validate `content-md5`, - if return `false`, then return a bad request Useful if content-md5 validation does not imply only raw md5 hash """ - @spec validate_content_checksum(conn,state) :: {:not_validated | boolean | halt,conn,state} - def validate_content_checksum(conn,state), do: - {:not_validated,conn,state} + @doc group: :handler + @spec validate_content_checksum(conn, state) :: {:not_validated | boolean | halt, conn, state} + def validate_content_checksum(conn, state), do: {:not_validated, conn, state} - @doc """ - Must be present and returning `pong` to prove that handlers are - well linked to the automate - """ - @spec ping(conn,state) :: {:pang | :pong | halt,conn,state} - def ping(conn,state), do: - {:pang,conn,state} + if Mix.env() == :test do + @doc false + @spec ping(conn, state) :: {:pang | :pong, conn, state} + def ping(conn, state), do: {:pang, conn, state} + end @doc """ Last handler, always called. Response is ignored except if it is a `halt`. """ - @spec finish_request(conn,state) :: {any | halt, conn, state} - def finish_request(conn,state), do: - {false,conn,state} + @doc group: :handler + @spec finish_request(conn, state) :: {any | halt, conn, state} + def finish_request(conn, state), do: {false, conn, state} @doc """ Example body-producing function, function atom name must be referenced in `content_types_provided/2`. - + - If the result is an `Enumerable` of `iodata`, then the HTTP response will be a chunk encoding response where each chunk on element of the enumeration. - If the result is an iodata, then it is used as the HTTP response body """ - @spec to_html(conn,state) :: {iodata | Enum.t | halt,conn,state} - def to_html(conn,state), do: - {"

Hello World

",conn,state} + @spec to_html(conn, state) :: {iodata | Enum.t() | halt, conn, state} + def to_html(conn, state), do: {"", conn, state} @doc """ Example POST/PUT processing function, function atom name must be referenced in `content_types_accepted/2`. It will be called when the request is `PUT` or when the - request is `POST` and `post_is_create` returns true. + request is `POST` and `post_is_create/2` returns true. """ - @spec from_json(conn,state) :: {true | halt,conn,state} - def from_json(conn,state), do: - {true,conn,state} + @spec from_json(conn, state) :: {true | halt, conn, state} + def from_json(conn, state), do: {true, conn, state} end diff --git a/lib/ewebmachine/log.ex b/lib/ewebmachine/log.ex index 22d9399..624ef20 100644 --- a/lib/ewebmachine/log.ex +++ b/lib/ewebmachine/log.ex @@ -3,63 +3,112 @@ defmodule Ewebmachine.Log do use GenServer @moduledoc false - def init([]), do: - (:ets.new(:logs, [:ordered_set, :named_table]); {:ok,[]}) - def handle_cast(conn,_), do: - (:ets.insert(:logs,{conn.private[:machine_log],conn}); {:noreply,[]}) + def init([]) do + :ets.new(:logs, [:ordered_set, :named_table]) + {:ok, []} + end + + def handle_cast(conn, _) do + :ets.insert(:logs, {conn.private[:machine_log], conn}) + {:noreply, []} + end # Public API, self describing - def start_link(_), do: - GenServer.start_link(__MODULE__,[], name: __MODULE__) - def put(conn), do: - GenServer.cast(__MODULE__,conn) - def list do # fold only needed file for log listing for perfs - :ets.foldl(fn {_,%{method: m, request_path: path, path_info: pi, private: %{machine_log: l, machine_init_at: i}}},acc-> - [%Conn{method: m,request_path: path, path_info: pi, private: %{machine_log: l,machine_init_at: i}}|acc] - end,[],:logs) + def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + def put(conn), do: GenServer.cast(__MODULE__, conn) + # fold only needed file for log listing for perfs + def list do + :ets.foldl( + fn {_, + %{ + method: m, + request_path: path, + path_info: pi, + private: %{machine_log: l, machine_init_at: i} + }}, + acc -> + [ + %Conn{ + method: m, + request_path: path, + path_info: pi, + private: %{machine_log: l, machine_init_at: i} + } + | acc + ] + end, + [], + :logs + ) + end + + def get(id) do + case :ets.lookup(:logs, id) do + [{_, conn}] -> conn + _ -> nil + end end - def get(id), do: - (case :ets.lookup(:logs,id) do [{_,conn}]->conn; _->nil end) - def id, do: - (make_ref() |> :erlang.term_to_binary |> Base.url_encode64) + + def id, do: make_ref() |> :erlang.term_to_binary() |> Base.url_encode64() # Conn modifiers called by automate during run def debug_init(conn) do if conn.private[:machine_debug] do conn - |> Conn.put_private(:machine_log,id()) - |> Conn.put_private(:machine_init_at,:erlang.timestamp) - |> Conn.put_private(:machine_decisions,[]) - |> Conn.put_private(:machine_calls,[]) - else conn end + |> Conn.put_private(:machine_log, id()) + |> Conn.put_private(:machine_init_at, :erlang.timestamp()) + |> Conn.put_private(:machine_decisions, []) + |> Conn.put_private(:machine_calls, []) + else + conn + end end - def debug_call(conn,module,function,[in_conn,in_state],{resp,out_conn,out_state}) do + + def debug_call(conn, module, function, [in_conn, in_state], {resp, out_conn, out_state}) do if conn.private[:machine_log] !== nil and module !== Ewebmachine.Handlers do - Conn.put_private(conn,:machine_calls, - [{module,function,[%{in_conn|private: %{}},in_state], - {resp,%{out_conn|private: %{}},out_state}} - |conn.private.machine_calls]) - else conn end + Conn.put_private(conn, :machine_calls, [ + {module, function, [%{in_conn | private: %{}}, in_state], + {resp, %{out_conn | private: %{}}, out_state}} + | conn.private.machine_calls + ]) + else + conn + end end + def debug_enddecision(conn) do if conn.private[:machine_log] do case conn.private.machine_decisions do - [{decision,_}|rest] -> - conn - |> Conn.put_private(:machine_decisions,[{decision,Enum.reverse(conn.private.machine_calls)}|rest]) - |> Conn.put_private(:machine_calls,[]) - _->conn + [{decision, _} | rest] -> + conn + |> Conn.put_private(:machine_decisions, [ + {decision, Enum.reverse(conn.private.machine_calls)} | rest + ]) + |> Conn.put_private(:machine_calls, []) + + _ -> + conn end - else conn end + else + conn + end end - def debug_decision(conn,decision) do + + def debug_decision(conn, decision) do if conn.private[:machine_log] do - case Regex.run(~r/^v[0-9]([a-z]*[0-9]*)$/,to_string(decision)) do - [_,decision]-> + case Regex.run(~r/^v[0-9]([a-z]*[0-9]*)$/, to_string(decision)) do + [_, decision] -> conn = debug_enddecision(conn) - Conn.put_private(conn,:machine_decisions,[{decision,[]}|conn.private.machine_decisions]) - _->conn + + Conn.put_private(conn, :machine_decisions, [ + {decision, []} | conn.private.machine_decisions + ]) + + _ -> + conn end - else conn end + else + conn + end end end diff --git a/lib/ewebmachine/plug.debug.ex b/lib/ewebmachine/plug.debug.ex index 02e1414..7abad16 100644 --- a/lib/ewebmachine/plug.debug.ex +++ b/lib/ewebmachine/plug.debug.ex @@ -1,59 +1,55 @@ defmodule Ewebmachine.Plug.Debug do @moduledoc ~S""" - A ewebmachine debug UI at `/wm_debug` + A plug providing a debug UI for the decision tree available at `/wm_debug`. Add it before `Ewebmachine.Plug.Run` in your plug pipeline when you want debugging facilities. + ```elixir + if Mix.env() == :dev, do: plug Ewebmachine.Plug.Debug ``` - if Mix.env == :dev, do: plug Ewebmachine.Plug.Debug - ``` - Then go to `http://youhost:yourport/wm_debug`, you will see the - request list since the launch of your server. Click on any to get - the ewebmachine debugging UI. The list will be automatically - updated on new query. - - The ewebmachine debugging UI - - - shows you the HTTP decision path taken by the request to the response. Every - - the red decisions are the one where decisions differs from the - default one because of a handler implementation : - - click on them, then select any handler available in the right - tab to see the `conn`, `state` inputs of the handler and the - response. - - The response and request right tab shows you the request and - result at the end of the ewebmachine run. - - click on "auto redirect on new query" and at every request, your - browser will navigate to the debugging UI of the new request (you - can still use back/next to navigate through requests) + Then go to `http://:/wm_debug`, you will see the request list + since the launch of your server. Click on any to get the Ewebmachine + debugging UI. The list will be automatically updated on new query. + + The Ewebmachine debugging UI shows you the path taken by the request through + the decision tree. Decisions where user defined handlers are called are + highlighted in red. Clicking on decision will open a panel with three tabs: + * `Q`: contains the received http request + * `R`: contains the sent http response + * `D`: displays the `conn` and the `state` before and after the handler + call. The handler's response is also displayed. + + The `auto redirect on new query` will at every request redirect your browser + to the new request debugging UI. ![Debug UI example](./assets/debug_ui.png) """ use Plug.Router - + alias Plug.Conn alias Ewebmachine.Log - + plug Plug.Static, at: "/wm_debug/static", from: :ewebmachine plug :match plug :dispatch require EEx - EEx.function_from_file :defp, :render_logs, "templates/log_list.html.eex", [:conns] - EEx.function_from_file :defp, :render_log, "templates/log_view.html.eex", [:logconn, :conn] + EEx.function_from_file(:defp, :render_logs, "templates/log_list.html.eex", [:conns]) + EEx.function_from_file(:defp, :render_log, "templates/log_view.html.eex", [:logconn, :conn]) get "/wm_debug/log/:id" do - if (logconn=Log.get(id)) do - conn |> send_resp(200,render_log(logconn,conn)) |> halt + if logconn = Log.get(id) do + conn |> send_resp(200, render_log(logconn, conn)) |> halt else - conn |> put_resp_header("location","/wm_debug") |> send_resp(302,"") |> halt + conn |> put_resp_header("location", "/wm_debug") |> send_resp(302, "") |> halt end end get "/wm_debug" do - html = render_logs(Log.list) - conn |> send_resp(200,html) |> halt + html = render_logs(Log.list()) + conn |> send_resp(200, html) |> halt end get "/wm_debug/events" do @@ -65,58 +61,64 @@ defmodule Ewebmachine.Plug.Debug do end @doc false - def to_draw(conn), do: %{ - request: """ - #{conn.method} #{conn.request_path} HTTP/1.1 - #{html_escape format_headers(conn.req_headers)} - #{html_escape body_of(conn)} - """, - response: %{ - http: """ - HTTP/1.1 #{conn.status} #{Ewebmachine.Core.Utils.http_label(conn.status)} - #{html_escape format_headers(conn.resp_headers)} - #{html_escape (conn.resp_body || "some chunked body")} + def to_draw(conn) do + %{ + request: """ + #{conn.method} #{conn.request_path} HTTP/1.1 + #{html_escape(format_headers(conn.req_headers))} + #{html_escape(body_of(conn))} """, - code: conn.status - }, - trace: Enum.map(Enum.reverse(conn.private.machine_decisions), fn {decision,calls}-> - %{ - d: decision, - calls: Enum.map(calls,fn {module,function,[in_conn,in_state],{resp,out_conn,out_state}}-> + response: %{ + http: """ + HTTP/1.1 #{conn.status} #{Ewebmachine.Core.Utils.http_label(conn.status)} + #{html_escape(format_headers(conn.resp_headers))} + #{html_escape(conn.resp_body || "some chunked body")} + """, + code: conn.status + }, + trace: + Enum.map(Enum.reverse(conn.private.machine_decisions), fn {decision, calls} -> %{ - module: inspect(module), - function: "#{function}", - input: """ - state = #{html_escape inspect(in_state, pretty: true)} - - conn = #{html_escape inspect(in_conn, pretty: true)} - """, - output: """ - response = #{html_escape inspect(resp, pretty: true)} - - state = #{html_escape inspect(out_state, pretty: true)} - - conn = #{html_escape inspect(out_conn, pretty: true)} - """ + d: decision, + calls: + Enum.map(calls, fn {module, function, [in_conn, in_state], + {resp, out_conn, out_state}} -> + %{ + module: inspect(module), + function: "#{function}", + input: """ + state = #{html_escape(inspect(in_state, pretty: true))} + + conn = #{html_escape(inspect(in_conn, pretty: true))} + """, + output: """ + response = #{html_escape(inspect(resp, pretty: true))} + + state = #{html_escape(inspect(out_state, pretty: true))} + + conn = #{html_escape(inspect(out_conn, pretty: true))} + """ + } + end) } end) - } - end) - } + } + end defp body_of(conn) do case Conn.read_body(conn) do - {:ok,body,_}->body + {:ok, body, _} -> body _ -> "" end end defp format_headers(headers) do - headers |> Enum.map(fn {k,v}->"#{k}: #{v}\n" end) |> Enum.join + headers |> Enum.map(fn {k, v} -> "#{k}: #{v}\n" end) |> Enum.join() end - defp html_escape(data), do: - to_string(for(<>, do: escape_char(char))) + defp html_escape(data), + do: to_string(for(<>, do: escape_char(char))) + defp escape_char(?<), do: "<" defp escape_char(?>), do: ">" defp escape_char(?&), do: "&" diff --git a/lib/ewebmachine/plug.error_as_exception.ex b/lib/ewebmachine/plug.error_as_exception.ex index 18916b7..5b1e65d 100644 --- a/lib/ewebmachine/plug.error_as_exception.ex +++ b/lib/ewebmachine/plug.error_as_exception.ex @@ -1,15 +1,28 @@ defmodule Ewebmachine.Plug.ErrorAsException do @moduledoc """ - This plug checks the current response status. If it is an error, raise a plug - exception with the status code and the HTTP error name as the message. If - this response body is not void, use it as the exception message. + Raises an `#{__MODULE__}` exception on errornous HTTP status code. + + If the `conn`'s state is `:set` and its `status` is higher than `399`, this + plug raises a `#{__MODULE__}` exception. + + The `conn`'s `resp_body` is used if not empty as the exception's message, + otherwise the http code's label is used. """ - defexception [:plug_status,:message] - def init(_), do: [] - def call(%{status: code, state: :set}=conn,_) when code > 399, do: raise(__MODULE__,conn) - def call(conn,_), do: conn - def exception(%{status: code,resp_body: msg}) when byte_size(msg)>0, do: - %__MODULE__{plug_status: code, message: msg} - def exception(%{status: code}), do: - %__MODULE__{plug_status: code, message: Ewebmachine.Core.Utils.http_label(code)} + @behaviour Plug + + defexception [:plug_status, :message] + + @impl Plug + def init(_), do: [] + + @impl Plug + def call(%{status: code, state: :set} = conn, _) when code > 399, do: raise(__MODULE__, conn) + def call(conn, _), do: conn + + @impl Exception + def exception(%{status: code, resp_body: msg}) when is_bitstring(msg) and byte_size(msg) > 0, + do: %__MODULE__{plug_status: code, message: msg} + + def exception(%{status: code}), + do: %__MODULE__{plug_status: code, message: Ewebmachine.Core.Utils.http_label(code)} end diff --git a/lib/ewebmachine/plug.error_as_forward.ex b/lib/ewebmachine/plug.error_as_forward.ex index 6026ea1..12d7987 100644 --- a/lib/ewebmachine/plug.error_as_forward.ex +++ b/lib/ewebmachine/plug.error_as_forward.ex @@ -1,23 +1,36 @@ defmodule Ewebmachine.Plug.ErrorAsForward do @moduledoc """ - This plug take an argument `forward_pattern` (default to `"/error/:status"`), - and, when the current response status is an error, simply forward to a `GET` - to the path defined by the pattern and this status. + Sets the `path_info` on errornous HTTP status code to forward the request to + another plug. + + If the `conn`'s `status` is higher than `399` , the `conn`'s `path_info` is + set to the `forward_pattern`, the `state` sets to `:unset`, and the `method` + to `GET`. + + Usefull is you want to have a generic route error handler downstream in the + pipeline. + + ## Options + + * `forward_pattern` - sets the redirection path. The `:status` will be + replaced by the `conn`'s `status`. Defaults to `/error/:status`. """ + @behaviour Plug - @doc false - def init(opts), do: (opts[:forward_pattern] || "/error/:status") + @impl Plug + def init(opts), do: opts[:forward_pattern] || "/error/:status" - @doc false + @impl Plug def call(%Plug.Conn{status: code, state: :set} = conn, pattern) when code > 399 do # `path_info` info is the request path split as segments. + # Generate a path according to the status code. path_info = - # Generate a path according to the status code. String.replace(pattern, ":status", to_string(code)) # Transform it into segments. |> String.split("/", trim: true) %{conn | path_info: path_info, method: "GET", state: :unset} end + def call(conn, _), do: conn end diff --git a/lib/ewebmachine/plug.run.ex b/lib/ewebmachine/plug.run.ex index 806751f..c095c58 100644 --- a/lib/ewebmachine/plug.run.ex +++ b/lib/ewebmachine/plug.run.ex @@ -1,43 +1,48 @@ defmodule Ewebmachine.Plug.Run do @moduledoc ~S""" - Plug passing your `conn` through the [HTTP decision - tree](./assets/http_diagram.png) to fill its status and response. - - This plug does not send the HTTP result, instead the `conn` - result of this plug must be sent with the plug - `Ewebmachine.Plug.Send`. This is useful to customize the Ewebmachine result - after the run, for instance to customize the error body (void by default). - - - Decisions are make according to handlers set in `conn.private[:resource_handlers]` - (`%{handler_name: handler_module}`) where `handler_name` is one - of the handler function of `Ewebmachine.Handlers` and - `handler_module` is the module implementing it. - - Initial user state (second parameter of handler function) is - taken from `conn.private[:machine_init]` - - `Ewebmachine.Builder.Handlers` `:add_handler` plug allows you to - set these parameters in order to use this Plug. - - A successfull run will reset the resource handlers and initial state. + A plug which runs the HTTP decision tree with the set handlers defined by a + matching `Ewebmachine.Builder.Resources.resource/2`. + + The initial user state is taken from the `do` block of the + `Ewebmachine.Builder.Resources.resource/2` macro. + + ![](./assets/http_diagram.png) + + This plug does not send the response to let additional plugs customize the + result. + + To send the response, use the `Ewebmachine.Plug.Send` after this one. """ - @doc false + @behaviour Plug + + @impl Plug def init(_opts), do: [] - @doc false + @impl Plug def call(conn, _opts) do init = conn.private[:machine_init] - if (init) do + + if init do conn = Ewebmachine.Core.v3(conn, init) log = conn.private[:machine_log] - if (log) do + + if log do Ewebmachine.Log.put(conn) Ewebmachine.Events.dispatch(log) end - private = Map.drop(conn.private, [ - :machine_init, :resource_handlers, :machine_decisions, :machine_calls, :machine_log, :machine_init_at - ]) - %{ conn | private: private } + + private = + Map.drop(conn.private, [ + :machine_init, + :resource_handlers, + :machine_decisions, + :machine_calls, + :machine_log, + :machine_init_at + ]) + + %{conn | private: private} else conn end diff --git a/lib/ewebmachine/plug.send.ex b/lib/ewebmachine/plug.send.ex index 9fb4257..7912616 100644 --- a/lib/ewebmachine/plug.send.ex +++ b/lib/ewebmachine/plug.send.ex @@ -1,35 +1,34 @@ defmodule Ewebmachine.Plug.Send do @moduledoc ~S""" - Calling this plug will send the response and halt the connection - pipeline if the `conn` has passed through an `Ewebmachine.Plug.Run`. + A plug which sends the response and halts the pipeline if the state is `:set`. + + Should be used in a plug pipeline after the `Ewebmachine.Plug.Run` plug. """ import Plug.Conn - @doc false + @behaviour Plug + + @impl Plug def init(_opts), do: [] - @doc false - def call(conn, _opts) do - if conn.state == :set do - stream = conn.private[:machine_body_stream] - conn = - if stream do - conn = send_chunked(conn,conn.status) - conn = Enum.reduce_while(stream, conn, fn chunk, conn -> - case Plug.Conn.chunk(conn, chunk) do - {:ok, conn} -> - {:cont, conn} - {:error, :closed} -> - {:halt, conn} - end - end) - conn - else - send_resp(conn) + @impl Plug + def call(%Plug.Conn{state: :set, private: %{machine_body_stream: stream}} = conn, _) do + conn = send_chunked(conn, conn.status) + + conn = + Enum.reduce_while(stream, conn, fn chunk, conn -> + case Plug.Conn.chunk(conn, chunk) do + {:ok, conn} -> + {:cont, conn} + + {:error, :closed} -> + {:halt, conn} end - halt(conn) - else - conn - end + end) + + halt(conn) end + + def call(%Plug.Conn{state: :set} = conn, _), do: halt(send_resp(conn)) + def call(%Plug.Conn{} = conn, _), do: conn end diff --git a/mix.exs b/mix.exs index 7505a0e..61b9977 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Ewebmachine.Mixfile do use Mix.Project - def version, do: "2.3.3" + def version, do: "2.4.0" @description """ Ewebmachine contains macros and plugs to allow you to compose @@ -18,13 +18,13 @@ defmodule Ewebmachine.Mixfile do docs: docs(), deps: deps(), description: @description, - package: package(), + package: package() ] end def application do [ - mod: { Ewebmachine.App, [] }, + mod: {Ewebmachine.App, []}, extra_applications: [:inets], env: [] ] @@ -32,23 +32,46 @@ defmodule Ewebmachine.Mixfile do defp docs do [ - assets: "assets", + assets: %{"assets" => "assets"}, + default_group_for_doc: fn + %{group: :handler} -> "Handlers" + _ -> nil + end, extras: [ "CHANGELOG.md": [title: "Changelog"], - "README.md": [title: "Overview"], - "pages/demystify_dsl.md": [title: "Demystify Ewebmachine DSL"], + "README.md": [title: "Ewebmachine"], + "pages/demystify_dsl.md": [title: "Demystify Ewebmachine DSL"] + ], + formatters: ["html"], + groups_for_modules: [ + # Ungrouped modules + # + # Ewebmachine + # Ewebmachine.Builder.Handlers + # Ewebmachine.Builder.Resources + # Ewebmachine.Core.Utils + # Ewebmachine.Handlers + + Plugs: [ + Ewebmachine.Plug.Debug, + Ewebmachine.Plug.ErrorAsException, + Ewebmachine.Plug.ErrorAsForward, + Ewebmachine.Plug.Send, + Ewebmachine.Plug.Run + ] ], main: "readme", + nest_modules_by_prefix: [Ewebmachine.Plug], + skip_undefined_reference_warnings_on: ["CHANGELOG.md"], source_url: git_repository(), # We need to git tag with the corresponding format. - source_ref: "v#{version()}", + source_ref: "v#{version()}" ] end defp deps do [ {:plug, "~> 1.10"}, - {:plug_cowboy, "~> 2.4"}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:dialyxir, "~> 0.5", only: [:dev], runtime: false} ] @@ -59,11 +82,20 @@ defmodule Ewebmachine.Mixfile do links: %{ "GitHub" => git_repository(), "Doc" => "http://hexdocs.pm/ewebmachine", - "Changelog" => "https://hexdocs.pm/ewebmachine/changelog.html", + "Changelog" => "https://hexdocs.pm/ewebmachine/changelog.html" }, maintainers: ["Arnaud Wetzel", "Yurii Rashkovskii", "Jean Parpaillon"], licenses: ["MIT"], - files: ["lib", "priv", "mix.exs", "README*", "templates", "LICENSE*", "CHANGELOG*", "examples"], + files: [ + "lib", + "priv", + "mix.exs", + "README*", + "templates", + "LICENSE*", + "CHANGELOG*", + ".formatter.exs" + ] ] end diff --git a/test/run_core_test.exs b/test/run_core_test.exs index 4c9c52a..4e6ece7 100644 --- a/test/run_core_test.exs +++ b/test/run_core_test.exs @@ -1,22 +1,23 @@ -Code.require_file "test_helper.exs", __DIR__ +Code.require_file("test_helper.exs", __DIR__) defmodule CommonMacros do - defmacro resources([do: body]) do - name = :"#{inspect make_ref()}" - - {:module, module, _, _} = Module.create( - name, - quote do - use Ewebmachine.Builder.Resources - plug :resource_match - plug Ewebmachine.Plug.Run - plug Ewebmachine.Plug.Send - plug :error_404 - defp error_404(conn, _), do: conn |> send_resp(404, "") |> halt() - unquote(body) - end, - Macro.Env.location(__ENV__) - ) + defmacro resources(do: body) do + name = :"#{inspect(make_ref())}" + + {:module, module, _, _} = + Module.create( + name, + quote do + use Ewebmachine.Builder.Resources + plug :resource_match + plug Ewebmachine.Plug.Run + plug Ewebmachine.Plug.Send + plug :error_404 + defp error_404(conn, _), do: conn |> send_resp(404, "") |> halt() + unquote(body) + end, + Macro.Env.location(__ENV__) + ) module end @@ -37,9 +38,10 @@ defmodule EwebmachineTest do defh to_html, do: "Hello World" end + conn = SimpleHtml.call(conn(:get, "/"), []) assert conn.status == 200 - assert Enum.into(conn.resp_headers,%{})["content-type"] == "text/html" + assert Enum.into(conn.resp_headers, %{})["content-type"] == "text/html" assert conn.resp_body == "Hello World" assert conn.state == :sent end @@ -47,224 +49,371 @@ defmodule EwebmachineTest do test "default plugs" do defmodule SimpleResources do use Ewebmachine.Builder.Resources, default_plugs: true - resource "/ok" do [] after defh(to_html, do: "toto") end + + resource "/ok" do + [] + after + defh(to_html, do: "toto") + end end + conn = SimpleResources.call(conn(:get, "/ok"), []) assert conn.status == 200 - assert Enum.into(conn.resp_headers,%{})["content-type"] == "text/html" + assert Enum.into(conn.resp_headers, %{})["content-type"] == "text/html" assert conn.resp_body == "toto" assert conn.state == :sent end test "Simple resource builder with XML and path match param" do - app = resources do - resource "/hello/:name" do %{name: name} after - content_types_provided do: ["application/xml": :to_xml] - defh to_xml, do: "#{state.name}" + app = + resources do + resource "/hello/:name" do + %{name: name} + after + content_types_provided do: ["application/xml": :to_xml] + defh to_xml, do: "#{state.name}" + end end - end + assert app.call(conn(:get, "/"), []).status == 404 conn = app.call(conn(:get, "/hello/arnaud"), []) assert conn.status == 200 - assert Enum.into(conn.resp_headers,%{})["content-type"] == "application/xml" + assert Enum.into(conn.resp_headers, %{})["content-type"] == "application/xml" assert conn.resp_body == "arnaud" end test "Implement not exists" do - app = resources do - resource "/hello/:name" do %{name: name} after - resource_exists do: state.name !== "idonotexist" - defh to_html, do: state.name + app = + resources do + resource "/hello/:name" do + %{name: name} + after + resource_exists do: state.name !== "idonotexist" + defh to_html, do: state.name + end end - end + assert app.call(conn(:get, "/hello/arnaud"), []).status == 200 assert app.call(conn(:get, "/hello/idonotexist"), []).status == 404 end test "Service not available" do - app = resources do - resource "/notok" do %{} after - service_available do: false - end - resource "/ok" do %{} after - service_available do: true + app = + resources do + resource "/notok" do + %{} + after + service_available do: false + end + + resource "/ok" do + %{} + after + service_available do: true + end end - end + assert app.call(conn(:get, "/notok"), []).status == 503 assert app.call(conn(:get, "/ok"), []).status == 200 end test "Unknown method" do - app = resources do - resource "/notok" do %{} after known_methods(do: ["TOTO"]) end - resource "/ok" do %{} after end - end + app = + resources do + resource "/notok" do + %{} + after + known_methods(do: ["TOTO"]) + end + + resource "/ok" do + %{} + after + end + end + assert app.call(conn(:get, "/notok"), []).status == 501 assert app.call(conn(:get, "/ok"), []).status == 200 end test "Url too long" do - app = resources do - resource "/notok" do %{} after uri_too_long(do: true) end - resource "/ok" do %{} after end - end + app = + resources do + resource "/notok" do + %{} + after + uri_too_long do: true + end + + resource "/ok" do + %{} + after + end + end + assert app.call(conn(:get, "/notok"), []).status == 414 assert app.call(conn(:get, "/ok"), []).status == 200 end test "Method allowed ?" do - app = resources do - resource "/notok" do %{} after allowed_methods(do: ["POST"]) end - resource "/ok" do %{} after end - end + app = + resources do + resource "/notok" do + %{} + after + allowed_methods do: ["POST"] + end + + resource "/ok" do + %{} + after + end + end + assert app.call(conn(:get, "/notok"), []).status == 405 assert app.call(conn(:get, "/ok"), []).status == 200 end test "Content MD5 check" do - app = resources do - resource "/" do %{} after - allowed_methods do: ["PUT"] - content_types_accepted do: ["application/json": :from_json] - defh from_json, do: true + app = + resources do + resource "/" do + %{} + after + allowed_methods do: ["PUT"] + content_types_accepted do: ["application/json": :from_json] + defh from_json, do: true + end end - end - headers = [{"content-type","application/json"},{"content-md5","qqsjdf"}] - assert app.call(%{conn(:put,"/","hello\n")|req_headers: headers},[]).status == 400 - headers = [{"content-type","application/json"},{"content-md5","sZRqySSS0jR8YjW00mERhA=="}] - assert app.call(%{conn(:put,"/","hello\n")|req_headers: headers},[]).status == 204 + + headers = [{"content-type", "application/json"}, {"content-md5", "qqsjdf"}] + assert app.call(%{conn(:put, "/", "hello\n") | req_headers: headers}, []).status == 400 + headers = [{"content-type", "application/json"}, {"content-md5", "sZRqySSS0jR8YjW00mERhA=="}] + assert app.call(%{conn(:put, "/", "hello\n") | req_headers: headers}, []).status == 204 end test "Malformed ?" do - app = resources do - resource "/notok" do %{} after malformed_request(do: true) end - resource "/ok" do %{} after end - end + app = + resources do + resource "/notok" do + %{} + after + malformed_request do: true + end + + resource "/ok" do + %{} + after + end + end + assert app.call(conn(:get, "/notok"), []).status == 400 assert app.call(conn(:get, "/ok"), []).status == 200 end test "Is authorized ?" do - app = resources do - resource "/notok" do %{} after is_authorized(do: "myrealm") end - resource "/ok" do %{} after end - end + app = + resources do + resource "/notok" do + %{} + after + is_authorized do: "myrealm" + end + + resource "/ok" do + %{} + after + end + end + assert app.call(conn(:get, "/ok"), []).status == 200 conn = app.call(conn(:get, "/notok"), []) assert conn.status == 401 - assert get_resp_header(conn,"www-authenticate") == ["myrealm"] + assert get_resp_header(conn, "www-authenticate") == ["myrealm"] end test "Encoding base64" do - app = resources do - resource "/" do %{} after - encodings_provided do: [base64: &Base.encode64/1, identity: &(&1)] - defh to_html, do: "hello" + app = + resources do + resource "/" do + %{} + after + encodings_provided do: [base64: &Base.encode64/1, identity: & &1] + defh to_html, do: "hello" + end end - end - conn = app.call(%{conn(:get,"/")|req_headers: [{"accept-encoding","base64"}]}, []) - assert get_resp_header(conn,"content-encoding") == ["base64"] + + conn = app.call(%{conn(:get, "/") | req_headers: [{"accept-encoding", "base64"}]}, []) + assert get_resp_header(conn, "content-encoding") == ["base64"] assert conn.resp_body == "aGVsbG8=" - conn = app.call(%{conn(:get,"/")|req_headers: [{"accept-encoding","toto"}]}, []) + conn = app.call(%{conn(:get, "/") | req_headers: [{"accept-encoding", "toto"}]}, []) assert conn.status == 200 - assert get_resp_header(conn,"content-encoding") == [] + assert get_resp_header(conn, "content-encoding") == [] assert conn.resp_body == "hello" end test "POST create path" do - app = resources do - resource "/orders" do %{} after - allowed_methods do: ["POST"] - post_is_create do: true - create_path do: "/titus" - content_types_accepted do: ["text/plain": :from_text] - defh from_text, do: - {true,put_private(conn,:body_post,read_body(conn)),state} - end - resource "/orders2" do %{} after - allowed_methods do: ["POST"] - post_is_create do: true - # Check modified state is propagated - defh create_path(conn, %{path: path} = state), do: {path, conn, state} - content_types_accepted do: ["text/plain": :from_text] - defh from_text(conn, state), do: {true, conn, state |> Map.put(:path, "titus")} + app = + resources do + resource "/orders" do + %{} + after + allowed_methods do: ["POST"] + post_is_create do: true + create_path do: "/titus" + content_types_accepted do: ["text/plain": :from_text] + defh from_text, do: {true, put_private(conn, :body_post, read_body(conn)), state} + end + + resource "/orders2" do + %{} + after + allowed_methods do: ["POST"] + post_is_create do: true + # Check modified state is propagated + defh create_path(conn, %{path: path} = state), do: {path, conn, state} + content_types_accepted do: ["text/plain": :from_text] + defh from_text(conn, state), do: {true, conn, state |> Map.put(:path, "titus")} + end end - end - conn = app.call(conn(:post,"/orders","titus") |> put_req_header("content-type", "text/plain"), []) - assert get_resp_header(conn,"location") == ["http://www.example.com/titus"] + + conn = + app.call( + conn(:post, "/orders", "titus") |> put_req_header("content-type", "text/plain"), + [] + ) + + assert get_resp_header(conn, "location") == ["http://www.example.com/titus"] assert conn.status == 201 - assert {:ok,"titus",_} = conn.private.body_post - conn = app.call(conn(:post,"/orders2","titus") |> put_req_header("content-type", "text/plain"), []) - assert get_resp_header(conn,"location") == ["http://www.example.com/orders2/titus"] + assert {:ok, "titus", _} = conn.private.body_post + + conn = + app.call( + conn(:post, "/orders2", "titus") |> put_req_header("content-type", "text/plain"), + [] + ) + + assert get_resp_header(conn, "location") == ["http://www.example.com/orders2/titus"] end test "POST process post" do - app = resources do - resource "/orders" do %{} after - allowed_methods do: ["POST"] - process_post do: - {true,put_private(conn,:body_post,"yes"),state} + app = + resources do + resource "/orders" do + %{} + after + allowed_methods do: ["POST"] + process_post do: {true, put_private(conn, :body_post, "yes"), state} + end end - end - conn = app.call(conn(:post,"/orders","titus") |> put_req_header("content-type", "text/plain"), []) + + conn = + app.call( + conn(:post, "/orders", "titus") |> put_req_header("content-type", "text/plain"), + [] + ) + assert conn.status == 204 assert "yes" = conn.private[:body_post] end test "Cache if modified" do - app = resources do - resource "/notcached" do %{} after - last_modified do: {{2013,1,1},{0,0,0}} - end - resource "/cached" do %{} after - last_modified do: {{2012,12,31},{0,0,0}} + app = + resources do + resource "/notcached" do + %{} + after + last_modified do: {{2013, 1, 1}, {0, 0, 0}} + end + + resource "/cached" do + %{} + after + last_modified do: {{2012, 12, 31}, {0, 0, 0}} + end end - end - conn = app.call(%{conn(:get,"/cached")|req_headers: [{"if-modified-since","Sat, 31 Dec 2012 19:43:31 GMT"}]}, []) + + conn = + app.call( + %{ + conn(:get, "/cached") + | req_headers: [{"if-modified-since", "Sat, 31 Dec 2012 19:43:31 GMT"}] + }, + [] + ) + assert conn.status == 304 - conn = app.call(%{conn(:get,"/notcached")|req_headers: [{"if-modified-since","Sat, 31 Dec 2012 19:43:31 GMT"}]}, []) + + conn = + app.call( + %{ + conn(:get, "/notcached") + | req_headers: [{"if-modified-since", "Sat, 31 Dec 2012 19:43:31 GMT"}] + }, + [] + ) + assert conn.status == 200 end test "Cache etag" do - app = resources do - resource "/notcached" do %{} after - generate_etag do: "titi" - end - resource "/cached" do %{} after - generate_etag do: "toto" + app = + resources do + resource "/notcached" do + %{} + after + generate_etag do: "titi" + end + + resource "/cached" do + %{} + after + generate_etag do: "toto" + end end - end - conn = app.call(%{conn(:get,"/cached")|req_headers: [{"if-none-match","toto"}]}, []) + + conn = app.call(%{conn(:get, "/cached") | req_headers: [{"if-none-match", "toto"}]}, []) assert conn.status == 304 - conn = app.call(%{conn(:get,"/notcached")|req_headers: [{"if-none-match","toto"}]}, []) + conn = app.call(%{conn(:get, "/notcached") | req_headers: [{"if-none-match", "toto"}]}, []) assert conn.status == 200 end test "halt test" do - app = resources do - resource "/error" do %{} after - content_types_provided do: {:halt,407} - defh to_html, do: "toto" + app = + resources do + resource "/error" do + %{} + after + content_types_provided do: {:halt, 407} + defh to_html, do: "toto" + end end - end - conn = app.call(conn(:get,"/error"), []) + + conn = app.call(conn(:get, "/error"), []) assert conn.status == 407 assert conn.resp_body == "" end test "fuzzy acceptance" do - app = resources do - resource "/" do %{} after - allowed_methods do: ["PUT"] - content_types_accepted do: %{"application/*"=> :from_app, {"text/*",%{"pretty"=>"true"}}=> :from_pretty} - defh from_app, do: {:halt,601} - defh from_pretty, do: {:halt,602} + app = + resources do + resource "/" do + %{} + after + allowed_methods do: ["PUT"] + + content_types_accepted do + %{"application/*" => :from_app, {"text/*", %{"pretty" => "true"}} => :from_pretty} + end + + defh from_app, do: {:halt, 601} + defh from_pretty, do: {:halt, 602} + end end - end - headers = [{"content-type","application/json; charset=utf8"}] - assert app.call(%{conn(:put,"/","h")|req_headers: headers},[]).status == 601 - headers = [{"content-type","text/html; pretty=true; charset=utf8"}] - assert app.call(%{conn(:put,"/","h")|req_headers: headers},[]).status == 602 + + headers = [{"content-type", "application/json; charset=utf8"}] + assert app.call(%{conn(:put, "/", "h") | req_headers: headers}, []).status == 601 + headers = [{"content-type", "text/html; pretty=true; charset=utf8"}] + assert app.call(%{conn(:put, "/", "h") | req_headers: headers}, []).status == 602 end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 4b8b246..869559e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1 @@ -ExUnit.start +ExUnit.start() diff --git a/test/utils_test.exs b/test/utils_test.exs index b8d0c1b..fa79031 100644 --- a/test/utils_test.exs +++ b/test/utils_test.exs @@ -1,32 +1,35 @@ -Code.require_file "test_helper.exs", __DIR__ +Code.require_file("test_helper.exs", __DIR__) defmodule Ewebmachine.Core.UtilsTest do use ExUnit.Case import Ewebmachine.Core.Utils test "Content Type negociation found a value" do - for accept_header<-["*", "*/*", "text/*", "text/html"] do - assert {"text","html",%{}} = choose_media_type([{"text","html",%{}}],accept_header) + for accept_header <- ["*", "*/*", "text/*", "text/html"] do + assert {"text", "html", %{}} = choose_media_type([{"text", "html", %{}}], accept_header) end end test "Content Type negociation no matching value" do - for accept_header<-["foo", "text/xml", "application/*", "foo/bar/baz"] do - assert nil == choose_media_type([{"text","html",%{}}], accept_header) + for accept_header <- ["foo", "text/xml", "application/*", "foo/bar/baz"] do + assert nil == choose_media_type([{"text", "html", %{}}], accept_header) end end test "Content Type negociation quality selection" do - provided = [{"text","html",%{}},{"image","jpeg",%{}}] - for accept_header<-["image/jpeg;q=0.5, text/html", - "text/html, image/jpeg; q=0.5", - "text/*; q=0.8, image/*;q=0.7", - "text/*;q=.8, image/*;q=.7"] do - assert {"text","html",%{}} = choose_media_type(provided,accept_header) + provided = [{"text", "html", %{}}, {"image", "jpeg", %{}}] + + for accept_header <- [ + "image/jpeg;q=0.5, text/html", + "text/html, image/jpeg; q=0.5", + "text/*; q=0.8, image/*;q=0.7", + "text/*;q=.8, image/*;q=.7" + ] do + assert {"text", "html", %{}} = choose_media_type(provided, accept_header) end - for accept_header<-["image/*;q=1, text/html;q=0.9", - "image/png, image/*;q=0.3"] do - assert {"image","jpeg",%{}} = choose_media_type(provided,accept_header) + + for accept_header <- ["image/*;q=1, text/html;q=0.9", "image/png, image/*;q=0.3"] do + assert {"image", "jpeg", %{}} = choose_media_type(provided, accept_header) end end @@ -35,15 +38,18 @@ defmodule Ewebmachine.Core.UtilsTest do end test "rfc1123 date parsing" do - assert {{2009,12,30},{14,39,2}} = convert_request_date("Wed, 30 Dec 2009 14:39:02 GMT") + assert {{2009, 12, 30}, {14, 39, 2}} = convert_request_date("Wed, 30 Dec 2009 14:39:02 GMT") assert :bad_date = convert_request_date(:toto) end test "content type normalization roundtrip" do - for type<-["audio/vnd.wave; codec=31", - "text/x-okie; charset=iso-8859-1; declaration=f950118.AEB0com"] do - assert type == (type |> normalize_mtype |> format_mtype) + for type <- [ + "audio/vnd.wave; codec=31", + "text/x-okie; charset=iso-8859-1; declaration=f950118.AEB0com" + ] do + assert type == type |> normalize_mtype |> format_mtype end - assert "audio/vnd.wave; codec=31" = format_mtype({"audio","vnd.wave",%{codec: "31"}}) + + assert "audio/vnd.wave; codec=31" = format_mtype({"audio", "vnd.wave", %{codec: "31"}}) end end