diff --git a/README.md b/README.md index 49766eb..bdc29e9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ execution its key names and handing the finished execution's answer to a sink. [`docs/guides/migrating-waiting-executions.md`](docs/guides/migrating-waiting-executions.md) moves waiting library loans onto a new revision of their document and back, and `mix statifier_examples.migrate_waiting` runs it. +[`docs/guides/basichttp-front.md`](docs/guides/basichttp-front.md) gives a +library hold its own HTTP location through `statifier_router`'s BasicHTTP +front, and answers the branch desk's POST at it. ## Opening a document in the editor diff --git a/config/config.exs b/config/config.exs index bd47074..a29c348 100644 --- a/config/config.exs +++ b/config/config.exs @@ -117,6 +117,10 @@ config :logger, :default_formatter, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +# A BasicHTTP location's token is a bearer capability, so a params log +# names it filtered, beside Phoenix's own default. +config :phoenix, :filter_parameters, ["password", "token"] + # OpenTelemetry. `opentelemetry_statifier` brings only the API, so the SDK's # exporter is this app's choice - and the default choice is none. An example # app that shipped an OTLP exporter on by default would spend every boot diff --git a/config/test.exs b/config/test.exs index 88ad032..b7a7f92 100644 --- a/config/test.exs +++ b/config/test.exs @@ -47,3 +47,8 @@ config :phoenix_live_view, # Sort query params output of verified routes for robust url comparisons config :phoenix, sort_verified_routes_query_params: true + +# The hold desk's outbound BasicHTTP POSTs go to a transport that hands +# each one to the process that made it, so a test reads what the desk was +# sent instead of reaching a branch desk over the network. +config :statifier_examples, StatifierExamples.HoldDesk, transport: StatifierExamples.DeskTransport diff --git a/docs/guides/basichttp-front.md b/docs/guides/basichttp-front.md new file mode 100644 index 0000000..5d7a417 --- /dev/null +++ b/docs/guides/basichttp-front.md @@ -0,0 +1,138 @@ +# A durable execution with an HTTP location: the BasicHTTP front + +A patron places a hold on a copy at the Riverside branch. The hold is a +durable execution, and it needs to hear back from the branch desk when the +copy is on the holds shelf. With `statifier_router`'s BasicHTTP front, the +execution gets an HTTP location of its own - a URL the desk POSTs an event +to - and the router delivers that POST into the execution through the same +path every routed event takes. + +This guide walks the pieces as this app wires them: +`StatifierExamples.HoldDesk` (the router configuration, the chart's +resolver and the executor), `priv/library/hold_desk.scxml` (the chart), +`StatifierExamplesWeb.BasicHTTPController` (the front's action) and +`test/statifier_examples_web/controllers/basic_http_controller_test.exs`, +which drives the whole of it through the controller. + +## A location is a bearer capability + +Anyone who holds a location can post events to that execution, and the +router authenticates nothing beyond possession of it (ruled by the +operator, 2026-09-30). The hold hands its location to the one desk its +request names and to nobody else. No request line or dispatch log carries +it: the endpoint's `Plug.Telemetry` logs no request line under +`/basichttp` (`StatifierExamplesWeb.Endpoint.log_level/1`), the route is +`log: false`, and `:filter_parameters` names `token`. Ecto's query log at +`:debug` prints bound parameters, and the router binds the token to look +a location up and to store it, so a host keeps `:debug` out of +production; at `:info`, the production level here, no query is logged. +A host serves +the base URL over TLS and rotates a location that may have leaked with +`StatifierRouter.BasicHTTP.rotate_location/2`, after which the old one +answers 404. + +## The pins + +| Package | Version | What this guide uses it for | +|---|---|---| +| `statifier_router` | 0.9.2 | the `:basichttp` key, the location, `StatifierRouter.BasicHTTP.Front`, the location table | +| `statifier` | 2.10.0 | the Basic HTTP Event I/O Processor and its decoder | +| `statifier_persistence` | 0.24.0 | the chart registry, the execution, the input log | + +0.9.2 is the floor because it is the first `statifier_router` release +whose migrations and address reaper both run on this app's SQLite +database. + +## The chart + +`priv/library/hold_desk.scxml` waits in `requested` for the hold request. +On `hold.requested` it sends `hold.placed` to the desk the request names, +with a `` whose `targetexpr` is the desk's URL, and +passes three parameters: the hold, the copy, and `reply_to`, its own +location, read from `_ioprocessors['basichttp']['location']`. It then waits +in `waiting` for `copy.shelved` and finishes in `shelved`. + +## The configuration + +`StatifierExamples.HoldDesk.config/0` is a router configuration of its own: +one binding, `hold_requests`, that routes each hold request to the +execution keyed by its hold id, and the `:basichttp` key: + +```elixir +basichttp: [base_url: StatifierExamplesWeb.Endpoint.url() <> "/basichttp"] +``` + +Setting the key does two things. It registers the router's processor under +the processor's URI and its short form `basichttp`, which is what lets the +chart's `` pass the engine's type check. And it +gives every execution created under a new address row a location: the base +URL, `/`, and a 43-character token the router mints, never the execution +id. The parcel recipe's configuration, +`StatifierExamples.RoutedWorkflow.config/0`, does not set the key, so its +executions get no location. + +The location token lives in the router's location table, which only a +configuration with `:basichttp` needs. This app creates it in +`priv/repo/migrations/20260930120001_add_statifier_router_locations.exs`: + +```elixir +def up, do: StatifierRouter.Migrations.up_locations(@opts) +def down, do: StatifierRouter.Migrations.down_locations(@opts) +``` + +with the same `depot_id` leading column the app's first router migration +gives every router table. + +## The outbound send runs in the executor + +A durable execution has no session to perform its sends: the step hands +each effect to the configuration's executor. `StatifierExamples.HoldDesk.execute/2` +plans a BasicHTTP send with `StatifierRouter.BasicHTTP.deliver/3` and +performs what it planned with the processor's `perform/2`, which POSTs a +form body to the desk with the send's `scxml-send-key` header. The POST +goes through the configuration's `:transport`: statifier's default, +on OTP's `:httpc`, in the dev app, and a transport under test that hands +the POST back to the test instead of sending it. + +The POST is made inside the delivery's transaction, before the step +commits. A desk that does not answer 2xx, or does not answer at all, is a +failed send: `statifier_persistence` enters `error.communication`, +carrying the send id, into the execution in the same step, and the chart +takes it from `waiting` to its other final state, `desk_unreached`. A +delayed BasicHTTP send is refused the same way, because its timer would +live in the delivering process rather than in the database. + +## The front + +The router answers `/basichttp/:token` for every method with +`StatifierExamplesWeb.BasicHTTPController.event/2`, outside the browser +pipeline. The action builds the request map the front reads - the token, +the method, the content type, the body as it arrived, the query string and +the `scxml-send-key` header - hands it to +`StatifierRouter.BasicHTTP.Front.handle/3` and answers the status +`StatifierRouter.BasicHTTP.Front.response/1` maps the answer to: + +| The request | The answer | +|---|---| +| a POST the execution takes, or a repeat of a send key it already took | 204 | +| a POST at a location that reaches no execution: unknown, rotated away, or finished | 404 | +| any other method | 405, with `allow: POST` | +| a body or a send key the decoder refuses | 400 | + +A form body is read by `Plug.Parsers` before any action runs, so the +endpoint's parsers use `StatifierExamplesWeb.RawBody`, which keeps the raw +body of a request under `/basichttp` for the action to hand on. + +## Driving it + +The controller test routes a hold request, reads the `hold.placed` POST the +desk was sent, takes `reply_to` from it and POSTs +`_scxmleventname=copy.shelved` at that location through the endpoint: + +```elixir +post(conn, "/basichttp/" <> token, "_scxmleventname=copy.shelved") +``` + +The answer is 204 and the execution is completed; the same POST again is +404, because the hold has finished. In the dev app, a desk that holds the +location does the same with any HTTP client. diff --git a/docs/guides/first-workflow-routed.md b/docs/guides/first-workflow-routed.md index fd925f4..f744426 100644 --- a/docs/guides/first-workflow-routed.md +++ b/docs/guides/first-workflow-routed.md @@ -23,17 +23,17 @@ The recipe is written and checked against these releases, which are what | Package | Version | What the recipe uses it for | |---|---|---| -| `statifier_router` | 0.6.0 | the binding, the delivery, the address and dedupe tables, the route, the reapers, the migration's `:leading_columns`, `:on_create` and `:on_step` | -| `statifier_persistence` | 0.21.0 | the chart registry, the execution, the input log, `ended_at` | +| `statifier_router` | 0.9.2 | the binding, the delivery, the address and dedupe tables, the route, the reapers, the migration's `:leading_columns`, `:on_create` and `:on_step` | +| `statifier_persistence` | 0.24.0 | the chart registry, the execution, the input log, `ended_at` | | `statifier_blocks` | 0.41.0 | the document and the compile | -| `statifier` | 2.9.0 | compiling the chart and running it | +| `statifier` | 2.10.0 | compiling the chart and running it | -`statifier_router` 0.6.0 requires `statifier ~> 2.9` and +`statifier_router` 0.9.2 requires `statifier ~> 2.10` and `statifier_persistence ~> 0.18`, which is what moved those two with it. `mix.exs` asks for `statifier_persistence ~> 0.20` all the same: 0.20.0 adds `Executions.migrate_batch/3`, which `docs/guides/migrating-waiting-executions.md` walks, and `mix.lock` -resolves 0.21.0, the release this table names. The jobs run on this +resolves 0.24.0, the release this table names. The jobs run on this app's own Oban. The first line the command prints names the versions it actually loaded. @@ -341,8 +341,26 @@ Each of these is in `statifier_router`'s README and not needed here: ## Moving the first-workflow host to these pins +The latest move, from statifier_router 0.6.0 to 0.9.2, is the one that +touched a migration and the reapers. 0.8.0 added the router's third migration version, +V03, which renames the subscription table's unique index on Postgres. The +migration this recipe runs calls `StatifierRouter.Migrations.up/1` with no +version, so on a fresh database it runs V03 too, and V03's `ALTER INDEX` +failed on SQLite until 0.9.1 made it do nothing there; a database that +already ran V02 needs no new migration of its own, since V03 has nothing +to rename on SQLite. 0.8.0 also wrote the address reaper's stamp and +delete with Postgres's `= ANY(...)`, so the `reaped` step failed on SQLite +until 0.9.2 wrote them as an IN list. 0.9.2 is therefore the floor. The same move took statifier +to 2.10.0 and statifier_persistence to 0.24.0, which changed nothing here. +0.9.0's BasicHTTP front is not part of this recipe; its configuration sets +no `:basichttp`, so its executions get no location. +`docs/guides/basichttp-front.md` walks the front. + +The move before it, to the first pins this recipe was written against: + This app moved from statifier 2.8.1, statifier_persistence 0.17.0 and -statifier_router 0.4.1 to the pins above, and nothing in it changed but the +statifier_router 0.4.1 to statifier 2.9.0, statifier_persistence 0.19.0 and +statifier_router 0.6.0, and nothing in it changed but the requirements: - **statifier 2.9.0** adds `Statifier.Publish.findings/2` and diff --git a/docs/guides/first-workflow.md b/docs/guides/first-workflow.md index 006af4d..113b943 100644 --- a/docs/guides/first-workflow.md +++ b/docs/guides/first-workflow.md @@ -19,11 +19,11 @@ The recipe is written and checked against these releases, which are what | Package | Version | What the recipe uses it for | |---|---|---| -| `statifier` | 2.9.0 | compiling the chart and running it | +| `statifier` | 2.10.0 | compiling the chart and running it | | `statifier_blocks` | 0.41.0 | the document, `Plan.expressible/3`, the compile | -| `statifier_persistence` | 0.21.0 | the chart registry, the execution, the input log, `ended_at` | +| `statifier_persistence` | 0.24.0 | the chart registry, the execution, the input log, `ended_at` | | `statifier_oban` | 0.13.0 | the invoke job and its `:invoke_timeout`, the timer job | -| `statifier_router` | 0.6.0 | two of the publish-time checks | +| `statifier_router` | 0.9.2 | two of the publish-time checks | `statifier_datamodel` 0.5.0 arrives through `statifier_blocks`. The first line the command prints names the versions it actually loaded. @@ -237,6 +237,13 @@ The move after it, to `statifier_persistence ~> 0.20` with `mix.lock` at nothing in this recipe calls back into the execution it is stepping, which is the one thing 0.21.0 refuses that it used to take. +The move to statifier 2.10.0, statifier_router 0.9.2 and, with them, +statifier_persistence 0.24.0 needed nothing in this recipe either. The +router releases add a migration version and the BasicHTTP front, which +`docs/guides/basichttp-front.md` walks; none of the persistence releases +adds a migration, and none of what they refuse is something this recipe +does. + The statifier_blocks moves after 0.35.0, one minor at a time to 0.41.0, needed nothing in this recipe either. statifier_blocks' `docs/upgrading.md` says what each asks of a host; this recipe calls `Decode.decode/1`, diff --git a/docs/guides/migrating-waiting-executions.md b/docs/guides/migrating-waiting-executions.md index d7a9f1e..973e3d8 100644 --- a/docs/guides/migrating-waiting-executions.md +++ b/docs/guides/migrating-waiting-executions.md @@ -34,7 +34,7 @@ opened. The next run starts from an empty chart. The migration surface is `statifier_persistence`'s. `mix.exs` asks for `~> 0.20`, the release that adds `Executions.migrate_batch/3`, and -`mix.lock` resolves 0.21.0. The first line the command prints names the +`mix.lock` resolves 0.24.0. The first line the command prints names the version it loaded. The migration surface is the verb and its report. A dry run is the diff --git a/lib/statifier_examples/hold_desk.ex b/lib/statifier_examples/hold_desk.ex new file mode 100644 index 0000000..d4bf267 --- /dev/null +++ b/lib/statifier_examples/hold_desk.ex @@ -0,0 +1,228 @@ +defmodule StatifierExamples.HoldDesk do + @moduledoc """ + A durable execution with an HTTP location of its own, in the library + world: a patron's hold on a copy, at one branch's desk, taking its + events through `statifier_router`'s BasicHTTP front. + + The chart is `priv/library/hold_desk.scxml`. One binding routes a hold + request into a new execution keyed by the hold id. The execution tells + the branch desk the hold was placed with a ``, + handing the desk its own location as `reply_to`, and waits; the desk + POSTs `copy.shelved` at that location once the copy is on the holds + shelf, and the execution finishes. The POST reaches + `StatifierExamplesWeb.BasicHTTPController`, which hands it to + `StatifierRouter.BasicHTTP.Front`. + + **A location is a bearer capability** (ruled by the operator, + 2026-09-30): anyone who holds it can post events to that execution, and + the router authenticates nothing beyond possession of it. This module + hands the location to the desk the hold request names and to nobody + else, and never logs it; `StatifierExamplesWeb.BasicHTTPController` + says what keeps it out of the request log. + + `config/0` is a router configuration of its own, separate from + `StatifierExamples.RoutedWorkflow.config/0`: only this configuration + sets `:basichttp`, so only the executions it creates get a location. + + ## The outbound half runs in this host's executor + + The router registers `StatifierRouter.BasicHTTP` for both of the + processor's type strings, which is what lets the chart's `` pass + the engine's type check and read `_ioprocessors['basichttp']`. A + durable execution has no session to perform the send, so the effect + reaches `execute/2`, which plans it with the router's processor and + performs what it planned, through the configuration's transport. That + runs inside the delivery's transaction: one POST to the desk, made + before the step commits. A POST the desk does not answer with a 2xx is + a failed send, which `statifier_persistence` enters into the execution + as `error.communication`, and the chart ends the hold unreached. A + delayed BasicHTTP send, whose timer would live in this process rather + than in the database, is refused, and enters the execution the same + way. + """ + + @behaviour StatifierRouter.Resolver + + alias Statifier.Effect.{Send, SendDelayed} + alias Statifier.Machine + alias Statifier.Send.Event, as: SendEvent + alias StatifierExamples.FirstWorkflow + alias StatifierExamples.RoutedWorkflow.Stepper + alias StatifierPersistence.Storage + alias StatifierRouter.{BasicHTTP, Config} + + @chart "library/hold_desk.scxml" + + # The document id the binding and the resolver know the chart by. + @document_id "hold_desk" + + @binding_id "hold_requests" + @source "hold_requests" + + # The processor's URI and its short form: the two type strings the + # router registers `StatifierRouter.BasicHTTP` under. + @basichttp_types ["http://www.w3.org/TR/scxml/#BasicHTTPEventProcessor", "basichttp"] + + # The path the controller answers at, under the endpoint's URL. + @front_path "/basichttp" + + @doc """ + The router configuration hold requests are routed with. + + One binding, `#{@binding_id}`, routes every hold request from the + `#{@source}` source to the execution of `#{@document_id}` keyed by its + `hold_id`. `:basichttp` names the base URL the controller answers at + (`base_url/0`) and the transport outbound POSTs go through + (`:transport` under this module's application environment, statifier's + default when unset). Creates and steps go through + `StatifierExamples.RoutedWorkflow.Stepper`, for this app's SQLite + serialization. + """ + @spec config() :: Config.t() + def config do + case Config.new( + repo: StatifierExamples.Repo, + store: FirstWorkflow.store(), + executor: &execute/2, + resolver: __MODULE__, + chart_resolver: &chart/1, + on_create: Stepper, + on_step: Stepper, + bindings: [hold_binding()], + basichttp: basichttp() + ) do + {:ok, config} -> config + {:error, reason} -> raise "the hold desk's router configuration: #{inspect(reason)}" + end + end + + @doc "The base URL every location starts with: the endpoint's URL and `#{@front_path}`." + @spec base_url() :: String.t() + def base_url, do: StatifierExamplesWeb.Endpoint.url() <> @front_path + + @doc "The document id the binding and the resolver share." + @spec document_id() :: String.t() + def document_id, do: @document_id + + @doc """ + Compiles the chart and stores it under its content hash, so the + resolver answers it for `#{@document_id}`. Answers the content hash. + """ + @spec register() :: {:ok, String.t()} | {:error, term()} + def register do + with {:ok, machine, scxml} <- compile(), + :ok <- Storage.save_chart(FirstWorkflow.store(), machine, scxml) do + {:ok, Machine.identity(machine).content_hash} + end + end + + @doc """ + Routes one hold request: `hold` carries `"hold_id"`, `"copy_id"` and + `"desk"`, the URL of the branch desk the execution tells, in `scope`. + """ + @spec request(String.t(), map()) :: {:ok, [StatifierRouter.outcome()]} | {:error, term()} + def request(scope, %{"hold_id" => hold_id} = hold) do + StatifierRouter.route(config(), %{ + scope: scope, + source: @source, + message_id: "hold-requested/" <> hold_id, + data: Map.put(hold, "kind", "hold") + }) + end + + # ------------------------------------------------------- the resolver + + @impl StatifierRouter.Resolver + @spec resolve(String.t(), String.t()) :: StatifierRouter.Resolver.result() + def resolve(_scope, @document_id) do + with {:ok, machine, _scxml} <- compile(), + content_hash = Machine.identity(machine).content_hash, + {:ok, _chart} <- Storage.fetch_chart(FirstWorkflow.store(), content_hash) do + {content_hash, machine} + else + _unregistered -> {:error, :not_published} + end + end + + def resolve(_scope, _document), do: {:error, :not_published} + + @doc "The chart registered under `content_hash`, compiled, or `:error`." + @spec chart(String.t()) :: {:ok, Machine.t()} | :error + def chart(content_hash) do + with {:ok, chart} <- Storage.fetch_chart(FirstWorkflow.store(), content_hash), + {:ok, machine} <- Statifier.compile(chart.chart_blob) do + {:ok, machine} + else + _missing -> :error + end + end + + # ------------------------------------------------------- the executor + + @doc """ + The executor every create and step hands its effects to. A BasicHTTP + `` is planned with `StatifierRouter.BasicHTTP.deliver/3` and each + instruction it plans is performed with the processor's `perform/2`; a + delayed one is refused as `{:delayed_basichttp_send, send_id}`. Every + other effect is passed. + """ + @spec execute(Statifier.Effect.t(), StatifierPersistence.Executor.context()) :: + :ok | {:error, term()} + def execute({:send, %Send{type: type} = send}, %{execution_id: execution_id}) + when type in @basichttp_types do + ctx = %{session_id: execution_id, opts: basichttp()} + event = SendEvent.build(send, execution_id) + {:ok, instructions} = BasicHTTP.deliver(send, event, ctx) + perform(instructions, ctx, send) + end + + def execute({:send_delayed, %SendDelayed{type: type} = send}, _context) + when type in @basichttp_types, + do: {:error, {:delayed_basichttp_send, send.send_id}} + + def execute(_effect, _context), do: :ok + + @spec perform([term()], map(), Send.t()) :: :ok | {:error, term()} + defp perform(instructions, ctx, send) do + Enum.reduce_while(instructions, :ok, fn + {:handler, module, payload}, :ok -> + case module.perform(payload, ctx) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + + _unperformed, :ok -> + {:halt, {:error, {:basichttp_send_not_planned, send.send_id}}} + end) + end + + # ---------------------------------------------------------- the parts + + @spec basichttp() :: keyword() + defp basichttp do + case Keyword.fetch(Application.get_env(:statifier_examples, __MODULE__, []), :transport) do + {:ok, transport} -> [base_url: base_url(), transport: transport] + :error -> [base_url: base_url()] + end + end + + @spec hold_binding() :: map() + defp hold_binding do + %{ + id: @binding_id, + source: @source, + match: ~s(event.kind == "hold"), + key: "event.hold_id", + document: @document_id, + event: "hold.requested", + data: ["hold_id", "copy_id", "desk"] + } + end + + @spec compile() :: {:ok, Machine.t(), String.t()} | {:error, term()} + defp compile do + scxml = :statifier_examples |> :code.priv_dir() |> Path.join(@chart) |> File.read!() + + with {:ok, machine} <- Statifier.compile(scxml), do: {:ok, machine, scxml} + end +end diff --git a/lib/statifier_examples_web/controllers/basic_http_controller.ex b/lib/statifier_examples_web/controllers/basic_http_controller.ex new file mode 100644 index 0000000..5183333 --- /dev/null +++ b/lib/statifier_examples_web/controllers/basic_http_controller.ex @@ -0,0 +1,64 @@ +defmodule StatifierExamplesWeb.BasicHTTPController do + @moduledoc """ + The BasicHTTP front for `StatifierExamples.HoldDesk`'s durable + executions: every request at `/basichttp/:token` is handed to + `StatifierRouter.BasicHTTP.Front.handle/3`, and answered with the + status and headers `StatifierRouter.BasicHTTP.Front.response/1` maps + its answer to - 204 for a delivered event or a duplicate, 404 for a + location that reaches no execution, 405 with `allow: POST` for another + method, 400 for a body the decoder refuses. + + The token is a bearer capability (ruled by the operator, 2026-09-30): + holding it is the whole of the authorization, so this action checks + nothing else. No request line or dispatch log carries it: + `StatifierExamplesWeb.Endpoint.log_level/1` skips the request line for + `/basichttp`, the route is `log: false`, and `:filter_parameters` names + `token`. At `:debug` Ecto's query log prints bound parameters, and the + router's location lookup binds the token, so a host keeps `:debug` out + of production. + """ + + use StatifierExamplesWeb, :controller + + alias StatifierExamples.HoldDesk + alias StatifierRouter.BasicHTTP.Front + + @doc "Hands one request at a location to the front and answers its status." + @spec event(Plug.Conn.t(), map()) :: Plug.Conn.t() + def event(conn, _params) do + {body, conn} = body(conn) + + answer = + Front.handle(HoldDesk.config(), %{ + token: conn.path_params["token"], + method: conn.method, + content_type: header(conn, "content-type"), + body: body, + query: if(conn.query_string == "", do: nil, else: conn.query_string), + send_key: header(conn, "scxml-send-key") + }) + + {status, headers} = Front.response(answer) + + conn + |> merge_resp_headers(headers) + |> send_resp(status, "") + end + + # The body `StatifierExamplesWeb.RawBody` kept when `Plug.Parsers` read + # it, or the body as it is still waiting, for a content type the parsers + # passed. + @spec body(Plug.Conn.t()) :: {binary(), Plug.Conn.t()} + defp body(%Plug.Conn{private: %{raw_body: body}} = conn), do: {body, conn} + + defp body(conn) do + case read_body(conn) do + {:ok, body, conn} -> {body, conn} + {_more_or_error, _partial, conn} -> {"", conn} + {:error, _reason} -> {"", conn} + end + end + + @spec header(Plug.Conn.t(), String.t()) :: String.t() | nil + defp header(conn, name), do: conn |> get_req_header(name) |> List.first() +end diff --git a/lib/statifier_examples_web/endpoint.ex b/lib/statifier_examples_web/endpoint.ex index 3dec257..8a3f140 100644 --- a/lib/statifier_examples_web/endpoint.ex +++ b/lib/statifier_examples_web/endpoint.ex @@ -36,15 +36,25 @@ defmodule StatifierExamplesWeb.Endpoint do end plug Plug.RequestId - plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint], log: {__MODULE__, :log_level, []} plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], + body_reader: {StatifierExamplesWeb.RawBody, :read_body, []}, json_decoder: Phoenix.json_library() plug Plug.MethodOverride plug Plug.Head plug Plug.Session, @session_options plug StatifierExamplesWeb.Router + + @doc """ + The level `Plug.Telemetry` logs a request line at: none for a request + under `/basichttp`, whose path carries a BasicHTTP location's token, a + bearer capability that must not reach a log; `:info` for every other. + """ + @spec log_level(Plug.Conn.t()) :: Logger.level() | false + def log_level(%Plug.Conn{path_info: ["basichttp" | _]}), do: false + def log_level(_conn), do: :info end diff --git a/lib/statifier_examples_web/raw_body.ex b/lib/statifier_examples_web/raw_body.ex new file mode 100644 index 0000000..95b9db7 --- /dev/null +++ b/lib/statifier_examples_web/raw_body.ex @@ -0,0 +1,28 @@ +defmodule StatifierExamplesWeb.RawBody do + @moduledoc """ + The body reader `Plug.Parsers` uses in `StatifierExamplesWeb.Endpoint`, + which keeps the raw body of a request under `/basichttp` in + `conn.private[:raw_body]`. + + A BasicHTTP POST is usually a form body, which `Plug.Parsers` reads and + decodes before any controller runs, and `StatifierRouter.BasicHTTP.Front` + needs the body as it arrived. Every other path reads as before. + """ + + @doc "Reads the body as `Plug.Conn.read_body/2` does, keeping it on a `/basichttp` path." + @spec read_body(Plug.Conn.t(), keyword()) :: + {:ok, binary(), Plug.Conn.t()} | {:more, binary(), Plug.Conn.t()} | {:error, term()} + def read_body(%Plug.Conn{path_info: ["basichttp" | _]} = conn, opts) do + case Plug.Conn.read_body(conn, opts) do + {:ok, body, conn} -> {:ok, body, keep(conn, body)} + {:more, body, conn} -> {:more, body, keep(conn, body)} + other -> other + end + end + + def read_body(conn, opts), do: Plug.Conn.read_body(conn, opts) + + defp keep(conn, body) do + Plug.Conn.put_private(conn, :raw_body, (conn.private[:raw_body] || "") <> body) + end +end diff --git a/lib/statifier_examples_web/router.ex b/lib/statifier_examples_web/router.ex index 3fbd58a..ccd3c72 100644 --- a/lib/statifier_examples_web/router.ex +++ b/lib/statifier_examples_web/router.ex @@ -24,6 +24,14 @@ defmodule StatifierExamplesWeb.Router do live "/signup-journey", SignupJourneyLive end + # The BasicHTTP front: a location of a durable execution. No pipeline: + # the request is a machine's POST, not a browser's, and every method + # reaches the action so the front can answer 405 itself. The dispatch is + # not logged: its path and params carry the location's token. + scope "/basichttp", StatifierExamplesWeb do + match :*, "/:token", BasicHTTPController, :event, log: false + end + # Other scopes may use custom stacks. # scope "/api", StatifierExamplesWeb do # pipe_through :api diff --git a/mix.exs b/mix.exs index 345abb8..da3c536 100644 --- a/mix.exs +++ b/mix.exs @@ -123,7 +123,14 @@ defmodule StatifierExamples.MixProject do # 2.9.0 is REQUIRED: `statifier_router` 0.6.0, below, states # `{:statifier, "~> 2.9"}`. 2.9.0 adds `Statifier.Publish.findings/2` # and `MachineState.last_selection`, and this app reads neither. - {:statifier, "~> 2.9"}, + # + # 2026-09-30: the requirement moves to the 2.10 line, and 2.10.0 is + # REQUIRED: `statifier_router` 0.9.2, below, states + # `{:statifier, "~> 2.10"}`. 2.10.0 ships `Statifier.Send.BasicHTTP`, + # the Basic HTTP Event I/O Processor and its decoder, which + # `StatifierExamples.HoldDesk` sends through and the router's front + # decodes with. + {:statifier, "~> 2.10"}, # A note on every `statifier_persistence` name below, added with # se-20j. These comments record why each floor moved, release by @@ -384,6 +391,14 @@ defmodule StatifierExamples.MixProject do # content-hash query the batch lists with, and still declares no # chart retirement: on this SQLite database `retire_chart/4` answers # `{:error, :chart_retirement_unsupported}`, as the guide shows. + # + # 2026-09-30: `mix.lock` moves to 0.24.0, which the update to + # `statifier_router` 0.9.2 brought with it; the requirement stays. None + # of 0.22.0, 0.23.0 and 0.24.0 adds a migration. 0.22.0 refuses a + # migration plan that keeps a timer for an event the new chart no + # longer handles, 0.23.0 closes a race with a chart retirement this + # adapter does not offer, and 0.24.0 renames the conformance suite's + # helpers, which this app reaches only through the case template. {:statifier_persistence, "~> 0.20"}, # Durable timers. `statifier_oban` never owns an Oban instance @@ -569,7 +584,23 @@ defmodule StatifierExamples.MixProject do # `:unmatched_event` drop and the `reason` on every # `:unregistered_routes` entry reach `StatifierExamples.Publish` # through a `%{route: _, location: _}` match that still holds. - {:statifier_router, "~> 0.6.0"}, + # + # 2026-09-30: the requirement moves to `~> 0.9.2`, and 0.9.2 is + # REQUIRED: it is the first release whose migrations and address + # reaper both run on this app's SQLite database. 0.8.0 added V03, + # which renames the subscription index with an `ALTER INDEX` SQLite + # does not have, so the migration above failed on a fresh database + # until 0.9.1 made V03 do nothing on SQLite; 0.8.0 also wrote + # `Addresses.reap/3`'s stamp and delete with Postgres's `= ANY(...)`, + # which failed `StatifierExamples.RoutedWorkflow.AddressReaper` until + # 0.9.2 wrote them as an IN list. 0.9.0 ships what `StatifierExamples.HoldDesk` + # uses: the `:basichttp` configuration key, a location for each + # execution it creates, `StatifierRouter.BasicHTTP.Front` and the + # location table, which + # `priv/repo/migrations/20260930120001_add_statifier_router_locations.exs` + # creates. 0.7.0's `:unchecked` entry for a `:bindings_resolver` + # reaches nothing here, since no configuration sets one. + {:statifier_router, "~> 0.9.2"}, # The observing/authoring component library, declared DIRECTLY rather # than taken transitively. `statifier_ui` is an OPTIONAL dependency of diff --git a/mix.lock b/mix.lock index 094cc37..60693e6 100644 --- a/mix.lock +++ b/mix.lock @@ -44,12 +44,12 @@ "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "predicator": {:hex, :predicator, "9.4.2", "2f4e52255330657d2bafada287b989357b4975be4c2052bf0d4a4c4a0666168a", [:mix], [], "hexpm", "893c35dc4f982c08d8d045ae742c510cd5b2c3d9d8a447c45ca0e4e52aec9b1e"}, "saxy": {:hex, :saxy, "1.6.1", "742eff28f553c066d0b54e84662dbf384a1d1f38595472ed15f6e0a33038bbe1", [:mix], [], "hexpm", "8989d504424ba29460a61950f8968380651413fa05e63b6118084db057da1a6b"}, - "statifier": {:hex, :statifier, "2.9.0", "ab7605abd7648026f49f940a7eb2bba17b9dff0a7050b70d018d5444356dd0ee", [:mix], [{:predicator, "~> 9.0", [hex: :predicator, repo: "hexpm", optional: false]}, {:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0f790878618aec8f65f8780dae2485bb88ba3bee42b538eee6bad1fd5b90e969"}, + "statifier": {:hex, :statifier, "2.10.0", "d281b4a056dcd4870462b000e0c880abe0ea97948c36151bc47127e6b6cf2012", [:mix], [{:predicator, "~> 9.0", [hex: :predicator, repo: "hexpm", optional: false]}, {:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1ebf8b721547666a5c817c46a813f61973ed303d97ae21781bc41c78ff7f6ed5"}, "statifier_blocks": {:hex, :statifier_blocks, "0.41.0", "e4a94580a3d811bde202dbb6390f119e9a92d8ec6b018bee1c7743185b9d2987", [:mix], [{:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:predicator, "~> 9.4.1", [hex: :predicator, repo: "hexpm", optional: false]}, {:statifier, "~> 2.2", [hex: :statifier, repo: "hexpm", optional: false]}, {:statifier_datamodel, "~> 0.4", [hex: :statifier_datamodel, repo: "hexpm", optional: false]}, {:statifier_ui, "~> 0.9", [hex: :statifier_ui, repo: "hexpm", optional: true]}], "hexpm", "3635344a8296bd70a59f74be3d77009f588cc9c567b857baed8d2864bc2e14d0"}, "statifier_datamodel": {:hex, :statifier_datamodel, "0.5.0", "72a04a1d672e9734b324b5aa164abd49f388cc921283edc22fbe8b22e371eca7", [:mix], [], "hexpm", "1d4e1f5ba659d80713f5c901343bac65ddedea2dfd984666fd60d5ddcd623f2a"}, "statifier_oban": {:hex, :statifier_oban, "0.13.0", "c95b0dd9b82e1c4355cb60085c088e662ecedcc307c6c1f11f4d4c4ace62f153", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:statifier, "~> 2.5", [hex: :statifier, repo: "hexpm", optional: false]}, {:statifier_persistence, "~> 0.13", [hex: :statifier_persistence, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e6ed294eaf9b07b3a05ca420e3d247a867ae316f2dfbba27d470239f64a6bcc"}, - "statifier_persistence": {:hex, :statifier_persistence, "0.21.0", "4c84d498f9fa6d68b3b17bba5b117698f571f207805a912aba447f94933bed06", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:statifier, "~> 2.9", [hex: :statifier, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uxid, "~> 2.0", [hex: :uxid, repo: "hexpm", optional: false]}], "hexpm", "139fe627f85a92c41c30d4437e15b46fb249c25e7f5663fb719cbbcbc7f1e6ae"}, - "statifier_router": {:hex, :statifier_router, "0.6.0", "38381a88c208da1d19168286ba3e7f75029989ee5249cbd45a3a0f4f7d8e6235", [:mix], [{:broadway, "~> 1.3", [hex: :broadway, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:predicator, "~> 9.4", [hex: :predicator, repo: "hexpm", optional: false]}, {:statifier, "~> 2.9", [hex: :statifier, repo: "hexpm", optional: false]}, {:statifier_persistence, "~> 0.18", [hex: :statifier_persistence, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uxid, "~> 2.0", [hex: :uxid, repo: "hexpm", optional: false]}], "hexpm", "0e8b97986c3c75cccc850ae193ab653b88fdc16a98df1442d8ecd7e891b52946"}, + "statifier_persistence": {:hex, :statifier_persistence, "0.24.0", "43cbeb2e2f818e3bb80f636e208892f27049b990773db850fabd9bec44585dfd", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:statifier, "~> 2.9", [hex: :statifier, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uxid, "~> 2.0", [hex: :uxid, repo: "hexpm", optional: false]}], "hexpm", "fa10f4a40d34d9d4db196b8afb1b20b4714e802f6aa4fe6bf95c61aea60cf7ce"}, + "statifier_router": {:hex, :statifier_router, "0.9.2", "d3aaf1e0264fbe6a7a87fac46bee8f808d8da10085437e2fdbb8656adcac71fa", [:mix], [{:broadway, "~> 1.3", [hex: :broadway, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:predicator, "~> 9.4", [hex: :predicator, repo: "hexpm", optional: false]}, {:statifier, "~> 2.10", [hex: :statifier, repo: "hexpm", optional: false]}, {:statifier_persistence, "~> 0.18", [hex: :statifier_persistence, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uxid, "~> 2.0", [hex: :uxid, repo: "hexpm", optional: false]}], "hexpm", "692ab40898c5cf01eae035cf824971d6278f26802ff8ce31ef3450abc83e317c"}, "statifier_ui": {:hex, :statifier_ui, "0.10.1", "fcd918b5259e4c8ce5430c3cf2075f4d13aaa52135cb563713db526b8bec9493", [:mix], [{:kino, "~> 0.14", [hex: :kino, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:predicator, "~> 9.4", [hex: :predicator, repo: "hexpm", optional: false]}, {:statifier, "~> 2.5", [hex: :statifier, repo: "hexpm", optional: false]}, {:statifier_datamodel, "~> 0.4", [hex: :statifier_datamodel, repo: "hexpm", optional: false]}], "hexpm", "cb4992baecefc40af76d587408b71cd94ceecd392374926ecaef07fe49211069"}, "tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, diff --git a/priv/library/hold_desk.scxml b/priv/library/hold_desk.scxml new file mode 100644 index 0000000..d9b1bdd --- /dev/null +++ b/priv/library/hold_desk.scxml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/priv/repo/migrations/20260930120001_add_statifier_router_locations.exs b/priv/repo/migrations/20260930120001_add_statifier_router_locations.exs new file mode 100644 index 0000000..bb408c4 --- /dev/null +++ b/priv/repo/migrations/20260930120001_add_statifier_router_locations.exs @@ -0,0 +1,23 @@ +defmodule StatifierExamples.Repo.Migrations.AddStatifierRouterLocations do + @moduledoc """ + `statifier_router`'s location table (its V04), which only a router + configuration that sets `:basichttp` needs: `StatifierExamples.HoldDesk` + keeps each execution's BasicHTTP location token there, beside its + address row. + + The table is opt-in and outside the router's version walk, so it is its + own migration after `20260925120001_add_statifier_router.exs`, with the + same `depot_id` leading column that migration gives every router table. + It references the address table, and Ecto's rollback undoes this + migration first. `down_locations/1` drops the table only if it is + there. It runs on SQLite, with no `:prefix`. + """ + + use Ecto.Migration + + @opts [leading_columns: [depot_id: {:text, null: true}]] + + def up, do: StatifierRouter.Migrations.up_locations(@opts) + + def down, do: StatifierRouter.Migrations.down_locations(@opts) +end diff --git a/test/statifier_examples/first_workflow_test.exs b/test/statifier_examples/first_workflow_test.exs index 3b19af5..40217db 100644 --- a/test/statifier_examples/first_workflow_test.exs +++ b/test/statifier_examples/first_workflow_test.exs @@ -32,7 +32,7 @@ defmodule StatifierExamples.FirstWorkflowTest do assert {:ok, lines} = FirstWorkflow.run(execution_id: execution_id) assert [ - "first workflow on statifier 2.9." <> _pins, + "first workflow on statifier 2.10." <> _pins, "expressible bdoc_hold_pickup against the host palette", "published every publish-time check passed, 0 warning(s)", "registered chart sha256:" <> _hash, diff --git a/test/statifier_examples/migrate_waiting_test.exs b/test/statifier_examples/migrate_waiting_test.exs index f87e746..b7134c0 100644 --- a/test/statifier_examples/migrate_waiting_test.exs +++ b/test/statifier_examples/migrate_waiting_test.exs @@ -30,7 +30,7 @@ defmodule StatifierExamples.MigrateWaitingTest do assert {:ok, lines} = MigrateWaiting.walk(loans: loans) assert [ - "migrate waiting on statifier_persistence 0.21." <> _patch, + "migrate waiting on statifier_persistence 0.24." <> _patch, "waiting 2 loan(s) on revision 1, sha256:" <> _old, "published revision 2, sha256:" <> _new, "diff breaking (5 unresolved) with the blocks mapping, " <> diff --git a/test/statifier_examples/mix_deps_test.exs b/test/statifier_examples/mix_deps_test.exs index 5755750..c4195f8 100644 --- a/test/statifier_examples/mix_deps_test.exs +++ b/test/statifier_examples/mix_deps_test.exs @@ -682,10 +682,17 @@ defmodule StatifierExamples.MixDepsTest do # this app reads neither. Sabotage: pointed the LOCK assertion back # at `"2.8.` and left `mix.lock` alone; it went red reporting the resolved # 2.9.0 entry. Reverted from a copy. + # + # 2026-09-30: the engine moves to the 2.10 line, and 2.10.0 is REQUIRED: + # `statifier_router` 0.9.2 states `{:statifier, "~> 2.10"}`, the release + # that ships the Basic HTTP Event I/O Processor and its decoder, which + # `StatifierExamples.HoldDesk` sends through. Sabotage: pointed the LOCK + # assertion back at `"2.9.` and left `mix.lock` alone; it went red + # reporting the resolved 2.10.0 entry. Reverted from a copy. test "the statifier dep is the Hex requirement, with no override" do deps = Mix.Project.config()[:deps] - assert {:statifier, "~> 2.9"} in deps + assert {:statifier, "~> 2.10"} in deps lock_line = "mix.lock" @@ -694,7 +701,7 @@ defmodule StatifierExamples.MixDepsTest do |> Enum.find(&String.starts_with?(&1, ~s( "statifier": ))) assert lock_line, "statifier has no mix.lock entry" - assert lock_line =~ ~s({:hex, :statifier, "2.9.) + assert lock_line =~ ~s({:hex, :statifier, "2.10.) end # The durable stepper. 0.3.0 was the floor two release lines back, as @@ -919,6 +926,15 @@ defmodule StatifierExamples.MixDepsTest do # Sabotage: pointed the LOCK assertion back at `"0.19.` and left # `mix.lock` alone; it went red reporting the resolved 0.21.0 entry. # Reverted from a copy. + # + # 2026-09-30: the requirement stays `~> 0.20`, and `mix.lock` resolves + # 0.24.0, which the update to `statifier_router` 0.9.2 brought with it. + # None of 0.22.0, 0.23.0 and 0.24.0 adds a migration; 0.24.0 renames the + # conformance suite's helpers, which this app's conformance test uses + # only through the case template; the migration guide's test holds the + # one plan it builds against 0.22.0's new `migrate/4` refusal. Sabotage: + # pointed the LOCK assertion back at `"0.21.` and left `mix.lock` alone; + # it went red reporting the resolved 0.24.0 entry. Reverted from a copy. test "the statifier_persistence dep is the Hex requirement" do deps = Mix.Project.config()[:deps] @@ -931,7 +947,7 @@ defmodule StatifierExamples.MixDepsTest do |> Enum.find(&String.starts_with?(&1, ~s( "statifier_persistence": ))) assert lock_line, "statifier_persistence has no mix.lock entry" - assert lock_line =~ ~s({:hex, :statifier_persistence, "0.21.) + assert lock_line =~ ~s({:hex, :statifier_persistence, "0.24.) refute lock_line =~ ":git," end @@ -962,10 +978,19 @@ defmodule StatifierExamples.MixDepsTest do # through a `%{route: _, location: _}` match that still holds. Sabotage: # pointed the LOCK assertion back at `"0.4.` and left `mix.lock` alone; it # went red reporting the resolved 0.6.0 entry. Reverted from a copy. + # + # 2026-09-30: the requirement moves to `~> 0.9.2`, and 0.9.2 is REQUIRED: + # 0.9.0 ships the BasicHTTP front and the location table + # `StatifierExamples.HoldDesk` uses, and 0.9.2 is the first release whose + # migrations and address reaper both run on this app's SQLite database + # (0.8.0's V03 and its `Addresses.reap/3` did not). Sabotage: pointed the + # LOCK assertion back at `"0.6.` and left `mix.lock` alone; it went red + # reporting the resolved 0.9.2 entry. + # Reverted from a copy. test "the statifier_router dep is the Hex requirement" do deps = Mix.Project.config()[:deps] - assert {:statifier_router, "~> 0.6.0"} in deps + assert {:statifier_router, "~> 0.9.2"} in deps lock_line = "mix.lock" @@ -974,7 +999,7 @@ defmodule StatifierExamples.MixDepsTest do |> Enum.find(&String.starts_with?(&1, ~s( "statifier_router": ))) assert lock_line, "statifier_router has no mix.lock entry" - assert lock_line =~ ~s({:hex, :statifier_router, "0.6.) + assert lock_line =~ ~s({:hex, :statifier_router, "0.9.2) refute lock_line =~ ":git," end diff --git a/test/statifier_examples/routed_workflow_test.exs b/test/statifier_examples/routed_workflow_test.exs index 299f04f..9b439f2 100644 --- a/test/statifier_examples/routed_workflow_test.exs +++ b/test/statifier_examples/routed_workflow_test.exs @@ -36,7 +36,7 @@ defmodule StatifierExamples.RoutedWorkflowTest do assert {:ok, lines} = RoutedWorkflow.run(parcel_id: "parcel-test") assert [ - "first workflow routed on statifier_router 0.6." <> _pins, + "first workflow routed on statifier_router 0.9." <> _pins, "migrated 4 router tables, depot_id at position 2 on each", "published bdoc_parcel_route accepts parcel.scanned; " <> "every publish-time check passed, 0 warning(s)", diff --git a/test/statifier_examples_web/controllers/basic_http_controller_test.exs b/test/statifier_examples_web/controllers/basic_http_controller_test.exs new file mode 100644 index 0000000..455ef75 --- /dev/null +++ b/test/statifier_examples_web/controllers/basic_http_controller_test.exs @@ -0,0 +1,179 @@ +defmodule StatifierExamplesWeb.BasicHTTPControllerTest do + use StatifierExamplesWeb.ConnCase, async: false + + import ExUnit.CaptureLog + + alias StatifierExamples.{FirstWorkflow, HoldDesk, RoutedWorkflow} + alias StatifierPersistence.{Execution, Executions, Storage} + alias StatifierRouter.BasicHTTP + + # A patron's hold on a copy at the Riverside branch: the execution tells + # the desk it was placed, handing it the location to answer at, and the + # desk posts copy.shelved there through the controller. + + @scope "branch_riverside" + @desk "https://riverside.example/holds-desk" + @form "application/x-www-form-urlencoded" + + setup do + {:ok, _content_hash} = HoldDesk.register() + :ok + end + + defp placed_hold(hold_id \\ "hold-0417") do + assert {:ok, [{:created_and_delivered, "hold_requests", execution_id}]} = + HoldDesk.request(@scope, %{ + "hold_id" => hold_id, + "copy_id" => "copy-2231", + "desk" => @desk + }) + + assert_received {:desk_post, @desk, headers, body} + {execution_id, headers, URI.decode_query(body)} + end + + defp token(location), do: String.replace_prefix(location, HoldDesk.base_url() <> "/", "") + + defp post_event(conn, location, body, headers \\ []) do + conn = + Enum.reduce([{"content-type", @form} | headers], conn, fn {name, value}, conn -> + put_req_header(conn, name, value) + end) + + post(conn, "/basichttp/" <> token(location), body) + end + + defp status!(execution_id) do + {:ok, record} = Storage.fetch_execution(FirstWorkflow.store(), execution_id) + Execution.from_record(record).status + end + + # sabotage: the :basichttp key dropped from HoldDesk.config/0 -> the + # chart's basichttp send was an unsupported type, no desk_post arrived + # and the route answered an error, red; restored, green. + # sabotage: execute/2's BasicHTTP clause made to answer :ok without + # performing -> assert_received {:desk_post, ...} failed, red; restored, + # green. + test "the hold tells the desk it was placed, with its own location to answer at" do + {execution_id, headers, params} = placed_hold() + + assert params["_scxmleventname"] == "hold.placed" + assert params["hold_id"] == "hold-0417" + assert params["copy_id"] == "copy-2231" + assert {:ok, location} = BasicHTTP.location(HoldDesk.config(), execution_id) + assert params["reply_to"] == location + assert String.starts_with?(location, HoldDesk.base_url() <> "/") + refute String.contains?(location, execution_id) + assert {"content-type", @form} in headers + assert Enum.any?(headers, &match?({"scxml-send-key", _key}, &1)) + assert status!(execution_id) == :active + end + + # sabotage: the controller handed the front the parsed body ("") instead + # of the raw one -> the event decoded as HTTP.POST, the execution stayed + # active, red; restored, green. + # sabotage: Front.response/1's status replaced with a constant 200 in the + # controller -> red on the 204; restored, green. + test "a POST at the location through the controller delivers the desk's event", %{conn: conn} do + {execution_id, _headers, %{"reply_to" => location}} = placed_hold() + + conn = post_event(conn, location, "_scxmleventname=copy.shelved") + + assert response(conn, 204) == "" + assert status!(execution_id) == :completed + + assert {:ok, [_requested, %{event: %{name: "copy.shelved"}}]} = + Executions.inputs(FirstWorkflow.store(), execution_id) + end + + test "a finished hold and an unknown location answer 404", %{conn: conn} do + {_execution_id, _headers, %{"reply_to" => location}} = placed_hold() + + assert conn |> post_event(location, "_scxmleventname=copy.shelved") |> response(204) + + assert build_conn() |> post_event(location, "_scxmleventname=copy.shelved") |> response(404) + + unknown = HoldDesk.base_url() <> "/" <> BasicHTTP.mint_token() + assert build_conn() |> post_event(unknown, "_scxmleventname=copy.shelved") |> response(404) + end + + # Ecto's own query lines are set aside: at :debug they print bound + # parameters, which the guide says. They also prove the capture is live. + # sabotage: the endpoint's Plug.Telemetry log option removed -> the + # request line carried the token, red; restored, green. + # sabotage: the route's log: false removed and "token" dropped from + # :filter_parameters -> the dispatch params carried the token, red; + # restored, green. + test "a POST at the location leaves the token out of the request log", %{conn: conn} do + {_execution_id, _headers, %{"reply_to" => location}} = placed_hold() + level = Logger.level() + Logger.configure(level: :debug) + on_exit(fn -> Logger.configure(level: level) end) + + log = + capture_log([level: :debug], fn -> + assert conn |> post_event(location, "_scxmleventname=copy.shelved") |> response(204) + end) + + {queries, others} = + log + |> String.split(~r/^(?=\d{2}:\d{2}:\d{2}\.\d{3} )/m, trim: true) + |> Enum.split_with(&(&1 =~ ~r/\] QUERY (OK|ERROR)/)) + + assert queries != [] + refute Enum.any?(others, &(&1 =~ token(location))) + end + + # sabotage: the controller handed the front "POST" whatever the method + # -> the GET was delivered and answered 204, red; restored, green. + test "another method answers 405 with allow: POST", %{conn: conn} do + {execution_id, _headers, %{"reply_to" => location}} = placed_hold() + + conn = get(conn, "/basichttp/" <> token(location)) + + assert response(conn, 405) == "" + assert get_resp_header(conn, "allow") == ["POST"] + assert status!(execution_id) == :active + end + + test "a malformed send key answers 400 and delivers nothing", %{conn: conn} do + {execution_id, _headers, %{"reply_to" => location}} = placed_hold() + + conn = + post_event(conn, location, "_scxmleventname=copy.shelved", [ + {"scxml-send-key", "not-eight-fields"} + ]) + + assert response(conn, 400) == "" + assert status!(execution_id) == :active + end + + test "a repeated send key is delivered once", %{conn: conn} do + {execution_id, _headers, %{"reply_to" => location}} = placed_hold() + key = [{"scxml-send-key", "sess_desk/shelved_1/1/1/0/0/onentry.0.0/0"}] + + assert conn |> post_event(location, "_scxmleventname=noted", key) |> response(204) + assert build_conn() |> post_event(location, "_scxmleventname=noted", key) |> response(204) + + assert {:ok, [_requested, %{event: %{name: "noted"}}]} = + Executions.inputs(FirstWorkflow.store(), execution_id) + end + + # sabotage: execute/2 made to answer :ok whatever perform/2 answered -> + # the execution stayed active in waiting and the location still took a + # POST, red; restored, green. + test "a desk that refuses the POST ends the hold unreached", %{conn: conn} do + Process.put(:desk_status, 503) + {execution_id, _headers, %{"reply_to" => location}} = placed_hold("hold-0418") + + assert status!(execution_id) == :completed + assert conn |> post_event(location, "_scxmleventname=copy.shelved") |> response(404) + end + + # sabotage: :basichttp added to RoutedWorkflow's configuration -> red; + # restored, green. + test "the parcel configuration carries no BasicHTTP location" do + assert RoutedWorkflow.config().basichttp == nil + assert HoldDesk.config().basichttp[:base_url] == HoldDesk.base_url() + end +end diff --git a/test/support/desk_transport.ex b/test/support/desk_transport.ex new file mode 100644 index 0000000..295482f --- /dev/null +++ b/test/support/desk_transport.ex @@ -0,0 +1,17 @@ +defmodule StatifierExamples.DeskTransport do + @moduledoc """ + The test transport for `StatifierExamples.HoldDesk`'s outbound + BasicHTTP POSTs: it sends `{:desk_post, url, headers, body}` to the + process performing the POST, which is the test process that routed the + hold request, and answers the status the process put under + `:desk_status`, 204 as a branch desk would when it put none. + """ + + @behaviour Statifier.Send.BasicHTTP.Transport + + @impl true + def post(url, headers, body) do + send(self(), {:desk_post, url, headers, body}) + {:ok, Process.get(:desk_status, 204)} + end +end