From c0e27b4276e46af0ba4a13f7353683420ae80f81 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sun, 20 Sep 2026 10:54:17 +0200 Subject: [PATCH] Move HTTP from cowboy to livery, and serve HTTP/2 and HTTP/3 Serve HTTP through livery instead of cowboy, and drive the test suites with livery_client instead of hackney. - The listener is owned by a new supervised hornbeam_listener; hornbeam:info/0 and hornbeam:is_running/0 replace ranch introspection. - hornbeam_handler is a livery handler. WSGI and ASGI responses resolve through livery_resp:stream_deferred/1 in the per-request process, so request-body chunks and Python events share one mailbox. - WebSocket runs on the ws_handler behaviour (new hornbeam_ws_handler); hornbeam_websocket keeps the upgrade entry point, scope building, and the session registry. - Duplicate request headers are preserved in the ASGI scope and joined with ", " in WSGI HTTP_* keys. - New max_body option (default infinity) caps the request body. http_version selects which protocols to serve. The option was documented but read by nothing; it now drives which listeners bind, defaulting to ['HTTP/1.1']. 'HTTP/2' and 'HTTP/3' exist only over TLS and are refused when ssl is off rather than quietly downgraded. - ['HTTP/1.1', 'HTTP/2'] takes livery's ALPN listener, so one TLS port serves both and a client that will not speak h2 still gets HTTP/1.1. - 'HTTP/3' adds a QUIC listener, on the bind port number by default or on the new http3_port, advertised through Alt-Svc. Its cert and key are converted from the configured PEM files to the DER shapes quic wants. - Each listener carries its own handler state, so server_scheme and server_port describe the listener that took the request rather than the service as a whole. - max_request_line_size, max_header_size and max_headers now reach h1, which answers 414 or 431 instead of ignoring them. Fix a crash the HTTP/2 work exposed: the ASGI and WSGI producers only handled {error, closed} from a stream write, so a HEAD response over h2 or h3, which answers {error, invalid_stream_state} where HTTP/1.1 silently drops the body, took the handler down. Runs on livery 0.9.2 and erlang_python 5.0.0. Breaking: routes are livery router entries {Method | '_', Pattern, HandlerFun | {Mod, Fun}} rather than cowboy {Path, HandlerModule, Opts} tuples; cowboy handler modules are rejected. max_header_size and friends changed from inert to enforced. The dead hornbeam_wsgi build_environ path is removed. --- CHANGELOG.md | 75 +++ README.md | 5 +- docs/guides/asgi.md | 6 +- docs/guides/benchmarking.md | 5 +- docs/guides/custom-apps.md | 33 +- docs/guides/erlang-apps.md | 17 +- docs/reference/configuration.md | 46 +- .../embedding_chat/src/embedding_chat_app.erl | 2 +- .../embedding_chat/src/embedding_chat_ws.erl | 62 +-- rebar.config | 7 +- src/hornbeam.app.src | 2 +- src/hornbeam.erl | 431 +++++++++++++----- src/hornbeam_asgi.erl | 379 +++++++-------- src/hornbeam_config.erl | 10 +- src/hornbeam_handler.erl | 401 ++++++++-------- src/hornbeam_listener.erl | 107 +++++ src/hornbeam_request.erl | 167 +++++-- src/hornbeam_sup.erl | 8 + src/hornbeam_websocket.erl | 421 ++--------------- src/hornbeam_ws_handler.erl | 343 ++++++++++++++ src/hornbeam_wsgi.erl | 221 --------- test/hornbeam_SUITE.erl | 6 +- test/hornbeam_asgi_SUITE.erl | 148 +++--- test/hornbeam_examples_smoke_SUITE.erl | 2 +- test/hornbeam_lifespan_SUITE.erl | 30 +- test/hornbeam_multiapp_SUITE.erl | 23 +- test/hornbeam_test_certs.erl | 46 ++ test/hornbeam_test_http.erl | 51 +++ test/hornbeam_tls_SUITE.erl | 274 +++++++++++ test/hornbeam_wsgi_SUITE.erl | 86 ++-- test/test_apps/asgi_test_app.py | 6 +- 31 files changed, 2063 insertions(+), 1357 deletions(-) create mode 100644 src/hornbeam_listener.erl create mode 100644 src/hornbeam_ws_handler.erl delete mode 100644 src/hornbeam_wsgi.erl create mode 100644 test/hornbeam_test_certs.erl create mode 100644 test/hornbeam_test_http.erl create mode 100644 test/hornbeam_tls_SUITE.erl diff --git a/CHANGELOG.md b/CHANGELOG.md index d5c6b48..b6b2c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`http_version` selects the protocols to serve** (default + `['HTTP/1.1']`). The option was documented but read by nothing; it now + drives which listeners bind. `'HTTP/2'` and `'HTTP/3'` exist only over + TLS and are refused with + `{error, {http_version_requires_ssl, Version}}` when `ssl` is off, + rather than quietly serving HTTP/1.1. + - `['HTTP/1.1', 'HTTP/2']` serves both from one TLS port, chosen per + connection by ALPN, so a client that cannot speak HTTP/2 is still + served rather than refused. This restores what cowboy's `start_tls` + did before the livery move. + - `'HTTP/3'` adds a QUIC listener, on the `bind` port number by + default or on the new `http3_port`, and advertises it to HTTP/1.1 + and HTTP/2 clients through `Alt-Svc`. Its certificate and key are + converted from the configured PEM files to the DER shapes quic + expects. +- `hornbeam:info/0` reports every bound protocol. Following livery + 0.8.0, `listeners` maps each protocol to a **list** of ports, because + one port can serve two protocols and one protocol can be on several + ports. +- **`max_request_line_size`, `max_header_size` and `max_headers` are + enforced.** They were accepted and documented but reached nothing. A + breach is now answered `414` or `431` instead of being ignored. + +### Fixed + +- **A HEAD response over HTTP/2 or HTTP/3 crashed the handler.** The + ASGI and WSGI producers only handled `{error, closed}` from a stream + write, and h2/h3 answer `{error, invalid_stream_state}` when a body is + written to a stream that must not carry one, where HTTP/1.1 silently + drops it. + ### Changed +- **HTTP server moved from cowboy to livery**: hornbeam now serves HTTP + through [livery](https://github.com/benoitc/livery) instead of cowboy. + - The listener is owned by a new supervised `hornbeam_listener` + process; `hornbeam:info/0` and `hornbeam:is_running/0` replace ranch + introspection. + - `hornbeam_handler` is a livery handler; WSGI and ASGI responses + resolve through `livery_resp:stream_deferred/1` in the per-request + process (one mailbox for request-body chunks and Python events). + - WebSocket runs on the `ws_handler` behaviour (new + `hornbeam_ws_handler`); `hornbeam_websocket` keeps the upgrade + entry point, scope building, and the session registry. + - Duplicate request headers are preserved in the ASGI scope and + joined with `", "` in WSGI `HTTP_*` keys. + - New `max_body` option (default `infinity`) caps the request body at + the listener. + - Requires livery 0.8.0: 103 Early Hints go out through + `livery_req:inform/3`, WSGI `REMOTE_ADDR` and the ASGI `client` / + WebSocket scope carry the real peer, `websocket_max_frame_size` and + `websocket_compress` are forwarded to `livery_ws:upgrade/3`, and + ASGI `websocket.disconnect` reports the peer's own close code + (1005 when it closed without one). +- **HTTP client moved from hackney to livery_client**: the test suites + call `livery_client` directly and match its response map; hackney is no + longer a direct dependency, and the shim that kept hackney's + `request/5` shape is gone. + +### Removed (breaking) + +- **`routes` option shape changed**: routes are livery router entries + `{Method | '_', Pattern, HandlerFun | {Mod, Fun}}` (optionally with a + meta map) instead of cowboy `{Path, HandlerModule, Opts}` tuples. + Cowboy handler modules are rejected with `{error, {invalid_route, _}}`. +- **`hornbeam_wsgi` module removed**: the classic `build_environ/1,2` + path was dead code (the live path is + `hornbeam_request:build_wsgi_tuple/2`). + +### Known limitations + +- 103 Early Hints are sent on HTTP/1.1 and HTTP/2 but not HTTP/3, which + has no interim responses in livery yet; the + `http.response.early_hints` ASGI extension is advertised accordingly. + - **erlang_python v3.0**: Track the simplified execution model - Switched dep to erlang_python `main` (worker / owngil modes only) - `config/sys.config`: replaced obsolete `num_workers` key with `num_contexts` diff --git a/README.md b/README.md index 733b37c..e5bbe09 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The name combines "horn" (unicorn, like gunicorn) with "BEAM" (Erlang VM). - **WSGI Support**: Run standard WSGI Python applications - **ASGI Support**: Run async ASGI Python applications (FastAPI, Starlette, etc.) - **WebSocket**: Full WebSocket support for real-time apps -- **HTTP/2**: Via Cowboy, with multiplexing and server push +- **HTTP via livery**: HTTP/1.1 today; HTTP/2 and HTTP/3 listeners land with upcoming livery releases - **Shared State**: ETS-backed state accessible from Python (concurrent-safe) - **Distributed RPC**: Call functions on remote Erlang nodes - **Pub/Sub**: pg-based publish/subscribe messaging @@ -227,7 +227,8 @@ hornbeam:start("myapp:application", #{ %% Protocol worker_class => wsgi, % wsgi | asgi - http_version => ['HTTP/1.1', 'HTTP/2'], + %% 'HTTP/2' and 'HTTP/3' require ssl => true + http_version => ['HTTP/1.1'], %% Workers workers => 4, diff --git a/docs/guides/asgi.md b/docs/guides/asgi.md index acb0bf1..320096f 100644 --- a/docs/guides/asgi.md +++ b/docs/guides/asgi.md @@ -39,7 +39,7 @@ The scope dict contains request information: |-----|------|-------------| | `type` | str | `"http"` | | `asgi` | dict | `{"version": "3.0"}` | -| `http_version` | str | `"1.1"` or `"2"` | +| `http_version` | str | `"1.1"`, `"2"` or `"3"` | | `method` | str | HTTP method | | `scheme` | str | `"http"` or `"https"` | | `path` | str | URL path (stripped of mount prefix in [multi-app mode](/docs/guides/multi-app)) | @@ -367,8 +367,8 @@ hornbeam:start("app:app", #{ workers => 4, timeout => 30000, - %% HTTP - http_version => ['HTTP/1.1', 'HTTP/2'] + %% HTTP; 'HTTP/2' and 'HTTP/3' require ssl => true + http_version => ['HTTP/1.1'] }). ``` diff --git a/docs/guides/benchmarking.md b/docs/guides/benchmarking.md index f9032d0..56de66e 100644 --- a/docs/guides/benchmarking.md +++ b/docs/guides/benchmarking.md @@ -283,11 +283,12 @@ hornbeam:start("app:application", #{ ### HTTP/2 for Lower Latency -Enable HTTP/2 for multiplexed connections: +Enable HTTP/2 for multiplexed connections. Listing HTTP/1.1 alongside it +serves both from the one TLS port, chosen per connection by ALPN: ```erlang hornbeam:start("app:application", #{ - http_version => ['HTTP/2', 'HTTP/1.1'], + http_version => ['HTTP/1.1', 'HTTP/2'], ssl => true, certfile => "server.crt", keyfile => "server.key" diff --git a/docs/guides/custom-apps.md b/docs/guides/custom-apps.md index b1d5779..5edab30 100644 --- a/docs/guides/custom-apps.md +++ b/docs/guides/custom-apps.md @@ -333,9 +333,12 @@ def distributed_inference(texts): return results ``` -## Custom Cowboy Routes +## Custom Erlang Routes -Add custom routes alongside your Python app: +Add custom routes alongside your Python app. A route is +`{Method | '_', Pattern, Handler}` where Handler is a `fun/1` or +`{Module, Function}` taking a livery request and returning a livery +response: ```erlang %% Start with custom routes @@ -344,13 +347,13 @@ hornbeam:start("app:app", #{ pythonpath => ["priv/python"], routes => [ %% Health check - pure Erlang, no Python - {"/health", health_handler, []}, + {<<"GET">>, <<"/health">>, fun health_handler:handle/1}, %% Metrics endpoint - {"/metrics", metrics_handler, []}, + {<<"GET">>, <<"/metrics">>, {metrics_handler, handle}}, %% WebSocket with custom handler - {"/ws/[...]", my_websocket_handler, []} + {'_', <<"/ws/*rest">>, fun my_websocket_handler:handle/1} ] }). ``` @@ -358,15 +361,19 @@ hornbeam:start("app:app", #{ ```erlang %% health_handler.erl -module(health_handler). --export([init/2]). +-export([handle/1]). -init(Req, State) -> - Reply = cowboy_req:reply(200, - #{<<"content-type">> => <<"application/json">>}, - <<"{\"status\":\"ok\"}">>, - Req - ), - {ok, Reply, State}. +handle(_Req) -> + livery_resp:json(200, <<"{\"status\":\"ok\"}">>). +``` + +A WebSocket route upgrades from inside its handler with a module +implementing the `ws_handler` behaviour: + +```erlang +%% my_websocket_handler.erl +handle(Req) -> + livery_ws:upgrade(Req, my_ws_session, #{idle_timeout => 60000}). ``` ## Configuration diff --git a/docs/guides/erlang-apps.md b/docs/guides/erlang-apps.md index e4117c5..fac2d6c 100644 --- a/docs/guides/erlang-apps.md +++ b/docs/guides/erlang-apps.md @@ -41,13 +41,8 @@ cd my_app {vm_args, "config/vm.args"} ]}. -{profiles, [ - {test, [ - {deps, [ - {hackney, "3.0.2"} - ]} - ]} -]}. +%% livery (already a hornbeam dependency) provides the HTTP client +%% used in the test suite below; no extra test dependency needed. ``` ### Project Structure @@ -519,18 +514,18 @@ def get_from_remote(key): all() -> [test_api]. init_per_suite(Config) -> - {ok, _} = application:ensure_all_started(hackney), {ok, _} = application:ensure_all_started(my_app), Config. end_per_suite(_Config) -> application:stop(my_app), - application:stop(hackney), ok. test_api(_Config) -> - {ok, 200, _Headers, ClientRef} = hackney:request(get, <<"http://localhost:8000/">>, [], <<>>, []), - {ok, Body} = hackney:body(ClientRef), + Client = livery_client:new(#{}), + {ok, Resp} = livery_client:get(Client, <<"http://localhost:8000/">>), + 200 = livery_client:status(Resp), + {full, Body} = livery_client:body(Resp), true = is_binary(Body), ok. ``` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a78c125..8cd13b6 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -85,9 +85,10 @@ See [Multi-App Guide](/docs/guides/multi-app) for detailed usage. |--------|------|---------|-------------| | `bind` | binary/string | `"127.0.0.1:8000"` | Address and port to bind to | | `ssl` | boolean | `false` | Enable SSL/TLS | -| `certfile` | binary | `undefined` | Path to SSL certificate | -| `keyfile` | binary | `undefined` | Path to SSL private key | +| `certfile` | binary | `undefined` | Path to SSL certificate (PEM) | +| `keyfile` | binary | `undefined` | Path to SSL private key (PEM) | | `cacertfile` | binary | `undefined` | Path to CA certificate | +| `http3_port` | integer | `undefined` | UDP port for HTTP/3; defaults to the `bind` port | ### SSL Example @@ -100,6 +101,43 @@ hornbeam:start("app:application", #{ }). ``` +### HTTP versions + +`http_version` picks which protocols to serve. It defaults to +`['HTTP/1.1']`. `'HTTP/2'` and `'HTTP/3'` exist only over TLS, so both +require `ssl => true`; asking for either without it fails at startup with +`{error, {http_version_requires_ssl, Version}}` rather than silently +serving HTTP/1.1. + +```erlang +hornbeam:start("app:application", #{ + bind => "0.0.0.0:443", + ssl => true, + certfile => "/path/to/cert.pem", + keyfile => "/path/to/key.pem", + http_version => ['HTTP/1.1', 'HTTP/2', 'HTTP/3'] +}). +``` + +| Setting | What binds | +|---------|------------| +| `['HTTP/1.1']` | One TCP listener, cleartext or TLS depending on `ssl` | +| `['HTTP/1.1', 'HTTP/2']` | One TLS port serving both, chosen per connection by ALPN | +| `['HTTP/2']` | One TLS port, HTTP/2 only; a client that cannot do h2 is refused | +| `['HTTP/3']` added | A QUIC listener on `http3_port`, advertised to HTTP/1.1 and HTTP/2 clients with `Alt-Svc` | + +HTTP/3 can share the `bind` port number because QUIC is UDP and the other +listeners are TCP. + +`hornbeam:info/0` reports what actually bound: + +```erlang +#{running => true, listeners => #{h1 => [443], h2 => [443], h3 => [443]}} +``` + +Each protocol maps to a list of ports, since one port can serve two +protocols and one protocol can appear on more than one port. + ## Protocol Options | Option | Type | Default | Description | @@ -159,6 +197,10 @@ hornbeam:start("app:app", #{ | `max_header_size` | integer | `8190` | Max HTTP header value length | | `max_headers` | integer | `100` | Max number of HTTP headers | +A request over the request-line limit is answered `414 URI Too Long`; one +over either header limit is answered `431 Request Header Fields Too Large`. +These apply to the HTTP/1.1 and ALPN listeners. + ## Python Options | Option | Type | Default | Description | diff --git a/examples/embedding_chat/src/embedding_chat_app.erl b/examples/embedding_chat/src/embedding_chat_app.erl index 7feb541..b979c65 100644 --- a/examples/embedding_chat/src/embedding_chat_app.erl +++ b/examples/embedding_chat/src/embedding_chat_app.erl @@ -55,7 +55,7 @@ start(_StartType, _StartArgs) -> pythonpath => [priv_dir(), VenvSitePackages], %% /ws handled by Erlang, rest by FastAPI routes => [ - {"/ws", embedding_chat_ws, #{}} + {'_', <<"/ws">>, fun embedding_chat_ws:handle/1} ] }), diff --git a/examples/embedding_chat/src/embedding_chat_ws.erl b/examples/embedding_chat/src/embedding_chat_ws.erl index 05bcaad..8ae3dcb 100644 --- a/examples/embedding_chat/src/embedding_chat_ws.erl +++ b/examples/embedding_chat/src/embedding_chat_ws.erl @@ -12,19 +12,23 @@ %% See the License for the specific language governing permissions and %% limitations under the License. -%%% @doc WebSocket echo handler using cowboy_websocket. +%%% @doc WebSocket echo handler using the ws_handler behaviour. %%% %%% Pure Erlang WebSocket handler that echoes messages. %%% Demonstrates handling WebSocket in Erlang while HTTP is Python/FastAPI. +%%% Mounted through hornbeam's `routes' option; the route handler +%%% upgrades the request via livery_ws:upgrade/3. -module(embedding_chat_ws). --behaviour(cowboy_websocket). +-behaviour(ws_handler). +%% Route handler (HTTP side) +-export([handle/1]). +%% ws_handler callbacks -export([init/2]). --export([websocket_init/1]). --export([websocket_handle/2]). --export([websocket_info/2]). --export([terminate/3]). +-export([handle_in/2]). +-export([handle_info/2]). +-export([terminate/2]). -record(state, { count = 0 :: non_neg_integer(), @@ -32,21 +36,22 @@ }). %%% ============================================================================ -%%% Cowboy WebSocket callbacks +%%% Route handler %%% ============================================================================ -init(Req, Opts) -> - {cowboy_websocket, Req, Opts, #{ - idle_timeout => 60000, - max_frame_size => 65536 - }}. +handle(Req) -> + livery_ws:upgrade(Req, ?MODULE, #{idle_timeout => 60000}). -websocket_init(_Opts) -> +%%% ============================================================================ +%%% ws_handler callbacks +%%% ============================================================================ + +init(_Req, _Opts) -> State = #state{connected_at = os:timestamp()}, Welcome = <<"Connected to Erlang WebSocket echo server!">>, - {[{text, Welcome}], State}. + {reply, {text, Welcome}, State}. -websocket_handle({text, Text}, #state{count = Count} = State) -> +handle_in({text, Text}, #state{count = Count} = State) -> NewCount = Count + 1, Reply = iolist_to_binary([ <<"[Erlang echo #">>, @@ -54,30 +59,31 @@ websocket_handle({text, Text}, #state{count = Count} = State) -> <<"] ">>, Text ]), - {[{text, Reply}], State#state{count = NewCount}}; + {reply, {text, Reply}, State#state{count = NewCount}}; -websocket_handle({binary, Data}, #state{count = Count} = State) -> +handle_in({binary, Data}, #state{count = Count} = State) -> NewCount = Count + 1, - {[{binary, Data}], State#state{count = NewCount}}; + {reply, {binary, Data}, State#state{count = NewCount}}; -websocket_handle({ping, Data}, State) -> - {[{pong, Data}], State}; +handle_in({ping, _Data}, State) -> + %% ws auto-replies to pings before this callback + {ok, State}; -websocket_handle(_Frame, State) -> +handle_in(_Frame, State) -> {ok, State}. -websocket_info({send, Text}, State) when is_binary(Text) -> - {[{text, Text}], State}; +handle_info({send, Text}, State) when is_binary(Text) -> + {reply, {text, Text}, State}; -websocket_info(close, State) -> - {[{close, 1000, <<"Server closing">>}], State}; +handle_info(close, State) -> + {reply, {close, 1000, <<"Server closing">>}, State}; -websocket_info(_Info, State) -> +handle_info(_Info, State) -> {ok, State}. -terminate(_Reason, _Req, #state{count = Count, connected_at = ConnectedAt}) -> +terminate(_Reason, #state{count = Count, connected_at = ConnectedAt}) -> Duration = timer:now_diff(os:timestamp(), ConnectedAt) div 1000000, io:format("WebSocket closed: ~p messages in ~p seconds~n", [Count, Duration]), ok; -terminate(_Reason, _Req, _State) -> +terminate(_Reason, _State) -> ok. diff --git a/rebar.config b/rebar.config index c3b2ccf..91ad8b4 100644 --- a/rebar.config +++ b/rebar.config @@ -15,12 +15,12 @@ unmatched_returns, error_handling ]}, - {plt_extra_apps, [cowboy, ranch]} + {plt_extra_apps, [livery, ws]} ]}. {deps, [ - {cowboy, "2.12.0"}, - {erlang_python, "3.1.1"} + {livery, "~> 0.9.2"}, + {erlang_python, "5.0.0"} ]}. {shell, [ @@ -39,7 +39,6 @@ {profiles, [ {test, [ {deps, [ - {hackney, "3.2.1"}, {jsx, "3.1.0"} ]} ]} diff --git a/src/hornbeam.app.src b/src/hornbeam.app.src index 5add815..4bfc894 100644 --- a/src/hornbeam.app.src +++ b/src/hornbeam.app.src @@ -6,7 +6,7 @@ {applications, [kernel, stdlib, - cowboy, + livery, erlang_python ]}, {env,[ diff --git a/src/hornbeam.erl b/src/hornbeam.erl index e31e5c4..1c0f2ce 100644 --- a/src/hornbeam.erl +++ b/src/hornbeam.erl @@ -15,7 +15,7 @@ %%% @doc Main API for hornbeam WSGI/ASGI server. %%% %%% Hornbeam is an Erlang-based WSGI/ASGI server that uses erlang-python -%%% for Python execution and Cowboy for HTTP handling. +%%% for Python execution and livery for HTTP handling. %%% %%% == Quick Start == %%% @@ -42,7 +42,7 @@ %%% {"/admin", "admin:app", #{worker_class => wsgi}}, %%% {"/", "frontend:app", #{worker_class => wsgi}} %%% ], -%%% routes => [{"/health", health_handler, #{}}] %% optional Erlang handlers +%%% routes => [{<<"GET">>, "/health", fun health_handler:handle/1}] %% optional Erlang handlers %%% }). %%% %%% %% Register Erlang functions callable from Python @@ -57,6 +57,8 @@ start/1, start/2, stop/0, + info/0, + is_running/0, register_function/2, register_function/3, unregister_function/1 @@ -69,6 +71,7 @@ num_contexts => pos_integer(), num_acceptors => pos_integer(), worker_class => wsgi | asgi, + http_version => [http_version()], context_mode => worker | owngil, timeout => pos_integer(), keepalive => pos_integer(), @@ -82,12 +85,15 @@ websocket_timeout => pos_integer(), websocket_max_frame_size => pos_integer(), websocket_compress => boolean(), - routes => [{Path :: binary(), Handler :: module(), Opts :: map()}], + max_body => non_neg_integer() | infinity, + routes => [route()], %% SSL/TLS ssl => boolean(), certfile => string() | binary() | undefined, keyfile => string() | binary() | undefined, cacertfile => string() | binary() | undefined, + %% UDP port for the HTTP/3 listener; defaults to the `bind' port + http3_port => inet:port_number(), %% HTTP lifecycle hooks hooks => #{ on_request => fun((map()) -> map()), @@ -95,11 +101,19 @@ on_error => fun((term(), map()) -> {integer(), binary()}) } }. +-type route() :: + {Method :: binary() | '_', Pattern :: binary() | string(), + Handler :: fun((term()) -> term()) | {module(), atom()}} | + {Method :: binary() | '_', Pattern :: binary() | string(), + Handler :: fun((term()) -> term()) | {module(), atom()}, Meta :: map()}. +%% `HTTP/2' and `HTTP/3' are TLS-only, so they require `ssl => true'. +-type http_version() :: 'HTTP/1.1' | 'HTTP/2' | 'HTTP/3'. -type multi_app_config() :: #{ mounts := [mount_spec()], - routes => [{Path :: binary(), Handler :: module(), Opts :: map()}], + routes => [route()], bind => string() | binary(), num_acceptors => pos_integer(), + http_version => [http_version()], pythonpath => [string() | binary()], venv => string() | binary() | undefined, %% SSL/TLS @@ -107,6 +121,7 @@ certfile => string() | binary() | undefined, keyfile => string() | binary() | undefined, cacertfile => string() | binary() | undefined, + http3_port => inet:port_number(), %% HTTP lifecycle hooks hooks => #{ on_request => fun((map()) -> map()), @@ -115,7 +130,8 @@ } }. --export_type([app_spec/0, options/0, mount_spec/0, multi_app_config/0]). +-export_type([app_spec/0, options/0, mount_spec/0, multi_app_config/0, route/0, + http_version/0]). %% @doc Start hornbeam with a WSGI/ASGI application or multi-app config. %% @@ -135,8 +151,12 @@ start(AppSpec) when is_list(AppSpec); is_binary(AppSpec) -> %% -spec start(app_spec(), options()) -> ok | {error, term()}. start(AppSpec, Options) -> @@ -209,9 +229,24 @@ stop() -> _ = hornbeam_lifespan:shutdown(), %% Stop the HTTP listener - _ = ranch:stop_listener(hornbeam_http), + _ = hornbeam_listener:stop_service(), ok. +%% @doc Listener status: `#{running := boolean(), listeners := map()}'. +%% +%% `listeners' maps each running protocol (`h1', `h2', `h3') to the list +%% of ports serving it. A list because the mapping is many-to-many: an +%% ALPN listener serves `h1' and `h2' from one port, and `h1' can also be +%% on a second, cleartext port beside it. +-spec info() -> #{running := boolean(), listeners := #{h1 | h2 | h3 => [inet:port_number()]}}. +info() -> + hornbeam_listener:info(). + +%% @doc Whether the HTTP listener is running. +-spec is_running() -> boolean(). +is_running() -> + hornbeam_listener:is_running(). + %% @doc Register an Erlang function to be callable from Python. %% The function should accept a list of arguments and return a term. %% @@ -469,52 +504,13 @@ run_lifespan_for_mounts([Mount | Rest], Opts) -> %% @private %% Start the HTTP listener in multi-app mode start_listener_multi(Config) -> - {Ip, Port} = parse_bind(maps:get(bind, Config)), - NumAcceptors = maps:get(num_acceptors, Config, 100), - - %% Build custom routes (WebSocket handlers, etc.) - CustomRoutes = maps:get(routes, Config, []), - %% Multi-app handler state - lookups mount per request %% Lifespan state cached at startup (shared across mounts) HandlerState = #{ multi_app => true, lifespan_state => hornbeam_lifespan:get_state() }, - - %% Default catchall route for Python apps (routes to mounts) - DefaultRoute = {'_', hornbeam_handler, HandlerState}, - - %% Combine custom routes with default (custom routes take precedence) - AllRoutes = CustomRoutes ++ [DefaultRoute], - - Dispatch = cowboy_router:compile([ - {'_', AllRoutes} - ]), - - %% Build protocol options including request limits - ProtoOpts = #{ - env => #{dispatch => Dispatch}, - idle_timeout => maps:get(keepalive, Config, 2) * 1000, - request_timeout => maps:get(timeout, Config, 30000), - max_request_line_length => maps:get(max_request_line_size, Config, 4094), - max_header_value_length => maps:get(max_header_size, Config, 8190), - max_headers => maps:get(max_headers, Config, 100) - }, - - %% Start with SSL/TLS or plain HTTP - case maps:get(ssl, Config, false) of - true -> - start_tls_listener(Ip, Port, Config, ProtoOpts); - false -> - TransportOpts = #{ - num_acceptors => NumAcceptors, - socket_opts => [{port, Port}, {ip, Ip}] - }, - {ok, _} = cowboy:start_clear(hornbeam_http, - TransportOpts, ProtoOpts), - ok - end. + start_service(Config, HandlerState). default_config() -> #{ @@ -522,6 +518,7 @@ default_config() -> %% num_contexts defaults to erlang:system_info(schedulers) in ensure_python_runtime num_acceptors => 100, worker_class => wsgi, + http_version => ['HTTP/1.1'], timeout => 30000, keepalive => 2, max_requests => 1000, @@ -646,87 +643,303 @@ maybe_run_lifespan_startup(wsgi, _Config) -> ok. start_listener(Config) -> - {Ip, Port} = parse_bind(maps:get(bind, Config)), - NumAcceptors = maps:get(num_acceptors, Config, 100), - WorkerClass = maps:get(worker_class, Config), - - %% Build custom routes (WebSocket handlers, etc.) - CustomRoutes = maps:get(routes, Config, []), - %% Cache frequently accessed config values in handler state to avoid %% repeated ETS lookups per request %% Lifespan state is fetched once here since it doesn't change after startup HandlerState = #{ - worker_class => WorkerClass, + worker_class => maps:get(worker_class, Config), app_module => maps:get(app_module, Config), app_callable => maps:get(app_callable, Config), timeout => maps:get(timeout, Config, 30000), lifespan_state => hornbeam_lifespan:get_state() }, + start_service(Config, HandlerState). - %% Default catchall route for Python app - DefaultRoute = {'_', hornbeam_handler, HandlerState}, - - %% Combine custom routes with default (custom routes take precedence) - AllRoutes = CustomRoutes ++ [DefaultRoute], +%% @private +%% Build the livery service opts from hornbeam config and start it under +%% the supervised hornbeam_listener owner. The handler state is exposed to +%% request handlers via livery_req:config/1. +start_service(Config, HandlerState0) -> + {Ip, Port} = parse_bind(maps:get(bind, Config, <<"127.0.0.1:8000">>)), + Ssl = maps:get(ssl, Config, false), + Versions = maps:get(http_version, Config, ['HTTP/1.1']), + BaseOpts = #{ + port => Port, + ip => Ip, + acceptors => maps:get(num_acceptors, Config, 100), + idle_timeout => maps:get(keepalive, Config, 2) * 1000, + request_timeout => maps:get(timeout, Config, 30000), + %% No request-body cap by default: hornbeam streamed bodies + %% unbounded under cowboy; livery's own default is 16 MiB. + max_body => maps:get(max_body, Config, infinity) + }, + with_ok([ + fun() -> validate_versions(Versions, Ssl) end, + fun() -> build_listeners(Versions, Ssl, Config, BaseOpts, HandlerState0) end, + fun(Listeners) -> merge_routing(Config, Listeners) end + ]). - Dispatch = cowboy_router:compile([ - {'_', AllRoutes} - ]), +%% @private +%% Thread a list of steps, short-circuiting on the first error. The first +%% step takes no argument; each later one takes the previous step's value. +with_ok([First | Rest]) -> + with_ok(Rest, First()). + +with_ok(_Steps, {error, _} = Error) -> + Error; +with_ok([], Result) -> + Result; +with_ok([Step | Rest], ok) -> + with_ok(Rest, Step()); +with_ok([Step | Rest], {ok, Value}) -> + with_ok(Rest, Step(Value)). - %% Build protocol options including request limits - ProtoOpts = #{ - env => #{dispatch => Dispatch}, - idle_timeout => maps:get(keepalive, Config) * 1000, - request_timeout => maps:get(timeout, Config), - %% Cowboy HTTP options for request limits - max_request_line_length => maps:get(max_request_line_size, Config, 4094), - max_header_value_length => maps:get(max_header_size, Config, 8190), - max_headers => maps:get(max_headers, Config, 100) - }, +%% @private +merge_routing(Config, Listeners) -> + case build_routing(maps:get(routes, Config, [])) of + {ok, Routing} -> + hornbeam_listener:start_service(maps:merge(Routing, Listeners)); + {error, _} = Error -> + Error + end. - %% Start with SSL/TLS or plain HTTP - case maps:get(ssl, Config, false) of - true -> - start_tls_listener(Ip, Port, Config, ProtoOpts); - false -> - TransportOpts = #{ - num_acceptors => NumAcceptors, - socket_opts => [{port, Port}, {ip, Ip}] - }, - {ok, _} = cowboy:start_clear(hornbeam_http, - TransportOpts, ProtoOpts), +%% @private +%% `HTTP/2' and `HTTP/3' exist only over TLS, so asking for either without +%% `ssl => true' is rejected rather than quietly served as HTTP/1.1. +validate_versions([], _Ssl) -> + {error, {invalid_http_version, []}}; +validate_versions(Versions, Ssl) when is_list(Versions) -> + Known = ['HTTP/1.1', 'HTTP/2', 'HTTP/3'], + case [V || V <- Versions, not lists:member(V, Known)] of + [Bad | _] -> + {error, {invalid_http_version, Bad}}; + [] when not Ssl -> + case [V || V <- Versions, V =/= 'HTTP/1.1'] of + [NeedsTls | _] -> {error, {http_version_requires_ssl, NeedsTls}}; + [] -> ok + end; + [] -> ok + end; +validate_versions(Other, _Ssl) -> + {error, {invalid_http_version, Other}}. + +%% @private +%% One livery listener entry per wire protocol. Each carries its own +%% `config' (the handler state), because `server_scheme' differs between a +%% cleartext and a TLS listener in the same service and livery lets a +%% per-listener config override the service-wide one. +build_listeners(Versions, Ssl, Config, BaseOpts, HandlerState) -> + H1 = lists:member('HTTP/1.1', Versions), + H2 = lists:member('HTTP/2', Versions), + H3 = lists:member('HTTP/3', Versions), + Limits = limit_opts(Config), + with_ok([ + fun() -> {ok, #{}} end, + fun(Acc) -> add_tcp_or_tls(Acc, H1, H2, Ssl, Config, BaseOpts, Limits, HandlerState) end, + fun(Acc) -> add_h3(Acc, H3, Config, BaseOpts, HandlerState) end + ]). + +%% @private +%% Without HTTP/2 the `bind' port is an h1 listener, cleartext or TLS. With +%% it, the port becomes livery's ALPN listener: `[h2, http1]' serves both +%% and prefers h2, `[h2]' alone is h2-only. +add_tcp_or_tls(Acc, false, false, _Ssl, _Config, _BaseOpts, _Limits, _HandlerState) -> + {ok, Acc}; +add_tcp_or_tls(Acc, true, false, false, _Config, BaseOpts, Limits, HandlerState) -> + Opts = maps:merge(BaseOpts, Limits), + {ok, Acc#{http => Opts#{config => listener_state(HandlerState, <<"http">>, BaseOpts)}}}; +add_tcp_or_tls(Acc, true, false, true, Config, BaseOpts, Limits, HandlerState) -> + case tls_opts(Config) of + {ok, Tls} -> + Opts = maps:merge(maps:merge(BaseOpts, Limits), Tls), + {ok, Acc#{http => Opts#{ + transport => ssl, + config => listener_state(HandlerState, <<"https">>, BaseOpts) + }}}; + {error, _} = Error -> + Error + end; +add_tcp_or_tls(Acc, H1, true, true, Config, BaseOpts, Limits, HandlerState) -> + Alpn = case H1 of + true -> [h2, http1]; + false -> [h2] + end, + case tls_opts(Config) of + {ok, Tls} -> + Opts = maps:merge(maps:merge(BaseOpts, Limits), Tls), + {ok, Acc#{https => Opts#{ + alpn => Alpn, + config => listener_state(HandlerState, <<"https">>, BaseOpts) + }}}; + {error, _} = Error -> + Error end. %% @private -%% Start SSL/TLS listener with certificate configuration -start_tls_listener(Ip, Port, Config, ProtoOpts) -> - NumAcceptors = maps:get(num_acceptors, Config, 100), - CertFile = maps:get(certfile, Config, undefined), - KeyFile = maps:get(keyfile, Config, undefined), - CaCertFile = maps:get(cacertfile, Config, undefined), - case {CertFile, KeyFile} of - {undefined, _} -> - {error, {missing_ssl_option, certfile}}; - {_, undefined} -> - {error, {missing_ssl_option, keyfile}}; - _ -> - SocketOpts = [{port, Port}, {ip, Ip}, - {certfile, ensure_list(CertFile)}, - {keyfile, ensure_list(KeyFile)}] ++ - case CaCertFile of - undefined -> []; - _ -> [{cacertfile, ensure_list(CaCertFile)}] - end, - TransportOpts = #{ - num_acceptors => NumAcceptors, - socket_opts => SocketOpts +%% HTTP/3 is QUIC over UDP, so it can hold the same port number as the TCP +%% listener beside it. livery derives a stable listener name from the port. +add_h3(Acc, false, _Config, _BaseOpts, _HandlerState) -> + {ok, Acc}; +add_h3(Acc, true, Config, BaseOpts, HandlerState) -> + case tls_der(Config) of + {ok, #{cert := Cert, key := Key}} -> + Port = case maps:get(http3_port, Config, undefined) of + undefined -> maps:get(port, BaseOpts); + Explicit -> Explicit + end, + H3Opts = #{ + port => Port, + ip => maps:get(ip, BaseOpts), + max_body => maps:get(max_body, BaseOpts), + cert => Cert, + key => Key, + config => listener_state(HandlerState, <<"https">>, BaseOpts#{port => Port}) }, - {ok, _} = cowboy:start_tls(hornbeam_http, TransportOpts, ProtoOpts), - ok + {ok, Acc#{http3 => H3Opts, alt_svc => advertise}}; + {error, _} = Error -> + Error end. +%% @private +%% `livery_req:scheme/1' reports `<<"http">>' on HTTP/1.1 even under TLS, +%% so the scheme a listener serves is recorded here per listener rather +%% than once per service. +listener_state(HandlerState, Scheme, BaseOpts) -> + HandlerState#{ + server_scheme => Scheme, + server_port => maps:get(port, BaseOpts) + }. + +%% @private +%% hornbeam's limit names predate livery; map them onto h1's. +limit_opts(Config) -> + #{ + max_request_line_size => maps:get(max_request_line_size, Config, 4094), + max_header_value_size => maps:get(max_header_size, Config, 8190), + max_headers => maps:get(max_headers, Config, 100) + }. + +%% @private +%% TLS material as file paths, which is what livery's h1 and h2 listeners take. +tls_opts(Config) -> + case cert_key_files(Config) of + {ok, CertFile, KeyFile} -> + SslOpts = case maps:get(cacertfile, Config, undefined) of + undefined -> []; + CaCertFile -> [{cacertfile, ensure_list(CaCertFile)}] + end, + {ok, #{ + cert => ensure_list(CertFile), + key => ensure_list(KeyFile), + ssl_opts => SslOpts + }}; + {error, _} = Error -> + Error + end. + +%% @private +%% The h3 listener takes DER instead: the certificate as raw DER bytes and +%% the private key as a decoded key term. +tls_der(Config) -> + case cert_key_files(Config) of + {ok, CertFile, KeyFile} -> + with_ok([ + fun() -> read_pem(certfile, CertFile) end, + fun(CertEntries) -> + case [D || {'Certificate', D, _} <- CertEntries] of + [Der | _] -> {ok, Der}; + [] -> {error, {no_certificate_in_pem, CertFile}} + end + end, + fun(CertDer) -> + case read_pem(keyfile, KeyFile) of + {ok, KeyEntries} -> decode_key(KeyEntries, KeyFile, CertDer); + {error, _} = Error -> Error + end + end + ]); + {error, _} = Error -> + Error + end. + +%% @private +cert_key_files(Config) -> + case {maps:get(certfile, Config, undefined), maps:get(keyfile, Config, undefined)} of + {undefined, _} -> {error, {missing_ssl_option, certfile}}; + {_, undefined} -> {error, {missing_ssl_option, keyfile}}; + {CertFile, KeyFile} -> {ok, CertFile, KeyFile} + end. + +%% @private +read_pem(Which, File) -> + case file:read_file(File) of + {ok, Pem} -> + case public_key:pem_decode(Pem) of + [] -> {error, {empty_pem, Which, File}}; + Entries -> {ok, Entries} + end; + {error, Reason} -> + {error, {unreadable_pem, Which, File, Reason}} + end. + +%% @private +%% quic wants the key as a decoded record, not as DER bytes. +decode_key(Entries, KeyFile, CertDer) -> + KeyTypes = ['RSAPrivateKey', 'ECPrivateKey', 'PrivateKeyInfo'], + case [{T, D} || {T, D, not_encrypted} <- Entries, lists:member(T, KeyTypes)] of + [{Type, Der} | _] -> + {ok, #{cert => CertDer, key => public_key:der_decode(Type, Der)}}; + [] -> + {error, {no_private_key_in_pem, KeyFile}} + end. + +%% @private +%% No custom routes: hand every request to the catch-all handler. With +%% routes, compile a livery router with the catch-all as wildcard fallback +%% so custom routes take precedence, as before. +build_routing([]) -> + {ok, #{handler => fun hornbeam_handler:handle/1}}; +build_routing(Routes) when is_list(Routes) -> + try + Entries = [validate_route(R) || R <- Routes], + Fallback = {'_', <<"/*hb_rest">>, fun hornbeam_handler:handle/1}, + {ok, #{router => livery_router:compile(Entries ++ [Fallback])}} + catch + throw:{invalid_route, _} = Reason -> + {error, Reason} + end; +build_routing(Other) -> + {error, {invalid_routes, Other}}. + +%% @private +%% Routes use livery shapes: {Method | '_', Pattern, Handler[, Meta]} with +%% Handler a fun/1 or {Module, Function}. The old cowboy +%% {Path, HandlerModule, Opts} shape is rejected. +validate_route({Method, Pattern, Handler}) -> + {validate_route_method(Method), validate_route_pattern(Pattern), + validate_route_handler(Handler)}; +validate_route({Method, Pattern, Handler, Meta}) when is_map(Meta) -> + {validate_route_method(Method), validate_route_pattern(Pattern), + validate_route_handler(Handler), Meta}; +validate_route(Route) -> + throw({invalid_route, Route}). + +%% @private +validate_route_method('_') -> '_'; +validate_route_method(M) when is_binary(M) -> M; +validate_route_method(M) -> throw({invalid_route, {method, M}}). + +%% @private +validate_route_pattern(P) when is_binary(P) -> P; +validate_route_pattern(P) when is_list(P) -> list_to_binary(P); +validate_route_pattern(P) -> throw({invalid_route, {pattern, P}}). + +%% @private +validate_route_handler(H) when is_function(H, 1) -> H; +validate_route_handler({M, F} = H) when is_atom(M), is_atom(F) -> H; +validate_route_handler(H) -> throw({invalid_route, {handler, H}}). + %% @private ensure_list(V) when is_list(V) -> V; ensure_list(V) when is_binary(V) -> binary_to_list(V). diff --git a/src/hornbeam_asgi.erl b/src/hornbeam_asgi.erl index 6ba63a0..728d86a 100644 --- a/src/hornbeam_asgi.erl +++ b/src/hornbeam_asgi.erl @@ -12,84 +12,69 @@ %% See the License for the specific language governing permissions and %% limitations under the License. -%%% @doc ASGI handler with fast synchronous path. +%%% @doc ASGI handler. %%% -%%% This module implements ASGI request handling with two paths: -%%% 1. Fast sync path: For simple requests (no body or small body), uses -%%% py_nif:context_call() with hornbeam_asgi_runner for WSGI-like performance -%%% 2. Async path: For streaming/large bodies, uses cowboy_loop handler -%%% with py_event_loop_pool for full async support +%%% Requests run in their own livery request process. The Python task is +%%% submitted to the event loop pool with this process's pid; response +%%% events (start_response/chunk/fin/error) arrive as plain messages, +%%% request-body chunks as `{livery_body, Ref, _}' messages, and client +%%% disconnects as `{livery_disconnect, Ref, Reason}' - all handled by +%%% one selective receive, first as a stream_deferred resolver (choosing +%%% status and headers) and then as the stream producer. %%% %%% @end -module(hornbeam_asgi). --behaviour(cowboy_loop). +-export([handle/2]). --export([init/2, info/3, terminate/3]). - -%% Threshold for fast synchronous path (64KB) -%% Requests with bodies smaller than this use the fast sync path +%% Threshold for passing the request body inline (64KB) +%% Bodies smaller than this are read synchronously and passed as a binary -define(ASGI_BODY_BUFFER_THRESHOLD, 65536). -%% Internal state --record(state, { - req_info, - app_module, - app_callable, - timeout_ms, - scope, - req_body_ch, - body_ref, %% Reference for async body reading - has_body, - %% Cached for direct send (avoid map lookups per body chunk) - cowboy_pid, - cowboy_streamid, - handler_state %% Original handler state from init -}). - %%% ============================================================================ -%%% Cowboy Loop Handler Callbacks +%%% Entry point (called from hornbeam_handler) %%% ============================================================================ -init(Req, HandlerState) -> - ReqInfo = build_request_info(Req), +-spec handle(livery_req:req(), map()) -> livery_resp:resp(). +handle(Req, HandlerState) -> + ReqInfo = hornbeam_handler:build_request_info(Req, HandlerState), ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), AppModule = maps:get(app_module, HandlerState), AppCallable = maps:get(app_callable, HandlerState), TimeoutMs = maps:get(timeout, HandlerState, 30000), - %% Cache pid/streamid for direct send (avoid map lookups per body chunk) - Pid = maps:get(pid, Req), - StreamID = maps:get(streamid, Req), - %% Build ASGI scope Scope = hornbeam_request:build_asgi_scope(Req, HandlerState), - %% Check if request has a body %% Body exists if: Content-Length > 0, or Transfer-Encoding is present - Method = cowboy_req:method(Req), - ContentLength = get_content_length(Req), - TransferEncoding = cowboy_req:header(<<"transfer-encoding">>, Req), + Method = livery_req:method(Req), + ContentLength = hornbeam_handler:get_content_length(Req), + TransferEncoding = livery_req:header(<<"transfer-encoding">>, Req), HasBody = has_request_body(Method, ContentLength, TransferEncoding), - %% Create request body channel only if body exists (skip for GET/no-body) - %% For small bodies with known Content-Length, read synchronously and pass directly - {ReqBodyRef, BodyRef} = case HasBody of - true when is_integer(ContentLength), ContentLength =< ?ASGI_BODY_BUFFER_THRESHOLD -> - %% Small body with known size - read synchronously, pass binary directly - {ok, Body, _Req2} = cowboy_req:read_body(Req), - {{body, Body}, undefined}; - true -> - %% Large/streaming body - use channel + async reading + %% Small bodies with known Content-Length are read synchronously and + %% passed directly; larger/streaming bodies flow through a byte + %% channel fed by this process's receive loop + {BodyArg, BodyRef} = case {HasBody, livery_req:body(Req)} of + {false, _} -> + {empty, undefined}; + {true, empty} -> + {empty, undefined}; + {true, {buffered, Data}} -> + {{body, iolist_to_binary(Data)}, undefined}; + {true, {stream, Reader}} when is_integer(ContentLength), + ContentLength =< ?ASGI_BODY_BUFFER_THRESHOLD -> + case livery_body:read_all(Reader, TimeoutMs, infinity) of + {ok, Body, _Reader1} -> + {{body, Body}, undefined}; + {error, _Reason, _Reader1} -> + %% Truncated client body: hand Python what we have + {{body, <<>>}, undefined} + end; + {true, {stream, Reader}} -> {ok, Ch} = py_byte_channel:new(), - Ref = make_ref(), - %% Direct send instead of cowboy_req:cast - Pid ! {{Pid, StreamID}, {read_body, self(), Ref, auto, infinity}}, - {{channel, Ch}, Ref}; - false -> - %% No body - pass empty marker, skip channel - {empty, undefined} + {{channel, Ch}, livery_body:ref(Reader)} end, %% Submit task to event loop pool for parallel distribution @@ -97,118 +82,140 @@ init(Req, HandlerState) -> TaskRef = make_ref(), ok = py_nif:submit_task(LoopRef, self(), TaskRef, <<"hornbeam_asgi_worker">>, <<"handle_asgi">>, - [self(), AppModule, AppCallable, Scope, ReqBodyRef], #{}), - - %% Extract channel ref for state (if using channel mode) - ReqBodyCh = case ReqBodyRef of - {channel, ChannelRef} -> ChannelRef; - _ -> ReqBodyRef - end, - - State = #state{ - req_info = ReqInfo1, - app_module = AppModule, - app_callable = AppCallable, - timeout_ms = TimeoutMs, - scope = Scope, - req_body_ch = ReqBodyCh, - body_ref = BodyRef, - has_body = HasBody, - cowboy_pid = Pid, - cowboy_streamid = StreamID, - handler_state = HandlerState + [self(), AppModule, AppCallable, Scope, BodyArg], #{}), + + St = #{ + req => Req, + req_info => ReqInfo1, + channel => body_channel(BodyArg), + body_ref => BodyRef, + timeout_ms => TimeoutMs }, + livery_resp:stream_deferred(fun() -> wait_response(St) end). - %% Return cowboy_loop to enable loop handler - {cowboy_loop, Req, State, TimeoutMs}. - -%% Handle async body chunks from Cowboy - more data coming -info({request_body, Ref, nofin, Data}, Req, - #state{body_ref = Ref, req_body_ch = Ch, cowboy_pid = Pid, - cowboy_streamid = StreamID} = State) -> - ok = push_to_channel(Ch, Data), - %% Direct send instead of cowboy_req:cast - Pid ! {{Pid, StreamID}, {read_body, self(), Ref, auto, infinity}}, - {ok, Req, State}; - -%% Handle async body chunks from Cowboy - final chunk -info({request_body, Ref, fin, _BodyLen, Data}, Req, - #state{body_ref = Ref, req_body_ch = Ch} = State) -> - case Data of - <<>> -> ok; - _ -> ok = push_to_channel(Ch, Data) - end, - py_byte_channel:close(Ch), - {ok, Req, State#state{body_ref = undefined}}; - -%% New simplified protocol: start_response (headers + first chunk) -info({<<"start_response">>, StatusCode, Headers, FirstChunk}, Req, State) -> - CowboyHeaders = convert_headers(filter_hop_by_hop(Headers)), - Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - case to_binary(FirstChunk) of - <<>> -> ok; - Body -> ok = cowboy_req:stream_body(Body, nofin, Req2) - end, - {ok, Req2, State}; - -%% New simplified protocol: subsequent chunk -info({<<"chunk">>, Data}, Req, State) -> - ok = cowboy_req:stream_body(to_binary(Data), nofin, Req), - {ok, Req, State}; - -%% New simplified protocol: end of response -info(<<"fin">>, Req, #state{handler_state = HS} = State) -> - ok = cowboy_req:stream_body(<<>>, fin, Req), - maybe_close_channel(State#state.req_body_ch), - {stop, Req, HS}; - -%% Handle early hints from Python -info({<<"early_hints">>, Headers}, Req, State) -> - HintHeaders = convert_headers(Headers), - Req2 = cowboy_req:inform(103, HintHeaders, Req), - {ok, Req2, State}; - -%% Handle error from Python -info({<<"error">>, Reason}, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) -> - maybe_close_channel(State#state.req_body_ch), - {StatusCode, Body} = hornbeam_http_hooks:run_on_error(Reason, ReqInfo), - Req2 = cowboy_req:reply(StatusCode, - #{<<"content-type">> => <<"text/plain">>}, - Body, Req), - {stop, Req2, HandlerState}; +%%% ============================================================================ +%%% Resolver: wait for the response head while pumping the request body +%%% ============================================================================ -%% Handle async task completion -info({async_result, _Ref, {ok, _}}, Req, State) -> - {ok, Req, State}; +wait_response(#{req_info := ReqInfo, timeout_ms := TimeoutMs} = St) -> + BodyRef = pump_ref(St), + receive + {livery_body, BodyRef, {data, Data}} -> + ok = push_to_channel(maps:get(channel, St), Data), + wait_response(St); + {livery_body, BodyRef, _EofOrError} -> + %% eof/trailers/reset/error all end the request body; a reset + %% surfaces to Python as EOF on its body reads + maybe_close_channel(maps:get(channel, St)), + wait_response(St#{body_ref := undefined}); + {<<"start_response">>, StatusCode, Headers, FirstChunk} -> + SafeHeaders = hornbeam_handler:convert_headers( + hornbeam_handler:filter_hop_by_hop(Headers)), + Status = hornbeam_handler:parse_status_code(StatusCode), + {stream, Status, SafeHeaders, + fun(Emit) -> stream_response(Emit, FirstChunk, St) end}; + {<<"early_hints">>, Headers} -> + %% Best-effort: h3 (and HTTP/1.0 clients) have no interim + %% responses, and livery answers {error, unsupported} + _ = livery_req:inform(103, hornbeam_handler:convert_headers(Headers), + maps:get(req, St)), + wait_response(St); + {<<"error">>, Reason} -> + cleanup(St), + hornbeam_handler:error_decision(Reason, ReqInfo); + {async_result, _Ref, {ok, _}} -> + wait_response(St); + {async_result, _Ref, {error, Reason}} -> + cleanup(St), + hornbeam_handler:error_decision(Reason, ReqInfo); + {livery_disconnect, _Ref, _Reason} -> + %% Client gone before the response started; the emit of this + %% decision fails on the closed stream, which livery treats + %% as a peer disconnect + cleanup(St), + {full, 500, [], <<>>} + after TimeoutMs -> + cleanup(St), + hornbeam_handler:error_decision(timeout, ReqInfo) + end. -info({async_result, _Ref, {error, Reason}}, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) -> - maybe_close_channel(State#state.req_body_ch), - {StatusCode, Body} = hornbeam_http_hooks:run_on_error(Reason, ReqInfo), - Req2 = cowboy_req:reply(StatusCode, - #{<<"content-type">> => <<"text/plain">>}, - Body, Req), - {stop, Req2, HandlerState}; +%%% ============================================================================ +%%% Producer: stream chunks to the client +%%% ============================================================================ -%% Handle timeout -info(timeout, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) -> - maybe_close_channel(State#state.req_body_ch), - {StatusCode, Body} = hornbeam_http_hooks:run_on_error(timeout, ReqInfo), - Req2 = cowboy_req:reply(StatusCode, - #{<<"content-type">> => <<"text/plain">>}, - Body, Req), - {stop, Req2, HandlerState}; +stream_response(Emit, FirstChunk, St) -> + case hornbeam_handler:to_binary(FirstChunk) of + <<>> -> + stream_loop(Emit, St); + Body -> + case Emit(Body) of + ok -> stream_loop(Emit, St); + {error, _} -> stream_done(St) + end + end. -%% Unknown message -info(_Msg, Req, State) -> - {ok, Req, State}. +stream_loop(Emit, #{timeout_ms := TimeoutMs} = St) -> + BodyRef = pump_ref(St), + receive + {livery_body, BodyRef, {data, Data}} -> + ok = push_to_channel(maps:get(channel, St), Data), + stream_loop(Emit, St); + {livery_body, BodyRef, _EofOrError} -> + maybe_close_channel(maps:get(channel, St)), + stream_loop(Emit, St#{body_ref := undefined}); + {<<"chunk">>, Data} -> + case Emit(hornbeam_handler:to_binary(Data)) of + ok -> stream_loop(Emit, St); + {error, _} -> stream_done(St) + end; + <<"fin">> -> + cleanup(St), + ok; + {<<"early_hints">>, _} -> + %% Too late: the final response head is already on the wire + stream_loop(Emit, St); + {<<"error">>, _Reason} -> + %% Response already started: truncate the stream + cleanup(St), + ok; + {async_result, _Ref, _} -> + stream_loop(Emit, St); + {livery_disconnect, _Ref, _Reason} -> + stream_done(St) + after TimeoutMs -> + cleanup(St), + ok + end. -terminate(_Reason, _Req, _State) -> - ok. +%% @private +%% Stop producing: the stream will take no more data. Either the client +%% disconnected, or the stream rejects a body at all - an h2/h3 response +%% to HEAD answers `{error, invalid_stream_state}' rather than swallowing +%% the bytes the way HTTP/1.1 does. Close the byte channel either way so +%% Python's body reads hit EOF (its sends to this pid are dropped once we +%% return). +stream_done(St) -> + cleanup(St), + {error, closed}. %%% ============================================================================ %%% Internal Functions %%% ============================================================================ +%% @private +%% Only pump raw body messages while a channel is live; otherwise use a +%% fresh ref that matches nothing. +pump_ref(#{body_ref := Ref}) when is_reference(Ref) -> Ref; +pump_ref(_) -> make_ref(). + +%% @private +body_channel({channel, Ch}) -> Ch; +body_channel(_) -> undefined. + +%% @private +cleanup(St) -> + maybe_close_channel(maps:get(channel, St)). + push_to_channel(Channel, Data) -> case py_byte_channel:send(Channel, Data) of ok -> ok; @@ -221,30 +228,18 @@ push_to_channel(Channel, Data) -> end. maybe_close_channel(undefined) -> ok; -maybe_close_channel(empty) -> ok; -maybe_close_channel({body, _}) -> ok; %% Small body passed inline, no channel maybe_close_channel(Channel) -> try case py_byte_channel:info(Channel) of #{closed := true} -> ok; _ -> - catch py_byte_channel:close(Channel), + _ = py_byte_channel:close(Channel), ok end catch _:_ -> ok end. -%% @private -get_content_length(Req) -> - case cowboy_req:header(<<"content-length">>, Req) of - undefined -> undefined; - CLBin -> - try binary_to_integer(CLBin) - catch _:_ -> undefined - end - end. - %% @private %% Check if request has a body based on method, content-length, and transfer-encoding %% Per HTTP spec: body exists if Content-Length > 0 OR Transfer-Encoding is present @@ -256,53 +251,3 @@ has_request_body(<<"HEAD">>, _, _) -> false; has_request_body(<<"DELETE">>, _, _) -> false; has_request_body(<<"OPTIONS">>, _, _) -> false; has_request_body(_, undefined, undefined) -> false. %% No CL, no TE = no body - -%% @private -build_request_info(Req) -> - #{ - method => cowboy_req:method(Req), - path => cowboy_req:path(Req), - query_string => cowboy_req:qs(Req), - headers => cowboy_req:headers(Req), - host => cowboy_req:host(Req), - port => cowboy_req:port(Req), - scheme => cowboy_req:scheme(Req), - peer => cowboy_req:peer(Req) - }. - -%% @private -filter_hop_by_hop(Headers) -> - HopByHop = [<<"connection">>, <<"keep-alive">>, <<"proxy-authenticate">>, - <<"proxy-authorization">>, <<"te">>, <<"trailers">>, - <<"transfer-encoding">>, <<"upgrade">>], - lists:filter(fun(Header) -> - Name = case Header of - [N, _] -> N; - {N, _} -> N - end, - LowerName = string:lowercase(to_binary(Name)), - not lists:member(LowerName, HopByHop) - end, Headers). - -%% @private -convert_headers(Headers) -> - lists:foldl(fun(Header, Acc) -> - case Header of - [Name, Value] -> - Acc#{to_lower_binary(Name) => to_binary(Value)}; - {Name, Value} -> - Acc#{to_lower_binary(Name) => to_binary(Value)}; - _ -> - Acc - end - end, #{}, Headers). - -to_binary(V) when is_binary(V) -> V; -to_binary(V) when is_list(V) -> list_to_binary(V); -to_binary(V) when is_atom(V) -> atom_to_binary(V, utf8); -to_binary(V) -> iolist_to_binary(io_lib:format("~p", [V])). - -to_lower_binary(V) when is_binary(V) -> string:lowercase(V); -to_lower_binary(V) when is_list(V) -> string:lowercase(list_to_binary(V)); -to_lower_binary(V) when is_atom(V) -> string:lowercase(atom_to_binary(V, utf8)); -to_lower_binary(V) -> string:lowercase(to_binary(V)). diff --git a/src/hornbeam_config.erl b/src/hornbeam_config.erl index ba14934..73e235b 100644 --- a/src/hornbeam_config.erl +++ b/src/hornbeam_config.erl @@ -27,7 +27,8 @@ %%% %%% === Protocol === %%% - worker_class: wsgi or asgi (default: wsgi) -%%% - http_version: List of supported HTTP versions (default: ['HTTP/1.1', 'HTTP/2']) +%%% - http_version: HTTP versions to serve (default: ['HTTP/1.1']). +%%% 'HTTP/2' and 'HTTP/3' require ssl. %%% %%% === Contexts === %%% - num_contexts: Number of Python contexts (default: schedulers) @@ -134,9 +135,12 @@ defaults() -> certfile => undefined, keyfile => undefined, cacertfile => undefined, + %% HTTP/3 UDP port; undefined means the `bind' port + http3_port => undefined, %% Protocol worker_class => wsgi, + http_version => ['HTTP/1.1'], %% Contexts %% num_contexts defaults to schedulers in hornbeam.erl @@ -225,9 +229,9 @@ code_change(_OldVsn, State, _Extra) -> load_app_env() -> Keys = [ %% Server - bind, ssl, certfile, keyfile, cacertfile, + bind, ssl, certfile, keyfile, cacertfile, http3_port, %% Protocol - worker_class, + worker_class, http_version, %% Contexts num_contexts, timeout, keepalive, max_requests, preload_app, %% Request limits diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl index 481a732..098e7b4 100644 --- a/src/hornbeam_handler.erl +++ b/src/hornbeam_handler.erl @@ -12,7 +12,7 @@ %% See the License for the specific language governing permissions and %% limitations under the License. -%%% @doc Cowboy HTTP handler for hornbeam. +%%% @doc Livery HTTP handler for hornbeam. %%% %%% This module handles HTTP requests and routes them to either WSGI or ASGI %%% handlers based on configuration. @@ -22,21 +22,49 @@ %%% - ASGI: Uses py_event_loop for full async execution %%% - Both stream responses via erlang.reply()/send() %%% +%%% The request runs in its own livery request process; request-body +%%% chunks are pushed to that process as `{livery_body, Ref, _}' messages +%%% and Python response events arrive as plain messages, so a single +%%% selective receive drives both. +%%% %%% @end -module(hornbeam_handler). --behaviour(cowboy_websocket). --behaviour(cowboy_loop). +-export([handle/1]). + +%% Shared helpers used by hornbeam_asgi +-export([ + build_request_info/2, + filter_hop_by_hop/1, + convert_headers/1, + parse_status_code/1, + error_decision/2, + get_content_length/1, + to_binary/1 +]). + +%% Streaming threshold: bodies larger than this are streamed via the +%% receive loop while Python runs; smaller ones are read inline first. +-define(WSGI_STREAMING_THRESHOLD, 65536). %% 64KB + +%% Hop-by-hop headers that should not be forwarded +-define(HOP_BY_HOP_HEADERS, [ + <<"connection">>, <<"keep-alive">>, <<"proxy-authenticate">>, + <<"proxy-authorization">>, <<"te">>, <<"trailers">>, + <<"transfer-encoding">>, <<"upgrade">> +]). --export([init/2]). -%% Loop handler callback (for ASGI) --export([info/3]). -%% WebSocket callbacks (delegate to hornbeam_websocket) --export([websocket_init/1, websocket_handle/2, websocket_info/2, terminate/3]). +-spec handle(livery_req:req()) -> livery_resp:resp(). +handle(Req) -> + State = case livery_req:config(Req) of + Map when is_map(Map) -> Map; + _ -> #{} + end, + dispatch(Req, State). -init(Req, #{multi_app := true} = State) -> +dispatch(Req, #{multi_app := true} = State) -> %% Multi-app mode: lookup mount based on request path - Path = cowboy_req:path(Req), + Path = livery_req:path(Req), case hornbeam_mounts:lookup(Path) of {ok, Mount, PathInfo} -> %% pythonpath is setup at mount registration time (hornbeam_mounts.erl) @@ -58,13 +86,9 @@ init(Req, #{multi_app := true} = State) -> handle_request(WorkerClass, Req, NewState); {error, no_match} -> %% No mount matched - return 404 - Req2 = cowboy_req:reply(404, - #{<<"content-type">> => <<"text/plain">>}, - <<"Not Found">>, - Req), - {ok, Req2, State} + livery_resp:text(404, <<"Not Found">>) end; -init(Req, State) -> +dispatch(Req, State) -> %% Single-app mode (backward compatible) WorkerClass = maps:get(worker_class, State, wsgi), handle_request(WorkerClass, Req, State). @@ -75,41 +99,25 @@ handle_request(asgi, Req, State) -> %% Check for WebSocket upgrade case is_websocket_upgrade(Req) of true -> - handle_websocket_upgrade(Req, State); + hornbeam_websocket:upgrade(Req, State); false -> - hornbeam_asgi:init(Req, State) + hornbeam_asgi:handle(Req, State) end. %% @private is_websocket_upgrade(Req) -> - case cowboy_req:header(<<"upgrade">>, Req) of + case livery_req:header(<<"upgrade">>, Req) of undefined -> false; Upgrade -> string:lowercase(Upgrade) =:= <<"websocket">> end. -%% @private -handle_websocket_upgrade(Req, State) -> - %% Delegate to WebSocket handler - hornbeam_websocket:init(Req, State). - %%% ============================================================================ %%% WSGI Handler - unified channel-based approach with schedule_inline %%% ============================================================================ -%% Streaming threshold: bodies larger than this are streamed via channel --define(WSGI_STREAMING_THRESHOLD, 65536). %% 64KB --define(WSGI_BODY_CHUNK_SIZE, 65536). %% 64KB chunks - -%% Hop-by-hop headers that should not be forwarded --define(HOP_BY_HOP_HEADERS, [ - <<"connection">>, <<"keep-alive">>, <<"proxy-authenticate">>, - <<"proxy-authorization">>, <<"te">>, <<"trailers">>, - <<"transfer-encoding">>, <<"upgrade">> -]). - handle_wsgi(Req, State) -> - ReqInfo = build_request_info(Req), + ReqInfo = build_request_info(Req, State), ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo), try @@ -120,18 +128,10 @@ handle_wsgi(Req, State) -> %% Build pre-parsed WSGI tuple (O(1) environ creation in Python) ReqTuple = hornbeam_request:build_wsgi_tuple(Req, State), - %% Create buffer for request body (skip for bodyless requests) - ContentLength = get_content_length(Req), - Method = cowboy_req:method(Req), - Buffer = case has_request_body(Method, ContentLength) of - false -> - %% No body expected - use empty buffer marker - empty; - true -> - {ok, Buf} = create_body_buffer(ContentLength), - write_body_to_buffer(Req, Buf, ContentLength), - Buf - end, + %% Create buffer for request body (skip for bodyless requests). + %% Small bodies are read inline; large ones stream into the + %% buffer inside the receive loop while Python already runs. + {Buffer, BodyCtx} = setup_body(Req, TimeoutMs), %% Call Python with tuple fast path CtxRef = hornbeam_context_pool:get_context_ref(), @@ -139,24 +139,28 @@ handle_wsgi(Req, State) -> <<"hornbeam_wsgi_worker">>, <<"handle_request_tuple">>, [self(), Buffer, AppModule, AppCallable, ReqTuple], #{}) of {ok, <<"done">>} -> - %% Enter receive loop - wsgi_receive_loop(Req, ReqInfo1, TimeoutMs, State); + %% Resolve status/headers once Python replies + livery_resp:stream_deferred(fun() -> + wsgi_wait(BodyCtx, ReqInfo1, TimeoutMs) + end); {ok, <<"error">>} -> - handle_error(Req, wsgi_error, ReqInfo1, State); + close_body(BodyCtx), + error_resp(wsgi_error, ReqInfo1); {error, Reason} -> - handle_error(Req, Reason, ReqInfo1, State) + close_body(BodyCtx), + error_resp(Reason, ReqInfo1) end catch Class:Error:Stack -> error_logger:error_msg("WSGI handler error: ~p:~p~n~p~n", [Class, Error, Stack]), - handle_error(Req, {Class, Error}, ReqInfo1, State) + error_resp({Class, Error}, ReqInfo1) end. %% @private %% Get content-length as integer, or undefined if not present/invalid get_content_length(Req) -> - case cowboy_req:header(<<"content-length">>, Req) of + case livery_req:header(<<"content-length">>, Req) of undefined -> undefined; CLBin -> try binary_to_integer(CLBin) @@ -173,51 +177,87 @@ has_request_body(<<"OPTIONS">>, undefined) -> false; has_request_body(_, 0) -> false; has_request_body(_, _) -> true. +%% @private +%% Prepare the request-body buffer. +%% Returns {BufferArg, BodyCtx} where BufferArg is the Python-side +%% argument (`empty' or a py_buffer) and BodyCtx tracks whether body +%% chunks still stream into the buffer ({streaming, Ref, Buffer}) or the +%% buffer is complete (done). +setup_body(Req, TimeoutMs) -> + Method = livery_req:method(Req), + ContentLength = get_content_length(Req), + case has_request_body(Method, ContentLength) of + false -> + {empty, done}; + true when is_integer(ContentLength), + ContentLength >= ?WSGI_STREAMING_THRESHOLD -> + {ok, Buf} = py_buffer:new(ContentLength), + case livery_req:body(Req) of + {stream, Reader} -> + {Buf, {streaming, livery_body:ref(Reader), Buf}}; + {buffered, Data} -> + _ = py_buffer:write(Buf, iolist_to_binary(Data)), + _ = py_buffer:close(Buf), + {Buf, done}; + empty -> + _ = py_buffer:close(Buf), + {Buf, done} + end; + true -> + {ok, Buf} = create_body_buffer(ContentLength), + case livery_req:body(Req) of + {stream, Reader} -> + case livery_body:read_all(Reader, TimeoutMs, infinity) of + {ok, Body, _Reader1} -> + _ = py_buffer:write(Buf, Body), + _ = py_buffer:close(Buf); + {error, Reason, _Reader1} -> + _ = py_buffer:close(Buf), + throw({body_read_error, Reason}) + end; + {buffered, Data} -> + _ = py_buffer:write(Buf, iolist_to_binary(Data)), + _ = py_buffer:close(Buf); + empty -> + _ = py_buffer:close(Buf) + end, + {Buf, done} + end. + %% @private %% Create buffer for body - pre-allocate if content-length known -create_body_buffer(undefined) -> - py_buffer:new(); create_body_buffer(ContentLength) when is_integer(ContentLength), ContentLength > 0 -> py_buffer:new(ContentLength); create_body_buffer(_) -> py_buffer:new(). %% @private -%% Write body to buffer - unified for small and large bodies -write_body_to_buffer(Req, Buffer, ContentLength) when - ContentLength =:= undefined; ContentLength < ?WSGI_STREAMING_THRESHOLD -> - %% Small body: read all, write once, close - {ok, Body, _Req2} = cowboy_req:read_body(Req), - py_buffer:write(Buffer, Body), - py_buffer:close(Buffer); -write_body_to_buffer(Req, Buffer, _ContentLength) -> - %% Large body: spawn process to stream chunks - spawn_link(fun() -> stream_body_to_buffer(Req, Buffer, ?WSGI_BODY_CHUNK_SIZE) end). +close_body({streaming, _Ref, Buffer}) -> + _ = py_buffer:close(Buffer), + ok; +close_body(done) -> + ok. %% @private -%% Stream request body to buffer in chunks -stream_body_to_buffer(Req, Buffer, ChunkSize) -> - case cowboy_req:read_body(Req, #{length => ChunkSize}) of - {ok, Chunk, _Req2} -> - %% Last chunk - py_buffer:write(Buffer, Chunk), - py_buffer:close(Buffer); - {more, Chunk, Req2} -> - %% More data available - py_buffer:write(Buffer, Chunk), - stream_body_to_buffer(Req2, Buffer, ChunkSize) - end. - -%% @private -%% Main receive loop for WSGI responses from Python -wsgi_receive_loop(Req, ReqInfo, TimeoutMs, State) -> +%% Wait for the Python response while pumping request-body chunks into +%% the buffer. Runs as a stream_deferred resolver: returns the response +%% decision before any header is written. +wsgi_wait(BodyCtx, ReqInfo, TimeoutMs) -> + {BodyRef, Buffer} = body_ctx_ref(BodyCtx), receive + {livery_body, BodyRef, {data, Chunk}} -> + _ = py_buffer:write(Buffer, Chunk), + wsgi_wait(BodyCtx, ReqInfo, TimeoutMs); + {livery_body, BodyRef, _EofOrError} -> + %% eof/trailers/reset/error all complete the buffer; a reset + %% surfaces to Python as EOF on the body stream + _ = py_buffer:close(Buffer), + wsgi_wait(done, ReqInfo, TimeoutMs); {<<"start_response">>, StatusCode, Headers} -> %% Streaming response - filter hop-by-hop and start streaming - SafeHeaders = filter_hop_by_hop(Headers), - CowboyHeaders = convert_headers(SafeHeaders), - Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req), - stream_response_loop(Req2, TimeoutMs, State); + SafeHeaders = convert_headers(filter_hop_by_hop(Headers)), + {stream, parse_status_code(StatusCode), SafeHeaders, + fun(Emit) -> wsgi_stream_loop(Emit, BodyCtx, TimeoutMs) end}; {<<"response">>, StatusCode, Headers, Body} -> %% Complete response SafeHeaders = filter_hop_by_hop(Headers), @@ -227,31 +267,59 @@ wsgi_receive_loop(Req, ReqInfo, TimeoutMs, State) -> <<"body">> => Body }, Response1 = hornbeam_http_hooks:run_on_response(Response), - send_response(Req, Response1, State); + close_body(BodyCtx), + {full, + parse_status_code(maps:get(<<"status">>, Response1)), + convert_headers(maps:get(<<"headers">>, Response1)), + maps:get(<<"body">>, Response1)}; {<<"error">>, Reason} -> - handle_error(Req, Reason, ReqInfo, State) + close_body(BodyCtx), + error_decision(Reason, ReqInfo) after TimeoutMs -> - handle_error(Req, timeout, ReqInfo, State) + close_body(BodyCtx), + error_decision(timeout, ReqInfo) end. %% @private -%% Receive and stream response chunks to client -stream_response_loop(Req, TimeoutMs, State) -> +%% Receive and stream response chunks to the client while still pumping +%% any remaining request-body chunks into the buffer +wsgi_stream_loop(Emit, BodyCtx, TimeoutMs) -> + {BodyRef, Buffer} = body_ctx_ref(BodyCtx), receive + {livery_body, BodyRef, {data, Chunk}} -> + _ = py_buffer:write(Buffer, Chunk), + wsgi_stream_loop(Emit, BodyCtx, TimeoutMs); + {livery_body, BodyRef, _EofOrError} -> + _ = py_buffer:close(Buffer), + wsgi_stream_loop(Emit, done, TimeoutMs); {<<"chunk">>, Chunk} -> - ok = cowboy_req:stream_body(Chunk, nofin, Req), - stream_response_loop(Req, TimeoutMs, State); + case Emit(Chunk) of + ok -> + wsgi_stream_loop(Emit, BodyCtx, TimeoutMs); + {error, _} -> + %% The stream takes no more data: the client is gone, or + %% it rejects a body outright - an h2/h3 response to HEAD + %% answers `{error, invalid_stream_state}' where HTTP/1.1 + %% just swallows the bytes. Unblock Python's body reads + %% and stop either way. + close_body(BodyCtx), + {error, closed} + end; <<"done">> -> - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State}; + close_body(BodyCtx), + ok; {<<"error">>, _Reason} -> - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State} + close_body(BodyCtx), + ok after TimeoutMs -> - ok = cowboy_req:stream_body(<<>>, fin, Req), - {ok, Req, State} + close_body(BodyCtx), + ok end. +%% @private +body_ctx_ref({streaming, Ref, Buffer}) -> {Ref, Buffer}; +body_ctx_ref(done) -> {make_ref(), undefined}. + %% @private %% Filter hop-by-hop headers from response filter_hop_by_hop(Headers) -> @@ -265,87 +333,73 @@ filter_hop_by_hop(Headers) -> end, Headers). %%% ============================================================================ -%%% Response sending +%%% Error handling %%% ============================================================================ -send_response(Req, Response, State) -> - Status = maps:get(<<"status">>, Response), - Headers = maps:get(<<"headers">>, Response), - Body = maps:get(<<"body">>, Response), - EarlyHints = maps:get(<<"early_hints">>, Response, []), - - %% Parse status code - StatusCode = parse_status_code(Status), - - %% Convert headers to cowboy format - CowboyHeaders = convert_headers(Headers), - - %% Send early hints if any (103 responses) - Req1 = send_early_hints(Req, EarlyHints), - - %% Send response - Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req1), - {ok, Req2, State}. - %% @private -send_early_hints(Req, []) -> - Req; -send_early_hints(Req, [Hints | Rest]) -> - HintHeaders = convert_headers(Hints), - Req1 = cowboy_req:inform(103, HintHeaders, Req), - send_early_hints(Req1, Rest). - -parse_status_code(Status) when is_binary(Status) -> - case binary:split(Status, <<" ">>) of - [CodeBin | _] -> binary_to_integer(CodeBin); - _ -> 500 - end; -parse_status_code(Status) when is_list(Status) -> - parse_status_code(list_to_binary(Status)); -parse_status_code(Status) when is_integer(Status) -> - Status. +%% Full error response as a livery_resp (pre-stream errors) +error_resp(Error, ReqInfo) -> + {full, StatusCode, Headers, Body} = error_decision(Error, ReqInfo), + livery_resp:new(StatusCode, Headers, {full, Body}). -%%% ============================================================================ -%%% Error handling -%%% ============================================================================ - -handle_error(Req, Error, ReqInfo, State) -> +%% @doc Error response as a stream_deferred decision. +error_decision(Error, ReqInfo) -> {StatusCode, Body} = hornbeam_http_hooks:run_on_error(Error, ReqInfo), - Req2 = cowboy_req:reply(StatusCode, - #{<<"content-type">> => <<"text/plain">>}, - Body, - Req), - {ok, Req2, State}. + {full, StatusCode, [{<<"content-type">>, <<"text/plain">>}], Body}. -%% @private -build_request_info(Req) -> +%% @doc Request info map passed to HTTP hooks. +build_request_info(Req, State) -> + {Host, Port} = hornbeam_request:server_info(Req, State), #{ - method => cowboy_req:method(Req), - path => cowboy_req:path(Req), - query_string => cowboy_req:qs(Req), - headers => cowboy_req:headers(Req), - host => cowboy_req:host(Req), - port => cowboy_req:port(Req), - scheme => cowboy_req:scheme(Req), - peer => cowboy_req:peer(Req) + method => livery_req:method(Req), + path => livery_req:path(Req), + query_string => livery_req:query(Req), + headers => headers_map(livery_req:headers(Req)), + host => Host, + port => Port, + scheme => hornbeam_request:scheme(State), + peer => livery_req:peer(Req) }. +%% @private +%% Hooks historically saw headers as a lowercase-keyed map; join +%% duplicates with ", " to preserve every value. +headers_map(Headers) -> + lists:foldl(fun({Name, Value}, Acc) -> + case Acc of + #{Name := Prev} -> Acc#{Name := <>}; + _ -> Acc#{Name => Value} + end + end, #{}, Headers). + %%% ============================================================================ %%% Utilities %%% ============================================================================ -%% @private +%% @doc Convert Python response headers ([[Name, Value], ...] or +%% [{Name, Value}, ...]) to livery's list shape, lowercasing names and +%% preserving duplicates. convert_headers(Headers) -> - lists:foldl(fun(Header, Acc) -> + lists:filtermap(fun(Header) -> case Header of [Name, Value] -> - Acc#{to_lower_binary(Name) => to_binary(Value)}; + {true, {to_lower_binary(Name), to_binary(Value)}}; {Name, Value} -> - Acc#{to_lower_binary(Name) => to_binary(Value)}; + {true, {to_lower_binary(Name), to_binary(Value)}}; _ -> - Acc + false end - end, #{}, Headers). + end, Headers). + +parse_status_code(Status) when is_binary(Status) -> + case binary:split(Status, <<" ">>) of + [CodeBin | _] -> binary_to_integer(CodeBin); + _ -> 500 + end; +parse_status_code(Status) when is_list(Status) -> + parse_status_code(list_to_binary(Status)); +parse_status_code(Status) when is_integer(Status) -> + Status. to_binary(V) when is_binary(V) -> V; to_binary(V) when is_list(V) -> list_to_binary(V); @@ -356,28 +410,3 @@ to_lower_binary(V) when is_binary(V) -> string:lowercase(V); to_lower_binary(V) when is_list(V) -> string:lowercase(list_to_binary(V)); to_lower_binary(V) when is_atom(V) -> string:lowercase(atom_to_binary(V, utf8)); to_lower_binary(V) -> string:lowercase(to_binary(V)). - -%%% ============================================================================ -%%% Loop handler callback (for ASGI) -%%% ============================================================================ - -%% @private -%% Delegate to hornbeam_asgi for ASGI loop handler messages -info(Msg, Req, State) -> - hornbeam_asgi:info(Msg, Req, State). - -%%% ============================================================================ -%%% WebSocket callbacks (delegate to hornbeam_websocket) -%%% ============================================================================ - -websocket_init(State) -> - hornbeam_websocket:websocket_init(State). - -websocket_handle(Frame, State) -> - hornbeam_websocket:websocket_handle(Frame, State). - -websocket_info(Info, State) -> - hornbeam_websocket:websocket_info(Info, State). - -terminate(Reason, Req, State) -> - hornbeam_websocket:terminate(Reason, Req, State). diff --git a/src/hornbeam_listener.erl b/src/hornbeam_listener.erl new file mode 100644 index 0000000..fc78205 --- /dev/null +++ b/src/hornbeam_listener.erl @@ -0,0 +1,107 @@ +%% Copyright 2026 Benoit Chesneau +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. + +%%% @doc Supervised owner of the livery HTTP service. +%%% +%%% `livery:start_service/1' links the service to its caller, so the +%%% service must be started from a supervised process rather than from +%%% whichever process calls `hornbeam:start/2' (a shell or a test case). +%%% This gen_server owns the service pid and exposes start/stop/info. +-module(hornbeam_listener). + +-behaviour(gen_server). + +-export([start_link/0]). +-export([start_service/1, stop_service/0, info/0, is_running/0]). +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). + +-define(SERVER, ?MODULE). + +%% @doc Start the livery service with the given service opts. +-spec start_service(map()) -> ok | {error, term()}. +start_service(ServiceOpts) -> + gen_server:call(?SERVER, {start_service, ServiceOpts}, infinity). + +%% @doc Stop the running livery service. Idempotent. +-spec stop_service() -> ok. +stop_service() -> + gen_server:call(?SERVER, stop_service, infinity). + +%% @doc Listener status: `#{running := boolean(), listeners := map()}'. +-spec info() -> #{running := boolean(), listeners := map()}. +info() -> + gen_server:call(?SERVER, info). + +-spec is_running() -> boolean(). +is_running() -> + maps:get(running, info()). + +start_link() -> + gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). + +%% gen_server callbacks + +init([]) -> + process_flag(trap_exit, true), + {ok, #{service => undefined}}. + +handle_call({start_service, _Opts}, _From, #{service := Pid} = State) + when is_pid(Pid) -> + {reply, {error, already_started}, State}; +handle_call({start_service, Opts}, _From, State) -> + case livery:start_service(Opts) of + {ok, Pid} -> + {reply, ok, State#{service := Pid}}; + {error, _} = Error -> + {reply, Error, State} + end; +handle_call(stop_service, _From, #{service := Pid} = State) when is_pid(Pid) -> + ok = livery:stop_service(Pid), + {reply, ok, State#{service := undefined}}; +handle_call(stop_service, _From, State) -> + {reply, ok, State}; +handle_call(info, _From, #{service := Pid} = State) when is_pid(Pid) -> + Listeners = + try livery:which_listeners(Pid) + catch _:_ -> #{} + end, + {reply, #{running => true, listeners => Listeners}, State}; +handle_call(info, _From, State) -> + {reply, #{running => false, listeners => #{}}, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info({'EXIT', Pid, Reason}, #{service := Pid} = State) -> + case Reason of + normal -> ok; + shutdown -> ok; + _ -> + error_logger:error_msg("hornbeam listener service exited: ~p~n", + [Reason]) + end, + {noreply, State#{service := undefined}}; +handle_info(_Info, State) -> + {noreply, State}. + +%% livery:stop_service/1 is synchronous (livery >= 0.7.0): it closes the +%% listen socket, the acceptors, and every accepted connection before +%% returning, so nothing is left serving the stopped service. +terminate(_Reason, #{service := Pid}) when is_pid(Pid) -> + try livery:stop_service(Pid) + catch _:_ -> ok + end, + ok; +terminate(_Reason, _State) -> + ok. diff --git a/src/hornbeam_request.erl b/src/hornbeam_request.erl index 1a7c4c7..272fbff 100644 --- a/src/hornbeam_request.erl +++ b/src/hornbeam_request.erl @@ -27,6 +27,7 @@ -export([build_wsgi_tuple/2, build_asgi_scope/2]). -export([to_wsgi_header_key/1, format_ip/1, format_http_version/1]). +-export([server_info/2, peer_info/1, scheme/1]). %% @doc Build a pre-parsed WSGI request tuple for Python. %% @@ -35,17 +36,15 @@ %% ContentType, ContentLength, Body, Server, Client, Scheme, Protocol, State} %% %% WsgiHeaders is a map with HTTP_* keys already formatted. --spec build_wsgi_tuple(cowboy_req:req(), map()) -> tuple(). +-spec build_wsgi_tuple(livery_req:req(), map()) -> tuple(). build_wsgi_tuple(Req, State) -> - Method = cowboy_req:method(Req), - Path = cowboy_req:path(Req), - Qs = cowboy_req:qs(Req), - Headers = cowboy_req:headers(Req), - Host = cowboy_req:host(Req), - Port = cowboy_req:port(Req), - Scheme = cowboy_req:scheme(Req), - Version = cowboy_req:version(Req), - {ClientIp, ClientPort} = cowboy_req:peer(Req), + Method = livery_req:method(Req), + Path = livery_req:path(Req), + Qs = livery_req:query(Req), + Headers = livery_req:headers(Req), + Scheme = scheme(State), + {Host, Port} = server_info(Req, State), + {ClientIp, ClientPort} = peer_info(Req), %% Get SCRIPT_NAME and PATH_INFO from state (multi-app) or defaults ScriptName = maps:get(script_name, State, <<>>), @@ -67,9 +66,9 @@ build_wsgi_tuple(Req, State) -> ContentLength, % CONTENT_LENGTH (or undefined) undefined, % Body placeholder (passed via buffer) {Host, Port}, % SERVER_NAME, SERVER_PORT - {format_ip(ClientIp), ClientPort}, % REMOTE_ADDR, REMOTE_PORT + {ClientIp, ClientPort}, % REMOTE_ADDR, REMOTE_PORT Scheme, % wsgi.url_scheme - format_protocol(Version), % SERVER_PROTOCOL + format_protocol(livery_req:protocol(Req)), % SERVER_PROTOCOL LifespanState % Lifespan state }. @@ -78,41 +77,42 @@ build_wsgi_tuple(Req, State) -> %% Headers are pre-formatted as [[name, value], ...] list. %% All binary conversions done in Erlang. %% Handles mount_id and per-mount lifespan state for multi-app mode. --spec build_asgi_scope(cowboy_req:req(), map()) -> map(). +-spec build_asgi_scope(livery_req:req(), map()) -> map(). build_asgi_scope(Req, State) -> - Path = cowboy_req:path(Req), - Version = cowboy_req:version(Req), - {ClientIp, ClientPort} = cowboy_req:peer(Req), + Path = livery_req:path(Req), + Protocol = livery_req:protocol(Req), + {Host, Port} = server_info(Req, State), %% Get root_path and path from state (multi-app) or defaults RootPath = maps:get(script_name, State, <<>>), ScopePath = maps:get(path_info, State, Path), - %% Convert headers to ASGI format [[name, value], ...] - HeaderList = maps:fold(fun(Name, Value, Acc) -> - [[Name, Value] | Acc] - end, [], cowboy_req:headers(Req)), + %% Convert headers to ASGI format [[name, value], ...] preserving + %% duplicates and wire order + HeaderList = [[Name, Value] || {Name, Value} <- livery_req:headers(Req)], %% Get mount_id for per-mount state isolation (multi-app mode) MountId = maps:get(mount_id, State, undefined), + Client = peer_info(Req), + %% Build scope map with all fields %% Note: state is NOT included here - Python fetches lazily via callback %% This avoids copying state dict on every request BaseScope = #{ type => <<"http">>, asgi => #{<<"version">> => <<"3.0">>, <<"spec_version">> => <<"2.4">>}, - http_version => format_http_version(Version), - method => cowboy_req:method(Req), - scheme => cowboy_req:scheme(Req), + http_version => format_http_version(Protocol), + method => livery_req:method(Req), + scheme => scheme(State), path => ScopePath, raw_path => ScopePath, - query_string => cowboy_req:qs(Req), + query_string => livery_req:query(Req), root_path => RootPath, headers => HeaderList, - server => {cowboy_req:host(Req), cowboy_req:port(Req)}, - client => {format_ip(ClientIp), ClientPort}, - extensions => build_extensions(Version) + server => {Host, Port}, + client => Client, + extensions => build_extensions(Protocol) }, %% Add mount_id to scope if in multi-app mode (used by Python to get correct state) @@ -121,6 +121,49 @@ build_asgi_scope(Req, State) -> _ -> BaseScope#{mount_id => MountId} end. +%% @doc URL scheme for the listener that accepted the request. +%% +%% On HTTP/1.1 `livery_req:scheme/1' is always `<<"http">>' even under +%% TLS, so the listener transport recorded in the handler state is +%% authoritative. +-spec scheme(map()) -> binary(). +scheme(State) -> + maps:get(server_scheme, State, <<"http">>). + +%% @doc Server host and port, from the host header (h1) or the +%% `:authority' pseudo-header (h2/h3), falling back to the bind port +%% recorded in the handler state. +-spec server_info(livery_req:req(), map()) -> {binary(), inet:port_number()}. +server_info(Req, State) -> + Authority = case livery_req:header(<<"host">>, Req) of + undefined -> + case livery_req:authority(Req) of + <<>> -> undefined; + A -> A + end; + HostHeader -> + HostHeader + end, + DefaultPort = maps:get(server_port, State, default_port(scheme(State))), + case Authority of + undefined -> + {<<"localhost">>, DefaultPort}; + _ -> + parse_authority(Authority, DefaultPort) + end. + +%% @doc Client address as `{IpBinary, Port}'. +%% +%% `livery_req:peer/1' is set on every adapter, but a synthetic request +%% (test adapter) may still carry none; degrade to an empty address so +%% the WSGI/ASGI shape stays stable. +-spec peer_info(livery_req:req()) -> {binary(), inet:port_number()}. +peer_info(Req) -> + case livery_req:peer(Req) of + undefined -> {<<>>, 0}; + {Ip, Port} -> {format_ip(Ip), Port} + end. + %% @doc Convert header name to WSGI HTTP_* format. %% Example: "accept-encoding" becomes "HTTP_ACCEPT_ENCODING" -spec to_wsgi_header_key(binary()) -> binary(). @@ -141,10 +184,10 @@ format_ip(Addr = {_, _, _, _, _, _, _, _}) -> list_to_binary(inet:ntoa(Addr)). %% @doc Format HTTP version for ASGI. --spec format_http_version(atom()) -> binary(). -format_http_version('HTTP/1.0') -> <<"1.0">>; -format_http_version('HTTP/1.1') -> <<"1.1">>; -format_http_version('HTTP/2') -> <<"2">>. +-spec format_http_version(h1 | h2 | h3) -> binary(). +format_http_version(h1) -> <<"1.1">>; +format_http_version(h2) -> <<"2">>; +format_http_version(h3) -> <<"3">>. %%% ============================================================================ %%% Internal functions @@ -152,9 +195,10 @@ format_http_version('HTTP/2') -> <<"2">>. %% @private %% Convert headers to WSGI format, extracting Content-Type and Content-Length. +%% Duplicate headers are joined with ", " per RFC 9110. %% Returns {WsgiHeadersMap, ContentType, ContentLength} convert_headers_wsgi(Headers) -> - maps:fold(fun(Name, Value, {Acc, CT, CL}) -> + lists:foldl(fun({Name, Value}, {Acc, CT, CL}) -> case Name of <<"content-type">> -> {Acc, Value, CL}; @@ -162,13 +206,42 @@ convert_headers_wsgi(Headers) -> {Acc, CT, Value}; _ -> Key = to_wsgi_header_key(Name), - {Acc#{Key => Value}, CT, CL} + Acc1 = case Acc of + #{Key := Prev} -> Acc#{Key := <>}; + _ -> Acc#{Key => Value} + end, + {Acc1, CT, CL} end end, {#{}, undefined, undefined}, Headers). %% @private -%% Convert lowercase header to uppercase with underscores. -%% Example: "accept-encoding" becomes "ACCEPT_ENCODING" +%% Parse "Host[:Port]" including bracketed IPv6 "[::1]:8000". +parse_authority(<<"[", Rest/binary>> = Authority, DefaultPort) -> + case binary:split(Rest, <<"]">>) of + [Host, <<":", PortBin/binary>>] -> + {<<"[", Host/binary, "]">>, to_port(PortBin, DefaultPort)}; + [Host, _] -> + {<<"[", Host/binary, "]">>, DefaultPort}; + _ -> + {Authority, DefaultPort} + end; +parse_authority(Authority, DefaultPort) -> + case binary:split(Authority, <<":">>) of + [Host, PortBin] -> {Host, to_port(PortBin, DefaultPort)}; + [Host] -> {Host, DefaultPort} + end. + +%% @private +to_port(Bin, Default) -> + try binary_to_integer(Bin) + catch _:_ -> Default + end. + +%% @private +default_port(<<"https">>) -> 443; +default_port(_) -> 80. + +%% @private to_upper_underscore(Bin) -> << <<(upper_char(C))>> || <> <= Bin >>. @@ -177,17 +250,17 @@ upper_char($-) -> $_; upper_char(C) -> C. %% @private -format_protocol('HTTP/1.0') -> <<"HTTP/1.0">>; -format_protocol('HTTP/1.1') -> <<"HTTP/1.1">>; -format_protocol('HTTP/2') -> <<"HTTP/2">>. +format_protocol(h1) -> <<"HTTP/1.1">>; +format_protocol(h2) -> <<"HTTP/2">>; +format_protocol(h3) -> <<"HTTP/3">>. %% @private -build_extensions('HTTP/2') -> - #{ - <<"http.response.trailers">> => #{}, - <<"http.response.early_hints">> => #{} - }; -build_extensions(_) -> - #{ - <<"http.response.early_hints">> => #{} - }. +%% Trailers are an h2/h3 feature. Early hints ride on livery's interim +%% responses, which h1 and h2 send and h3 does not (livery 0.7.0). +build_extensions(h1) -> + #{<<"http.response.early_hints">> => #{}}; +build_extensions(h2) -> + #{<<"http.response.trailers">> => #{}, + <<"http.response.early_hints">> => #{}}; +build_extensions(h3) -> + #{<<"http.response.trailers">> => #{}}. diff --git a/src/hornbeam_sup.erl b/src/hornbeam_sup.erl index a6267a5..4fb9c55 100644 --- a/src/hornbeam_sup.erl +++ b/src/hornbeam_sup.erl @@ -46,6 +46,14 @@ init([]) -> }, Children = [ + #{ + id => hornbeam_listener, + start => {hornbeam_listener, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [hornbeam_listener] + }, #{ id => hornbeam_mounts, start => {hornbeam_mounts, start_link, []}, diff --git a/src/hornbeam_websocket.erl b/src/hornbeam_websocket.erl index b789c36..a63a60b 100644 --- a/src/hornbeam_websocket.erl +++ b/src/hornbeam_websocket.erl @@ -12,36 +12,15 @@ %% See the License for the specific language governing permissions and %% limitations under the License. -%%% @doc Cowboy WebSocket handler for ASGI applications. +%%% @doc WebSocket upgrade and session registry. %%% -%%% This module implements the Cowboy websocket behavior and bridges -%%% to ASGI WebSocket applications through the hornbeam_websocket_runner -%%% Python module. -%%% -%%% == ASGI WebSocket Protocol == -%%% -%%% The handler translates between Cowboy WebSocket and ASGI WebSocket events: -%%% -%%% Erlang -> Python (receive): -%%% - websocket.connect: On WebSocket upgrade -%%% - websocket.receive: On text/binary frame -%%% - websocket.disconnect: On close -%%% -%%% Python -> Erlang (send): -%%% - websocket.accept: Accept connection -%%% - websocket.send: Send text/binary frame -%%% - websocket.close: Close connection +%%% The HTTP-side entry point: builds the ASGI WebSocket scope and hands +%%% the request to the ws session via livery_ws:upgrade/3 with +%%% hornbeam_ws_handler as the session handler. Also owns the +%%% session-id -> pid ETS registry and the pubsub API exposed to Python. -module(hornbeam_websocket). --behaviour(cowboy_websocket). - --export([ - init/2, - websocket_init/1, - websocket_handle/2, - websocket_info/2, - terminate/3 -]). +-export([upgrade/2, build_websocket_scope/2]). %% Pubsub API for Python -export([ @@ -52,26 +31,13 @@ unregister_session/1 ]). --record(state, { - scope :: map(), - app_module :: binary(), - app_callable :: binary(), - session_id :: binary(), - accepted = false :: boolean(), - subprotocol :: binary() | undefined, - subscriptions = [] :: [term()], - %% Channel protocol support - mode = undefined :: undefined | asgi | channel, - channel_pid :: pid() | undefined -}). - -%% @doc Initialize WebSocket connection. +%% @doc Upgrade an HTTP request to a WebSocket session. %% -%% This is called during HTTP request handling to decide whether to -%% upgrade to WebSocket. -init(Req, _Opts) -> - %% Build ASGI WebSocket scope - Scope = build_websocket_scope(Req), +%% Returns the livery response produced by the handshake (101 taken_over +%% on success, 400/500 otherwise). +-spec upgrade(livery_req:req(), map()) -> livery_resp:resp(). +upgrade(Req, State) -> + Scope = build_websocket_scope(Req, State), %% Get app module and callable from config AppModule = hornbeam_config:get_config(app_module), @@ -80,164 +46,42 @@ init(Req, _Opts) -> %% Generate session ID for this connection SessionId = generate_session_id(), - State = #state{ - scope = Scope, - app_module = AppModule, - app_callable = AppCallable, - session_id = SessionId - }, - - %% Get WebSocket options from config WsTimeout = hornbeam_config:get_config(websocket_timeout), - WsMaxFrameSize = hornbeam_config:get_config(websocket_max_frame_size), - WsCompress = hornbeam_config:get_config(websocket_compress, false), - - WsOpts = #{ + MaxFrameSize = hornbeam_config:get_config(websocket_max_frame_size), + Compress = hornbeam_config:get_config(websocket_compress), + + %% Subprotocols are deliberately not passed: livery would reject + %% clients whose offer does not intersect - negotiation happens in + %% the Python app instead, as before + Opts = #{ + scope => Scope, + app_module => AppModule, + app_callable => AppCallable, + session_id => SessionId, idle_timeout => get_value(WsTimeout, 60000), - max_frame_size => get_value(WsMaxFrameSize, 16777216), - compress => WsCompress + max_frame_size => get_value(MaxFrameSize, 16777216), + compress => get_value(Compress, false) =:= true }, + livery_ws:upgrade(Req, hornbeam_ws_handler, Opts). - %% Upgrade to WebSocket - {cowboy_websocket, Req, State, WsOpts}. - -%% @doc WebSocket initialization after upgrade. +%% @doc Build the ASGI WebSocket scope from a livery request. %% -%% Sends websocket.connect event to Python app. -websocket_init(#state{scope = Scope, app_module = AppModule, - app_callable = AppCallable, session_id = SessionId} = State) -> - %% Register session - register_session(SessionId, self()), - - %% Auto-subscribe to pubsub topic based on path (e.g., /chat/general -> chat:general) - Path = maps:get(<<"path">>, Scope, <<>>), - Topic = path_to_topic(Path), - case Topic of - undefined -> ok; - _ -> hornbeam_pubsub:subscribe(Topic, self()) - end, - - %% Start Python WebSocket session - case start_websocket_session(AppModule, AppCallable, Scope, SessionId) of - {ok, Response} -> - handle_connect_response(Response, State#state{subscriptions = [Topic]}); - {error, Reason} -> - unregister_session(SessionId), - error_logger:error_msg("WebSocket init error: ~p~n", [Reason]), - {stop, State} - end. - -%% Convert URL path to pubsub topic: /chat/general -> <<"chat:general">> -path_to_topic(<<"/", Rest/binary>>) -> - binary:replace(Rest, <<"/">>, <<":">>, [global]); -path_to_topic(_) -> - undefined. - -%% @doc Handle incoming WebSocket frames. -websocket_handle({text, Data}, #state{mode = undefined} = State) -> - %% First message - detect protocol - handle_first_message(text, Data, State); -websocket_handle({text, Data}, #state{mode = channel, channel_pid = Pid} = State) -> - %% Channel protocol mode - route to channel process - handle_channel_message(text, Data, Pid, State); -websocket_handle({text, Data}, #state{mode = asgi} = State) -> - %% ASGI mode - standard handling - handle_receive(text, Data, State); -websocket_handle({binary, Data}, #state{mode = undefined} = State) -> - %% Binary data - assume ASGI mode - State1 = State#state{mode = asgi}, - handle_receive(binary, Data, State1); -websocket_handle({binary, Data}, #state{mode = channel, channel_pid = Pid} = State) -> - %% Channel protocol doesn't use binary, but forward anyway - handle_channel_message(binary, Data, Pid, State); -websocket_handle({binary, Data}, #state{mode = asgi} = State) -> - handle_receive(binary, Data, State); -websocket_handle(ping, State) -> - %% Cowboy handles ping/pong automatically - {ok, State}; -websocket_handle(pong, State) -> - {ok, State}; -websocket_handle(_Frame, State) -> - {ok, State}. - -%% @doc Handle Erlang messages sent to the WebSocket process. -websocket_info({websocket_send, text, Data}, State) -> - {[{text, Data}], State}; -websocket_info({websocket_send, binary, Data}, State) -> - {[{binary, Data}], State}; -websocket_info({websocket_close, Code, Reason}, State) -> - {[{close, Code, Reason}], State}; -websocket_info({websocket_close, Code}, State) -> - {[{close, Code, <<>>}], State}; -%% Handle pubsub messages - forward to WebSocket as JSON (ASGI mode only) -websocket_info({pubsub, _Topic, Message}, #state{mode = asgi} = State) when is_map(Message) -> - Json = json:encode(Message), - {[{text, Json}], State}; -websocket_info({pubsub, _Topic, Message}, #state{mode = asgi} = State) when is_binary(Message) -> - {[{text, Message}], State}; -websocket_info({pubsub, _Topic, _Message}, #state{mode = channel} = State) -> - %% Channel mode handles pubsub through the channel process - {ok, State}; -%% Handle channel process death -websocket_info({'DOWN', _Ref, process, Pid, _Reason}, #state{channel_pid = Pid} = State) -> - %% Channel process died - close WebSocket - {[{close, 1011, <<"Channel terminated">>}], State#state{channel_pid = undefined}}; -websocket_info(_Info, State) -> - {ok, State}. - -%% @doc Handle WebSocket termination. -terminate(Reason, _Req, #state{session_id = SessionId, - app_module = AppModule, - app_callable = AppCallable, - mode = Mode, - channel_pid = ChannelPid} = _State) -> - %% Unregister session from pubsub - unregister_session(SessionId), - %% Handle mode-specific cleanup - case Mode of - channel when is_pid(ChannelPid) -> - %% Stop the channel process (it will handle cleanup) - catch gen_server:stop(ChannelPid, normal, 5000); - asgi -> - %% Send disconnect event to Python ASGI app - Code = reason_to_code(Reason), - _ = send_disconnect(AppModule, AppCallable, SessionId, Code); - _ -> - ok - end, - ok; -terminate(_Reason, _Req, _State) -> - %% Non-WebSocket request or request without proper state - ok. - -%%% ============================================================================ -%%% Internal Functions -%%% ============================================================================ - -build_websocket_scope(Req) -> - Method = cowboy_req:method(Req), - Path = cowboy_req:path(Req), - RawPath = cowboy_req:path(Req), - Qs = cowboy_req:qs(Req), - Headers = cowboy_req:headers(Req), - Host = cowboy_req:host(Req), - Port = cowboy_req:port(Req), - Scheme = cowboy_req:scheme(Req), - Version = cowboy_req:version(Req), - - %% Get client info - {ClientIp, ClientPort} = cowboy_req:peer(Req), +%% The client address is unknown at upgrade time; hornbeam_ws_handler +%% fills `<<"client">>' from the ws req map once the session starts. +-spec build_websocket_scope(livery_req:req(), map()) -> map(). +build_websocket_scope(Req, State) -> + Path = livery_req:path(Req), + Headers = livery_req:headers(Req), + {Host, Port} = hornbeam_request:server_info(Req, State), %% Convert headers to list of [name, value] pairs - HeaderList = maps:fold(fun(Name, Value, Acc) -> - [[Name, Value] | Acc] - end, [], Headers), + HeaderList = [[Name, Value] || {Name, Value} <- Headers], %% Get WebSocket subprotocols from Sec-WebSocket-Protocol header - Subprotocols = get_subprotocols(Headers), + Subprotocols = get_subprotocols(Req), - %% Determine WebSocket scheme - WsScheme = case Scheme of + %% Determine WebSocket scheme from the listener transport + WsScheme = case hornbeam_request:scheme(State) of <<"https">> -> <<"wss">>; _ -> <<"ws">> end, @@ -248,21 +92,22 @@ build_websocket_scope(Req) -> <<"version">> => <<"3.0">>, <<"spec_version">> => <<"2.4">> }, - <<"http_version">> => format_http_version(Version), - <<"method">> => Method, + <<"http_version">> => + hornbeam_request:format_http_version(livery_req:protocol(Req)), + <<"method">> => livery_req:method(Req), <<"scheme">> => WsScheme, <<"path">> => Path, - <<"raw_path">> => RawPath, - <<"query_string">> => Qs, + <<"raw_path">> => Path, + <<"query_string">> => livery_req:query(Req), <<"root_path">> => <<>>, <<"headers">> => HeaderList, <<"server">> => [Host, Port], - <<"client">> => [format_ip(ClientIp), ClientPort], + <<"client">> => [<<>>, 0], <<"subprotocols">> => Subprotocols }. -get_subprotocols(Headers) -> - case maps:get(<<"sec-websocket-protocol">>, Headers, undefined) of +get_subprotocols(Req) -> + case livery_req:header(<<"sec-websocket-protocol">>, Req) of undefined -> []; Protocols -> %% Parse comma-separated protocol list @@ -270,18 +115,6 @@ get_subprotocols(Headers) -> [string:trim(P) || P <- Parts] end. -format_http_version('HTTP/1.0') -> <<"1.0">>; -format_http_version('HTTP/1.1') -> <<"1.1">>; -format_http_version('HTTP/2') -> <<"2">>. - -%% IPv4 -format_ip({A, B, C, D}) -> - iolist_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D])); -%% IPv6 -format_ip({A, B, C, D, E, F, G, H}) -> - iolist_to_binary(io_lib:format("~.16B:~.16B:~.16B:~.16B:~.16B:~.16B:~.16B:~.16B", - [A, B, C, D, E, F, G, H])). - generate_session_id() -> Bytes = crypto:strong_rand_bytes(16), Base64 = base64:encode(Bytes), @@ -292,169 +125,13 @@ generate_session_id() -> get_value(undefined, Default) -> Default; get_value(Value, _Default) -> Value. -start_websocket_session(AppModule, AppCallable, Scope, SessionId) -> - Timeout = hornbeam_config:get_config(timeout), - TimeoutMs = case Timeout of - undefined -> 30000; - T -> T - end, - py:call(hornbeam_websocket_runner, start_session, - [AppModule, AppCallable, Scope, SessionId], #{}, TimeoutMs). - -handle_connect_response(Response, State) -> - case maps:get(<<"type">>, Response, undefined) of - <<"websocket.accept">> -> - Subprotocol = maps:get(<<"subprotocol">>, Response, undefined), - NewState = State#state{accepted = true, subprotocol = Subprotocol}, - %% Connection accepted, continue with WebSocket - {ok, NewState}; - <<"websocket.close">> -> - Code = maps:get(<<"code">>, Response, 1000), - Reason = maps:get(<<"reason">>, Response, <<>>), - {[{close, Code, Reason}], State}; - _ -> - %% Unknown response, close connection - {[{close, 1002, <<"Protocol error">>}], State} - end. - -handle_receive(Type, Data, #state{session_id = SessionId, - app_module = AppModule, - app_callable = AppCallable} = State) -> - Timeout = hornbeam_config:get_config(timeout), - TimeoutMs = case Timeout of - undefined -> 30000; - T -> T - end, - - TypeStr = case Type of - text -> <<"text">>; - binary -> <<"bytes">> - end, - - case py:call(hornbeam_websocket_runner, receive_message, - [AppModule, AppCallable, SessionId, TypeStr, Data], #{}, TimeoutMs) of - {ok, Responses} when is_list(Responses) -> - process_responses(Responses, State); - {ok, Response} when is_map(Response) -> - process_responses([Response], State); - {error, Reason} -> - error_logger:error_msg("WebSocket receive error: ~p~n", [Reason]), - {[{close, 1011, <<"Server error">>}], State} - end. - -process_responses([], State) -> - {ok, State}; -process_responses(Responses, State) -> - %% First, handle any broadcast requests - lists:foreach(fun(Response) -> - case maps:get(<<"type">>, Response, undefined) of - <<"hornbeam.broadcast">> -> - Topic = maps:get(<<"topic">>, Response), - Message = maps:get(<<"message">>, Response), - hornbeam_pubsub:publish(Topic, Message); - _ -> - ok - end - end, Responses), - %% Then collect WebSocket frames to send - Frames = lists:filtermap(fun(Response) -> - case maps:get(<<"type">>, Response, undefined) of - <<"websocket.send">> -> - case maps:get(<<"text">>, Response, undefined) of - undefined -> - case maps:get(<<"bytes">>, Response, undefined) of - undefined -> false; - Bytes -> {true, {binary, Bytes}} - end; - Text -> {true, {text, Text}} - end; - <<"websocket.close">> -> - Code = maps:get(<<"code">>, Response, 1000), - Reason = maps:get(<<"reason">>, Response, <<>>), - {true, {close, Code, Reason}}; - _ -> - false - end - end, Responses), - case Frames of - [] -> {ok, State}; - _ -> {Frames, State} - end. - -send_disconnect(AppModule, AppCallable, SessionId, Code) -> - Timeout = hornbeam_config:get_config(timeout), - TimeoutMs = case Timeout of - undefined -> 5000; % Shorter timeout for disconnect - T -> min(T, 5000) - end, - catch py:call(hornbeam_websocket_runner, disconnect, - [AppModule, AppCallable, SessionId, Code], #{}, TimeoutMs). - -reason_to_code(normal) -> 1000; -reason_to_code(shutdown) -> 1001; -reason_to_code(timeout) -> 1002; -reason_to_code(remote) -> 1000; -reason_to_code({remote, Code, _Reason}) -> Code; -reason_to_code(_) -> 1006. - -%%% ============================================================================ -%%% Channel Protocol Support -%%% ============================================================================ - -%% @doc Handle first message to detect protocol. -%% Channel protocol uses JSON arrays: [join_ref, ref, topic, event, payload] -handle_first_message(text, Data, State) -> - case try_parse_channel_message(Data) of - {ok, Message} when is_list(Message), length(Message) =:= 5 -> - %% Detected channel protocol - start_channel_mode(Message, State); - _ -> - %% Not channel protocol, use ASGI mode - State1 = State#state{mode = asgi}, - handle_receive(text, Data, State1) - end. - -try_parse_channel_message(Data) -> - try - {ok, json:decode(Data)} - catch - _:_ -> error - end. - -start_channel_mode(Message, #state{session_id = SessionId} = State) -> - %% Start channel process for this connection - case hornbeam_channel:start_link(self(), SessionId) of - {ok, Pid} -> - %% Monitor the channel process - erlang:monitor(process, Pid), - %% Route the initial message - hornbeam_channel:handle_message(Pid, Message), - {ok, State#state{mode = channel, channel_pid = Pid, accepted = true}}; - {error, Reason} -> - error_logger:error_msg("Failed to start channel process: ~p~n", [Reason]), - {[{close, 1011, <<"Server error">>}], State} - end. - -handle_channel_message(text, Data, Pid, State) -> - case try_parse_channel_message(Data) of - {ok, Message} -> - hornbeam_channel:handle_message(Pid, Message), - {ok, State}; - error -> - %% Invalid JSON - close connection - {[{close, 1007, <<"Invalid JSON">>}], State} - end; -handle_channel_message(binary, _Data, _Pid, State) -> - %% Channel protocol is text-only - {ok, State}. - %%% ============================================================================ %%% Pubsub API for Python %%% ============================================================================ -define(SESSION_TABLE, hornbeam_ws_sessions). -%% @doc Register a WebSocket session (called from websocket_init). +%% @doc Register a WebSocket session (called from hornbeam_ws_handler). -spec register_session(binary(), pid()) -> ok. register_session(SessionId, Pid) -> _ = ensure_session_table(), @@ -465,7 +142,9 @@ register_session(SessionId, Pid) -> %% @doc Unregister a WebSocket session. -spec unregister_session(binary()) -> ok. unregister_session(SessionId) -> - catch ets:delete(?SESSION_TABLE, SessionId), + try ets:delete(?SESSION_TABLE, SessionId) + catch _:_ -> true + end, ok. %% @doc Subscribe a WebSocket session to a pubsub topic. diff --git a/src/hornbeam_ws_handler.erl b/src/hornbeam_ws_handler.erl new file mode 100644 index 0000000..e56bfa3 --- /dev/null +++ b/src/hornbeam_ws_handler.erl @@ -0,0 +1,343 @@ +%% Copyright 2026 Benoit Chesneau +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. + +%%% @doc WebSocket session handler (ws_handler behaviour) for ASGI apps. +%%% +%%% Bridges the ws session to ASGI WebSocket applications through the +%%% hornbeam_websocket_runner Python module, or to Phoenix-style channels +%%% when the first text frame is a JSON array of 5 elements. +%%% +%%% == ASGI WebSocket Protocol == +%%% +%%% Erlang -> Python (receive): +%%% - websocket.connect: On WebSocket upgrade +%%% - websocket.receive: On text/binary frame +%%% - websocket.disconnect: On close +%%% +%%% Python -> Erlang (send): +%%% - websocket.accept: Accept connection +%%% - websocket.send: Send text/binary frame +%%% - websocket.close: Close connection +-module(hornbeam_ws_handler). + +-behaviour(ws_handler). + +-export([init/2, handle_in/2, handle_info/2, terminate/2]). + +-record(state, { + scope :: map(), + app_module :: binary(), + app_callable :: binary(), + session_id :: binary(), + accepted = false :: boolean(), + subprotocol :: binary() | undefined, + subscriptions = [] :: [term()], + %% Channel protocol support + mode = undefined :: undefined | asgi | channel, + channel_pid :: pid() | undefined +}). + +%% @doc Session start after a successful upgrade handshake. +%% +%% `Opts' comes from hornbeam_websocket:upgrade/2. The ws `Req' map +%% carries the socket peer, which the upgrade-time scope could not know. +init(Req, Opts) -> + Scope0 = maps:get(scope, Opts), + Scope = case maps:get(peer, Req, undefined) of + {Ip, Port} -> + Scope0#{<<"client">> => [hornbeam_request:format_ip(Ip), Port]}; + _ -> + Scope0 + end, + SessionId = maps:get(session_id, Opts), + State = #state{ + scope = Scope, + app_module = maps:get(app_module, Opts), + app_callable = maps:get(app_callable, Opts), + session_id = SessionId + }, + + %% Register session + hornbeam_websocket:register_session(SessionId, self()), + + %% Auto-subscribe to pubsub topic based on path (e.g., /chat/general -> chat:general) + Path = maps:get(<<"path">>, Scope, <<>>), + Topic = path_to_topic(Path), + case Topic of + undefined -> ok; + _ -> hornbeam_pubsub:subscribe(Topic, self()) + end, + + %% Start Python WebSocket session + case start_websocket_session(State) of + {ok, Response} -> + handle_connect_response(Response, State#state{subscriptions = [Topic]}); + {error, Reason} -> + hornbeam_websocket:unregister_session(SessionId), + error_logger:error_msg("WebSocket init error: ~p~n", [Reason]), + {stop, ws_init_failed, State} + end. + +%% Convert URL path to pubsub topic: /chat/general -> <<"chat:general">> +path_to_topic(<<"/", Rest/binary>>) -> + binary:replace(Rest, <<"/">>, <<":">>, [global]); +path_to_topic(_) -> + undefined. + +%% @doc Handle incoming WebSocket frames. +handle_in({text, Data}, #state{mode = undefined} = State) -> + %% First message - detect protocol + handle_first_message(text, Data, State); +handle_in({text, Data}, #state{mode = channel, channel_pid = Pid} = State) -> + %% Channel protocol mode - route to channel process + handle_channel_message(text, Data, Pid, State); +handle_in({text, Data}, #state{mode = asgi} = State) -> + %% ASGI mode - standard handling + handle_receive(text, Data, State); +handle_in({binary, Data}, #state{mode = undefined} = State) -> + %% Binary data - assume ASGI mode + State1 = State#state{mode = asgi}, + handle_receive(binary, Data, State1); +handle_in({binary, Data}, #state{mode = channel, channel_pid = Pid} = State) -> + %% Channel protocol doesn't use binary, but forward anyway + handle_channel_message(binary, Data, Pid, State); +handle_in({binary, Data}, #state{mode = asgi} = State) -> + handle_receive(binary, Data, State); +handle_in({ping, _}, State) -> + %% ws_session already replied with a pong + {ok, State}; +handle_in({pong, _}, State) -> + {ok, State}; +handle_in(_Frame, State) -> + {ok, State}. + +%% @doc Handle Erlang messages sent to the WebSocket session process. +handle_info({websocket_send, text, Data}, State) -> + {reply, {text, Data}, State}; +handle_info({websocket_send, binary, Data}, State) -> + {reply, {binary, Data}, State}; +handle_info({websocket_close, Code, Reason}, State) -> + {reply, {close, Code, Reason}, State}; +handle_info({websocket_close, Code}, State) -> + {reply, {close, Code, <<>>}, State}; +%% Handle pubsub messages - forward to WebSocket as JSON (ASGI mode only) +handle_info({pubsub, _Topic, Message}, #state{mode = asgi} = State) when is_map(Message) -> + Json = json:encode(Message), + {reply, {text, Json}, State}; +handle_info({pubsub, _Topic, Message}, #state{mode = asgi} = State) when is_binary(Message) -> + {reply, {text, Message}, State}; +handle_info({pubsub, _Topic, _Message}, #state{mode = channel} = State) -> + %% Channel mode handles pubsub through the channel process + {ok, State}; +%% Handle channel process death +handle_info({'DOWN', _Ref, process, Pid, _Reason}, #state{channel_pid = Pid} = State) -> + %% Channel process died - close WebSocket + {reply, {close, 1011, <<"Channel terminated">>}, State#state{channel_pid = undefined}}; +handle_info(_Info, State) -> + {ok, State}. + +%% @doc Handle WebSocket termination. +terminate(Reason, #state{session_id = SessionId, + mode = Mode, + channel_pid = ChannelPid} = State) -> + %% Unregister session from pubsub + hornbeam_websocket:unregister_session(SessionId), + %% Handle mode-specific cleanup + _ = case Mode of + channel when is_pid(ChannelPid) -> + %% Stop the channel process (it will handle cleanup) + _ = try gen_server:stop(ChannelPid, normal, 5000) + catch _:_ -> ok + end, + ok; + asgi -> + %% Send disconnect event to Python ASGI app + Code = reason_to_code(Reason), + _ = send_disconnect(State, Code); + _ -> + ok + end, + ok; +terminate(_Reason, _State) -> + %% Session torn down before init completed + ok. + +%%% ============================================================================ +%%% Internal Functions +%%% ============================================================================ + +start_websocket_session(#state{app_module = AppModule, app_callable = AppCallable, + scope = Scope, session_id = SessionId}) -> + Timeout = hornbeam_config:get_config(timeout), + TimeoutMs = case Timeout of + undefined -> 30000; + T -> T + end, + py:call(hornbeam_websocket_runner, start_session, + [AppModule, AppCallable, Scope, SessionId], #{}, TimeoutMs). + +handle_connect_response(Response, State) -> + case maps:get(<<"type">>, Response, undefined) of + <<"websocket.accept">> -> + Subprotocol = maps:get(<<"subprotocol">>, Response, undefined), + NewState = State#state{accepted = true, subprotocol = Subprotocol}, + %% Connection accepted, continue with WebSocket + {ok, NewState}; + <<"websocket.close">> -> + Code = maps:get(<<"code">>, Response, 1000), + Reason = maps:get(<<"reason">>, Response, <<>>), + {reply, {close, Code, Reason}, State}; + _ -> + %% Unknown response, close connection + {reply, {close, 1002, <<"Protocol error">>}, State} + end. + +handle_receive(Type, Data, #state{session_id = SessionId, + app_module = AppModule, + app_callable = AppCallable} = State) -> + Timeout = hornbeam_config:get_config(timeout), + TimeoutMs = case Timeout of + undefined -> 30000; + T -> T + end, + + TypeStr = case Type of + text -> <<"text">>; + binary -> <<"bytes">> + end, + + case py:call(hornbeam_websocket_runner, receive_message, + [AppModule, AppCallable, SessionId, TypeStr, Data], #{}, TimeoutMs) of + {ok, Responses} when is_list(Responses) -> + process_responses(Responses, State); + {ok, Response} when is_map(Response) -> + process_responses([Response], State); + {error, Reason} -> + error_logger:error_msg("WebSocket receive error: ~p~n", [Reason]), + {reply, {close, 1011, <<"Server error">>}, State} + end. + +process_responses([], State) -> + {ok, State}; +process_responses(Responses, State) -> + %% First, handle any broadcast requests + lists:foreach(fun(Response) -> + case maps:get(<<"type">>, Response, undefined) of + <<"hornbeam.broadcast">> -> + Topic = maps:get(<<"topic">>, Response), + Message = maps:get(<<"message">>, Response), + hornbeam_pubsub:publish(Topic, Message); + _ -> + ok + end + end, Responses), + %% Then collect WebSocket frames to send + Frames = lists:filtermap(fun(Response) -> + case maps:get(<<"type">>, Response, undefined) of + <<"websocket.send">> -> + case maps:get(<<"text">>, Response, undefined) of + undefined -> + case maps:get(<<"bytes">>, Response, undefined) of + undefined -> false; + Bytes -> {true, {binary, Bytes}} + end; + Text -> {true, {text, Text}} + end; + <<"websocket.close">> -> + Code = maps:get(<<"code">>, Response, 1000), + Reason = maps:get(<<"reason">>, Response, <<>>), + {true, {close, Code, Reason}}; + _ -> + false + end + end, Responses), + case Frames of + [] -> {ok, State}; + _ -> {reply, Frames, State} + end. + +send_disconnect(#state{app_module = AppModule, app_callable = AppCallable, + session_id = SessionId}, Code) -> + Timeout = hornbeam_config:get_config(timeout), + TimeoutMs = case Timeout of + undefined -> 5000; % Shorter timeout for disconnect + T -> min(T, 5000) + end, + try + py:call(hornbeam_websocket_runner, disconnect, + [AppModule, AppCallable, SessionId, Code], #{}, TimeoutMs) + catch + _:_ -> ok + end. + +%% The peer's own close code when it sent one (livery >= 0.7.0); +%% otherwise derived from the local terminate reason. +reason_to_code({remote, Code, _Reason}) when is_integer(Code) -> Code; +reason_to_code(remote) -> 1005; +reason_to_code(normal) -> 1000; +reason_to_code(shutdown) -> 1001; +reason_to_code(timeout) -> 1002; +reason_to_code({transport_error, _}) -> 1006; +reason_to_code(_) -> 1006. + +%%% ============================================================================ +%%% Channel Protocol Support +%%% ============================================================================ + +%% @doc Handle first message to detect protocol. +%% Channel protocol uses JSON arrays: [join_ref, ref, topic, event, payload] +handle_first_message(text, Data, State) -> + case try_parse_channel_message(Data) of + {ok, Message} when is_list(Message), length(Message) =:= 5 -> + %% Detected channel protocol + start_channel_mode(Message, State); + _ -> + %% Not channel protocol, use ASGI mode + State1 = State#state{mode = asgi}, + handle_receive(text, Data, State1) + end. + +try_parse_channel_message(Data) -> + try + {ok, json:decode(Data)} + catch + _:_ -> error + end. + +start_channel_mode(Message, #state{session_id = SessionId} = State) -> + %% Start channel process for this connection + case hornbeam_channel:start_link(self(), SessionId) of + {ok, Pid} -> + %% Monitor the channel process + _ = erlang:monitor(process, Pid), + %% Route the initial message + hornbeam_channel:handle_message(Pid, Message), + {ok, State#state{mode = channel, channel_pid = Pid, accepted = true}}; + {error, Reason} -> + error_logger:error_msg("Failed to start channel process: ~p~n", [Reason]), + {reply, {close, 1011, <<"Server error">>}, State} + end. + +handle_channel_message(text, Data, Pid, State) -> + case try_parse_channel_message(Data) of + {ok, Message} -> + hornbeam_channel:handle_message(Pid, Message), + {ok, State}; + error -> + %% Invalid JSON - close connection + {reply, {close, 1007, <<"Invalid JSON">>}, State} + end; +handle_channel_message(binary, _Data, _Pid, State) -> + %% Channel protocol is text-only + {ok, State}. diff --git a/src/hornbeam_wsgi.erl b/src/hornbeam_wsgi.erl deleted file mode 100644 index 75b6ffa..0000000 --- a/src/hornbeam_wsgi.erl +++ /dev/null @@ -1,221 +0,0 @@ -%% Copyright 2026 Benoit Chesneau -%% -%% Licensed under the Apache License, Version 2.0 (the "License"); -%% you may not use this file except in compliance with the License. -%% You may obtain a copy of the License at -%% -%% http://www.apache.org/licenses/LICENSE-2.0 -%% -%% Unless required by applicable law or agreed to in writing, software -%% distributed under the License is distributed on an "AS IS" BASIS, -%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -%% See the License for the specific language governing permissions and -%% limitations under the License. - -%%% @doc WSGI environment builder and protocol handling. -%%% -%%% Builds a PEP 3333 compliant WSGI environ dictionary from a Cowboy request. -%%% This module creates environ dicts with all required and recommended WSGI -%%% variables for full PEP 3333 compliance. -%%% -%%% == Required CGI Variables == -%%% - REQUEST_METHOD -%%% - SCRIPT_NAME -%%% - PATH_INFO -%%% - QUERY_STRING -%%% - CONTENT_TYPE (if present) -%%% - CONTENT_LENGTH (if present) -%%% - SERVER_NAME -%%% - SERVER_PORT -%%% - SERVER_PROTOCOL -%%% - HTTP_* (for each request header) -%%% -%%% == Required WSGI Variables == -%%% - wsgi.version: (1, 0) -%%% - wsgi.url_scheme: "http" or "https" -%%% - wsgi.input: Request body -%%% - wsgi.errors: "stderr" (routed to logging in Python) -%%% - wsgi.multithread: true -%%% - wsgi.multiprocess: true -%%% - wsgi.run_once: false -%%% -%%% == Recommended Extensions == -%%% - wsgi.file_wrapper: FileWrapper class -%%% - wsgi.input_terminated: true -%%% - wsgi.early_hints: callback function -%%% -%%% == Additional Variables == -%%% - REMOTE_ADDR: Client IP address -%%% - REMOTE_PORT: Client port -%%% - REMOTE_HOST: Client hostname (if available) --module(hornbeam_wsgi). - --export([ - build_environ/1, - build_environ/2 -]). - --type environ_opts() :: #{ - script_name => binary(), - root_path => binary(), - proxy_allow_from => [binary()], - proxy_headers => [binary()] -}. - -%% @doc Build a WSGI environ dictionary from a Cowboy request. -%% -%% Creates a dictionary containing all standard WSGI variables. --spec build_environ(cowboy_req:req()) -> map(). -build_environ(Req) -> - build_environ(Req, #{}). - -%% @doc Build a WSGI environ dictionary with options. -%% -%% Options: -%% - script_name: Override SCRIPT_NAME (default: empty binary) -%% - root_path: Root path for the application -%% - proxy_allow_from: IPs allowed to send proxy headers -%% - proxy_headers: Headers to check for proxied client info --spec build_environ(cowboy_req:req(), environ_opts()) -> map(). -build_environ(Req, Opts) -> - %% Get basic request info - Method = cowboy_req:method(Req), - Path = cowboy_req:path(Req), - Qs = cowboy_req:qs(Req), - Headers = cowboy_req:headers(Req), - Host = cowboy_req:host(Req), - Port = cowboy_req:port(Req), - Scheme = cowboy_req:scheme(Req), - Version = cowboy_req:version(Req), - - %% Get client info - {ClientIp, ClientPort} = cowboy_req:peer(Req), - - %% Check for proxy headers (X-Forwarded-For, X-Real-IP, etc.) - {RemoteAddr, RemotePort} = get_remote_addr(Headers, ClientIp, ClientPort, Opts), - - %% Read body for wsgi.input - {ok, Body, _Req2} = cowboy_req:read_body(Req), - - %% Get SCRIPT_NAME from options or default - ScriptName = maps:get(script_name, Opts, <<>>), - - %% Build base environ - BaseEnviron = #{ - %% Required CGI variables - <<"REQUEST_METHOD">> => Method, - <<"SCRIPT_NAME">> => ScriptName, - <<"PATH_INFO">> => Path, - <<"QUERY_STRING">> => Qs, - <<"SERVER_NAME">> => Host, - <<"SERVER_PORT">> => integer_to_binary(Port), - <<"SERVER_PROTOCOL">> => format_protocol(Version), - - %% Required WSGI variables - <<"wsgi.version">> => [1, 0], - <<"wsgi.url_scheme">> => Scheme, - <<"wsgi.input">> => Body, - <<"wsgi.errors">> => <<"stderr">>, - <<"wsgi.multithread">> => true, - <<"wsgi.multiprocess">> => true, - <<"wsgi.run_once">> => false, - - %% Recommended extensions (handled in Python runner) - %% wsgi.file_wrapper - set in Python - %% wsgi.input_terminated - set in Python - %% wsgi.early_hints - set in Python - - %% Client info - <<"REMOTE_ADDR">> => RemoteAddr, - <<"REMOTE_PORT">> => integer_to_binary(RemotePort) - }, - - %% Add CONTENT_TYPE and CONTENT_LENGTH if present - Environ1 = case maps:get(<<"content-type">>, Headers, undefined) of - undefined -> BaseEnviron; - CT -> BaseEnviron#{<<"CONTENT_TYPE">> => CT} - end, - - Environ2 = case maps:get(<<"content-length">>, Headers, undefined) of - undefined -> Environ1; - CL -> Environ1#{<<"CONTENT_LENGTH">> => CL} - end, - - %% Add HTTP_* headers - add_http_headers(Headers, Environ2). - -%% @private -%% Get remote address, checking proxy headers if configured -get_remote_addr(Headers, ClientIp, ClientPort, Opts) -> - ProxyAllowFrom = maps:get(proxy_allow_from, Opts, []), - ProxyHeaders = maps:get(proxy_headers, Opts, - [<<"x-forwarded-for">>, <<"x-real-ip">>]), - - ClientIpBin = format_ip(ClientIp), - - %% Check if client is in proxy allowlist - case is_proxy_allowed(ClientIpBin, ProxyAllowFrom) of - true -> - %% Try to get real client IP from proxy headers - case get_forwarded_addr(Headers, ProxyHeaders) of - {ok, ForwardedAddr} -> {ForwardedAddr, 0}; - not_found -> {ClientIpBin, ClientPort} - end; - false -> - {ClientIpBin, ClientPort} - end. - -%% @private -is_proxy_allowed(_ClientIp, []) -> false; -is_proxy_allowed(ClientIp, AllowList) -> - lists:member(ClientIp, AllowList). - -%% @private -get_forwarded_addr(Headers, [Header | Rest]) -> - case maps:get(Header, Headers, undefined) of - undefined -> - get_forwarded_addr(Headers, Rest); - Value -> - %% X-Forwarded-For can contain multiple IPs, take first - case binary:split(Value, <<",">>) of - [Ip | _] -> {ok, string:trim(Ip)}; - _ -> {ok, Value} - end - end; -get_forwarded_addr(_Headers, []) -> - not_found. - -%% @private -format_protocol('HTTP/1.0') -> <<"HTTP/1.0">>; -format_protocol('HTTP/1.1') -> <<"HTTP/1.1">>; -format_protocol('HTTP/2') -> <<"HTTP/2">>. - -%% @private -%% IPv4 -format_ip({A, B, C, D}) -> - iolist_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D])); -%% IPv6 -format_ip({A, B, C, D, E, F, G, H}) -> - iolist_to_binary(io_lib:format("~.16B:~.16B:~.16B:~.16B:~.16B:~.16B:~.16B:~.16B", - [A, B, C, D, E, F, G, H])). - -%% @private -add_http_headers(Headers, Environ) -> - maps:fold(fun(Name, Value, Acc) -> - %% Skip content-type and content-length (handled separately) - case Name of - <<"content-type">> -> Acc; - <<"content-length">> -> Acc; - _ -> - %% Convert header name to HTTP_* format - HttpName = header_to_cgi(Name), - Acc#{HttpName => Value} - end - end, Environ, Headers). - -%% @private -%% Convert header name to CGI format: foo-bar -> HTTP_FOO_BAR -header_to_cgi(Name) when is_binary(Name) -> - Upper = string:uppercase(Name), - Underscored = binary:replace(Upper, <<"-">>, <<"_">>, [global]), - <<"HTTP_", Underscored/binary>>. diff --git a/test/hornbeam_SUITE.erl b/test/hornbeam_SUITE.erl index 12f7865..c32fd3c 100644 --- a/test/hornbeam_SUITE.erl +++ b/test/hornbeam_SUITE.erl @@ -93,15 +93,13 @@ test_start_stop(_Config) -> ok = hornbeam:start("hello_wsgi.app:application"), %% Verify listener is running - Listeners = ranch:info(), - ?assert(lists:keymember(hornbeam_http, 1, Listeners)), + ?assert(hornbeam:is_running()), %% Test stopping ok = hornbeam:stop(), %% Verify listener is stopped - Listeners2 = ranch:info(), - ?assertNot(lists:keymember(hornbeam_http, 1, Listeners2)). + ?assertNot(hornbeam:is_running()). test_config(_Config) -> %% Test config management diff --git a/test/hornbeam_asgi_SUITE.erl b/test/hornbeam_asgi_SUITE.erl index 2598bb4..07c97c0 100644 --- a/test/hornbeam_asgi_SUITE.erl +++ b/test/hornbeam_asgi_SUITE.erl @@ -36,7 +36,8 @@ test_hello_world/1, test_not_found/1, test_request_info/1, - test_post_echo/1 + test_post_echo/1, + test_early_hints/1 ]). %% ASGI compliance tests @@ -116,7 +117,8 @@ groups() -> test_hello_world, test_not_found, test_request_info, - test_post_echo + test_post_echo, + test_early_hints ]}, {asgi_compliance, [sequence], [ test_asgi_scope_type, @@ -169,11 +171,11 @@ groups() -> init_per_suite(Config) -> {ok, _} = application:ensure_all_started(hornbeam), - {ok, _} = application:ensure_all_started(hackney), + {ok, _} = application:ensure_all_started(livery), Config. end_per_suite(_Config) -> - application:stop(hackney), + ok, application:stop(hornbeam), ok. @@ -251,19 +253,19 @@ make_url(Config, Path) -> test_hello_world(Config) -> Url = make_url(Config, <<"/">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assertEqual(<<"Hello from ASGI Test App!\n">>, Body). test_not_found(Config) -> Url = make_url(Config, <<"/nonexistent">>), - {ok, 404, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 404, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assertEqual(<<"Not Found\n">>, Body). test_request_info(Config) -> Url = make_url(Config, <<"/info?foo=bar&baz=qux">>), - {ok, 200, Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}} = Resp} = hornbeam_test_http:request(get, Url, #{}), - ContentType = proplists:get_value(<<"content-type">>, Headers), + ContentType = livery_client:header(<<"content-type">>, Resp), ?assertEqual(<<"application/json">>, ContentType), Info = jsx:decode(Body, [return_maps]), @@ -279,25 +281,49 @@ test_post_echo(Config) -> Url = make_url(Config, <<"/echo">>), ReqBody = <<"Hello, ASGI Echo!">>, Headers = [{<<"Content-Type">>, <<"text/plain">>}], - {ok, 200, RespHeaders, RespBody} = hackney:request(post, Url, Headers, ReqBody, []), + {ok, #{status := 200, body := {full, RespBody}} = Resp} = hornbeam_test_http:request(post, Url, #{headers => Headers, body => ReqBody}), ?assertEqual(ReqBody, RespBody), - EchoLength = proplists:get_value(<<"x-echo-length">>, RespHeaders), + EchoLength = livery_client:header(<<"x-echo-length">>, Resp), ?assertEqual(<<"17">>, EchoLength). +%% A `http.response.informational' event with status 103 reaches the +%% wire as its own head, ahead of the final response. Read raw: an HTTP +%% client library swallows interim responses. +test_early_hints(Config) -> + Port = proplists:get_value(port, Config), + {ok, Sock} = gen_tcp:connect("127.0.0.1", Port, + [binary, {active, false}, {packet, raw}]), + ok = gen_tcp:send(Sock, [<<"GET /early-hints HTTP/1.1\r\n">>, + <<"Host: 127.0.0.1\r\n">>, + <<"Connection: close\r\n\r\n">>]), + Resp = recv_until_closed(Sock, <<>>), + ok = gen_tcp:close(Sock), + + ?assertMatch(<<"HTTP/1.1 103", _/binary>>, Resp), + ?assert(binary:match(Resp, <<"rel=preload">>) =/= nomatch), + ?assert(binary:match(Resp, <<"HTTP/1.1 200">>) =/= nomatch), + ?assert(binary:match(Resp, <<"Response with early hints">>) =/= nomatch). + +recv_until_closed(Sock, Acc) -> + case gen_tcp:recv(Sock, 0, 5000) of + {ok, Data} -> recv_until_closed(Sock, <>); + {error, _} -> Acc + end. + %%% ============================================================================ %%% ASGI compliance tests %%% ============================================================================ test_asgi_scope_type(Config) -> Url = make_url(Config, <<"/scope">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Scope = jsx:decode(Body, [return_maps]), ?assertEqual(<<"http">>, maps:get(<<"type">>, Scope)). test_asgi_scope_version(Config) -> Url = make_url(Config, <<"/scope">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Scope = jsx:decode(Body, [return_maps]), %% ASGI 3.0 requires 'asgi' key with 'version' and optionally 'spec_version' @@ -312,17 +338,17 @@ test_asgi_scope_method(Config) -> %% Test various methods Url = make_url(Config, <<"/scope">>), - {ok, 200, _, Body1} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body1}}} = hornbeam_test_http:request(get, Url, #{}), Scope1 = jsx:decode(Body1, [return_maps]), ?assertEqual(<<"GET">>, maps:get(<<"method">>, Scope1)), - {ok, 200, _, Body2} = hackney:request(post, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body2}}} = hornbeam_test_http:request(post, Url, #{}), Scope2 = jsx:decode(Body2, [return_maps]), ?assertEqual(<<"POST">>, maps:get(<<"method">>, Scope2)). test_asgi_scope_path(Config) -> Url = make_url(Config, <<"/scope">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Scope = jsx:decode(Body, [return_maps]), Path = maps:get(<<"path">>, Scope), ?assert(is_binary(Path)), @@ -330,7 +356,7 @@ test_asgi_scope_path(Config) -> test_asgi_scope_query_string(Config) -> Url = make_url(Config, <<"/scope?key1=value1&key2=value2">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Scope = jsx:decode(Body, [return_maps]), QueryString = maps:get(<<"query_string">>, Scope), ?assert(is_binary(QueryString)), @@ -342,7 +368,7 @@ test_asgi_scope_headers(Config) -> {<<"X-Custom-Test">>, <<"test-value">>}, {<<"Accept">>, <<"application/json">>} ], - {ok, 200, _RespHeaders, Body} = hackney:request(get, Url, ReqHeaders, <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{headers => ReqHeaders}), Scope = jsx:decode(Body, [return_maps]), %% Headers should be a list of [name, value] pairs @@ -352,7 +378,7 @@ test_asgi_scope_headers(Config) -> test_asgi_scope_server(Config) -> Port = proplists:get_value(port, Config), Url = make_url(Config, <<"/scope">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Scope = jsx:decode(Body, [return_maps]), %% Server is a tuple/list [host, port] in ASGI scope @@ -369,17 +395,21 @@ test_asgi_scope_server(Config) -> test_asgi_scope_client(Config) -> Url = make_url(Config, <<"/scope">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Scope = jsx:decode(Body, [return_maps]), - %% Client is a tuple/list [host, port] in ASGI scope + %% Client is a [host, port] list in ASGI scope, carrying the real + %% peer the adapter accepted the connection from. /scope stringifies + %% scalar members, so the port comes back as text. Client = maps:get(<<"client">>, Scope), - ?assert(is_list(Client)), - ?assertEqual(2, length(Client)). + ?assertMatch([_, _], Client), + [Host, ClientPort] = Client, + ?assertEqual(<<"127.0.0.1">>, Host), + ?assert(binary_to_integer(ClientPort) > 0). test_asgi_scope_scheme(Config) -> Url = make_url(Config, <<"/scope">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Scope = jsx:decode(Body, [return_maps]), Scheme = maps:get(<<"scheme">>, Scope), ?assertEqual(<<"http">>, Scheme). @@ -390,41 +420,41 @@ test_asgi_scope_scheme(Config) -> test_method_get(Config) -> Url = make_url(Config, <<"/methods/GET">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assert(binary:match(Body, <<"Method GET OK">>) =/= nomatch). test_method_post(Config) -> Url = make_url(Config, <<"/methods/POST">>), - {ok, 200, _Headers, Body} = hackney:request(post, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(post, Url, #{}), ?assert(binary:match(Body, <<"Method POST OK">>) =/= nomatch). test_method_put(Config) -> Url = make_url(Config, <<"/methods/PUT">>), - {ok, 200, _Headers, Body} = hackney:request(put, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(put, Url, #{}), ?assert(binary:match(Body, <<"Method PUT OK">>) =/= nomatch). test_method_delete(Config) -> Url = make_url(Config, <<"/methods/DELETE">>), - {ok, 200, _Headers, Body} = hackney:request(delete, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(delete, Url, #{}), ?assert(binary:match(Body, <<"Method DELETE OK">>) =/= nomatch). test_method_patch(Config) -> Url = make_url(Config, <<"/methods/PATCH">>), - {ok, 200, _Headers, Body} = hackney:request(patch, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(patch, Url, #{}), ?assert(binary:match(Body, <<"Method PATCH OK">>) =/= nomatch). test_method_head(Config) -> Url = make_url(Config, <<"/methods/HEAD">>), - %% HEAD request returns no body, hackney returns 3-tuple - {ok, 200, Headers} = hackney:request(head, Url, [], <<>>, []), + %% HEAD request returns no body, client returns 3-tuple + {ok, #{status := 200} = Resp} = hornbeam_test_http:request(head, Url, #{}), %% Verify headers are present - ?assert(proplists:get_value(<<"content-type">>, Headers) =/= undefined), - ?assert(proplists:get_value(<<"content-length">>, Headers) =/= undefined). + ?assert(livery_client:header(<<"content-type">>, Resp) =/= undefined), + ?assert(livery_client:header(<<"content-length">>, Resp) =/= undefined). test_method_options(Config) -> Url = make_url(Config, <<"/methods/OPTIONS">>), - {ok, 200, Headers, _Body} = hackney:request(options, Url, [], <<>>, []), - Allow = proplists:get_value(<<"allow">>, Headers), + {ok, #{status := 200} = Resp} = hornbeam_test_http:request(options, Url, #{}), + Allow = livery_client:header(<<"allow">>, Resp), ?assert(Allow =/= undefined), ?assert(binary:match(Allow, <<"GET">>) =/= nomatch). @@ -434,40 +464,40 @@ test_method_options(Config) -> test_status_200(Config) -> Url = make_url(Config, <<"/status?code=200">>), - {ok, 200, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 200}} = hornbeam_test_http:request(get, Url, #{}). test_status_201(Config) -> Url = make_url(Config, <<"/status?code=201">>), - {ok, 201, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 201}} = hornbeam_test_http:request(get, Url, #{}). test_status_204(Config) -> Url = make_url(Config, <<"/status?code=204">>), - {ok, 204, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 204, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assertEqual(<<>>, Body). test_status_301(Config) -> Url = make_url(Config, <<"/status?code=301&location=/redirected">>), - {ok, 301, Headers, _Body} = hackney:request(get, Url, [], <<>>, [{follow_redirect, false}]), - Location = proplists:get_value(<<"location">>, Headers), + {ok, #{status := 301} = Resp} = hornbeam_test_http:request(get, Url, #{}), + Location = livery_client:header(<<"location">>, Resp), ?assertEqual(<<"/redirected">>, Location). test_status_302(Config) -> Url = make_url(Config, <<"/status?code=302&location=/found">>), - {ok, 302, Headers, _Body} = hackney:request(get, Url, [], <<>>, [{follow_redirect, false}]), - Location = proplists:get_value(<<"location">>, Headers), + {ok, #{status := 302} = Resp} = hornbeam_test_http:request(get, Url, #{}), + Location = livery_client:header(<<"location">>, Resp), ?assertEqual(<<"/found">>, Location). test_status_400(Config) -> Url = make_url(Config, <<"/status?code=400">>), - {ok, 400, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 400}} = hornbeam_test_http:request(get, Url, #{}). test_status_404(Config) -> Url = make_url(Config, <<"/status?code=404">>), - {ok, 404, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 404}} = hornbeam_test_http:request(get, Url, #{}). test_status_500(Config) -> Url = make_url(Config, <<"/status?code=500">>), - {ok, 500, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 500}} = hornbeam_test_http:request(get, Url, #{}). %%% ============================================================================ %%% Header tests @@ -475,20 +505,20 @@ test_status_500(Config) -> test_custom_headers(Config) -> Url = make_url(Config, <<"/headers?custom=test-value">>), - {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []), - CustomHeader = proplists:get_value(<<"x-custom-header">>, Headers), + {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}), + CustomHeader = livery_client:header(<<"x-custom-header">>, Resp), ?assertEqual(<<"test-value">>, CustomHeader). test_multiple_headers(Config) -> Url = make_url(Config, <<"/headers?multi=yes">>), - {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []), - MultiHeaders = [V || {K, V} <- Headers, K =:= <<"x-multi">>], + {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}), + MultiHeaders = [V || {K, V} <- livery_client:headers(Resp), string:lowercase(K) =:= <<"x-multi">>], ?assert(length(MultiHeaders) >= 1). test_cache_headers(Config) -> Url = make_url(Config, <<"/headers?cache=3600">>), - {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []), - CacheControl = proplists:get_value(<<"cache-control">>, Headers), + {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}), + CacheControl = livery_client:header(<<"cache-control">>, Resp), ?assertEqual(<<"max-age=3600">>, CacheControl). %%% ============================================================================ @@ -499,9 +529,9 @@ test_json_request(Config) -> Url = make_url(Config, <<"/json">>), ReqBody = <<"{\"key\": \"value\", \"number\": 42}">>, Headers = [{<<"Content-Type">>, <<"application/json">>}], - {ok, 200, RespHeaders, RespBody} = hackney:request(post, Url, Headers, ReqBody, []), + {ok, #{status := 200, body := {full, RespBody}} = Resp} = hornbeam_test_http:request(post, Url, #{headers => Headers, body => ReqBody}), - ContentType = proplists:get_value(<<"content-type">>, RespHeaders), + ContentType = livery_client:header(<<"content-type">>, Resp), ?assertEqual(<<"application/json">>, ContentType), Response = jsx:decode(RespBody, [return_maps]), @@ -512,19 +542,19 @@ test_json_request(Config) -> test_large_body(Config) -> Url = make_url(Config, <<"/large?size=102400">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assertEqual(102400, byte_size(Body)). test_empty_body(Config) -> Url = make_url(Config, <<"/echo">>), - {ok, 200, _Headers, Body} = hackney:request(post, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(post, Url, #{}), ?assertEqual(<<>>, Body). test_unicode_body(Config) -> Url = make_url(Config, <<"/unicode">>), - {ok, 200, Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}} = Resp} = hornbeam_test_http:request(get, Url, #{}), - ContentType = proplists:get_value(<<"content-type">>, Headers), + ContentType = livery_client:header(<<"content-type">>, Resp), ?assert(binary:match(ContentType, <<"utf-8">>) =/= nomatch), ?assert(binary:match(Body, <<"café"/utf8>>) =/= nomatch), @@ -537,7 +567,7 @@ test_unicode_body(Config) -> test_streaming_response(Config) -> Url = make_url(Config, <<"/streaming?chunks=3&size=50">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assert(binary:match(Body, <<"Chunk 1">>) =/= nomatch), ?assert(binary:match(Body, <<"Chunk 2">>) =/= nomatch), @@ -545,7 +575,7 @@ test_streaming_response(Config) -> test_streaming_chunks(Config) -> Url = make_url(Config, <<"/streaming?chunks=5&size=100">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), %% Should have 5 chunks Matches = binary:matches(Body, <<"Chunk">>), @@ -557,5 +587,5 @@ test_streaming_chunks(Config) -> test_error_exception(Config) -> Url = make_url(Config, <<"/error?type=exception">>), - {ok, 500, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 500, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assert(binary:match(Body, <<"Internal Server Error">>) =/= nomatch). diff --git a/test/hornbeam_examples_smoke_SUITE.erl b/test/hornbeam_examples_smoke_SUITE.erl index bd1b26c..bc43227 100644 --- a/test/hornbeam_examples_smoke_SUITE.erl +++ b/test/hornbeam_examples_smoke_SUITE.erl @@ -99,7 +99,7 @@ python_dep_skip(_) -> end_per_testcase(_TestCase, _Config) -> catch hornbeam:stop(), - %% Wait for the cowboy listener to actually release its socket and for + %% Wait for the listener to actually release its socket and for %% any in-flight lifespan task to drain. Without this, the next test's %% start can race the prior listener's shutdown. timer:sleep(800), diff --git a/test/hornbeam_lifespan_SUITE.erl b/test/hornbeam_lifespan_SUITE.erl index 80aba85..f68f5c9 100644 --- a/test/hornbeam_lifespan_SUITE.erl +++ b/test/hornbeam_lifespan_SUITE.erl @@ -99,11 +99,11 @@ groups() -> init_per_suite(Config) -> {ok, _} = application:ensure_all_started(hornbeam), - {ok, _} = application:ensure_all_started(hackney), + {ok, _} = application:ensure_all_started(livery), Config. end_per_suite(_Config) -> - application:stop(hackney), + ok, application:stop(hornbeam), ok. @@ -166,7 +166,7 @@ make_url(Config, Path) -> test_startup_complete(Config) -> %% Test that lifespan startup completed Url = make_url(Config, <<"/state">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), State = jsx:decode(Body, [return_maps]), %% Check module_state which tracks lifespan events @@ -176,7 +176,7 @@ test_startup_complete(Config) -> test_startup_called(Config) -> %% Test that startup was called Url = make_url(Config, <<"/state">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), State = jsx:decode(Body, [return_maps]), ModuleState = maps:get(<<"module_state">>, State), @@ -185,7 +185,7 @@ test_startup_called(Config) -> test_startup_time_recorded(Config) -> %% Test that startup time was recorded Url = make_url(Config, <<"/state">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), State = jsx:decode(Body, [return_maps]), ModuleState = maps:get(<<"module_state">>, State), @@ -195,7 +195,7 @@ test_startup_time_recorded(Config) -> test_health_after_startup(Config) -> %% Test health endpoint returns OK after startup Url = make_url(Config, <<"/health">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assertEqual(<<"OK">>, Body). %%% ============================================================================ @@ -205,7 +205,7 @@ test_health_after_startup(Config) -> test_lifespan_info_endpoint(Config) -> %% Test lifespan info endpoint Url = make_url(Config, <<"/lifespan-info">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Info = jsx:decode(Body, [return_maps]), ?assertEqual(true, maps:get(<<"lifespan_supported">>, Info)), @@ -214,7 +214,7 @@ test_lifespan_info_endpoint(Config) -> test_uptime_tracking(Config) -> %% Test that uptime is tracked Url = make_url(Config, <<"/lifespan-info">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Info = jsx:decode(Body, [return_maps]), Uptime = maps:get(<<"uptime_seconds">>, Info), @@ -228,7 +228,7 @@ test_uptime_tracking(Config) -> test_state_endpoint(Config) -> %% Test state endpoint returns state info Url = make_url(Config, <<"/state">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), State = jsx:decode(Body, [return_maps]), ?assert(maps:is_key(<<"module_state">>, State)). @@ -238,12 +238,12 @@ test_request_count_increments(Config) -> Url = make_url(Config, <<"/counter">>), %% Make first request - {ok, 200, _Headers1, Body1} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body1}}} = hornbeam_test_http:request(get, Url, #{}), Counter1 = jsx:decode(Body1, [return_maps]), Count1 = maps:get(<<"counter">>, Counter1), %% Make second request - {ok, 200, _Headers2, Body2} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body2}}} = hornbeam_test_http:request(get, Url, #{}), Counter2 = jsx:decode(Body2, [return_maps]), Count2 = maps:get(<<"counter">>, Counter2), @@ -257,7 +257,7 @@ test_request_count_increments(Config) -> test_counter_endpoint(Config) -> %% Test counter endpoint Url = make_url(Config, <<"/counter">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Counter = jsx:decode(Body, [return_maps]), ?assert(maps:is_key(<<"counter">>, Counter)), @@ -268,7 +268,7 @@ test_counter_increments_multiple_times(Config) -> Url = make_url(Config, <<"/counter">>), Counts = lists:map(fun(_) -> - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Counter = jsx:decode(Body, [return_maps]), maps:get(<<"counter">>, Counter) end, lists:seq(1, 5)), @@ -286,10 +286,10 @@ test_counter_increments_multiple_times(Config) -> test_root_endpoint(Config) -> %% Test root endpoint Url = make_url(Config, <<"/">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assertEqual(<<"Lifespan Test App">>, Body). test_not_found(Config) -> %% Test 404 for unknown path Url = make_url(Config, <<"/unknown-path">>), - {ok, 404, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 404}} = hornbeam_test_http:request(get, Url, #{}). diff --git a/test/hornbeam_multiapp_SUITE.erl b/test/hornbeam_multiapp_SUITE.erl index ec52f19..6d153c2 100644 --- a/test/hornbeam_multiapp_SUITE.erl +++ b/test/hornbeam_multiapp_SUITE.erl @@ -63,11 +63,11 @@ groups() -> init_per_suite(Config) -> {ok, _} = application:ensure_all_started(hornbeam), - {ok, _} = application:ensure_all_started(hackney), + {ok, _} = application:ensure_all_started(livery), Config. end_per_suite(_Config) -> - application:stop(hackney), + ok, application:stop(hornbeam), ok. @@ -104,8 +104,7 @@ test_multi_app_start(_Config) -> }), %% Verify listener is running - Listeners = ranch:info(), - ?assert(lists:keymember(hornbeam_http, 1, Listeners)), + ?assert(hornbeam:is_running()), %% Verify mounts are registered Mounts = hornbeam_mounts:list(), @@ -116,7 +115,7 @@ test_routing_to_api_mount(_Config) -> ok = start_multi_app_server(), %% Request to /api should route to api_app - {ok, 200, _Headers, Body} = hackney:request(get, "http://127.0.0.1:18080/api", [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/api", #{}), %% Parse JSON response Response = jsx:decode(Body, [return_maps]), @@ -129,7 +128,7 @@ test_routing_to_admin_mount(_Config) -> ok = start_multi_app_server(), %% Request to /admin/users should route to admin_app - {ok, 200, _Headers, Body} = hackney:request(get, "http://127.0.0.1:18080/admin/users", [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/admin/users", #{}), %% Parse JSON response Response = jsx:decode(Body, [return_maps]), @@ -142,7 +141,7 @@ test_routing_to_root_mount(_Config) -> ok = start_multi_app_server(), %% Request to /other should route to frontend_app (root mount) - {ok, 200, _Headers, Body} = hackney:request(get, "http://127.0.0.1:18080/other/page", [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/other/page", #{}), %% Parse JSON response Response = jsx:decode(Body, [return_maps]), @@ -164,14 +163,14 @@ test_longest_prefix_matching(_Config) -> }), %% /api/v2/users should match /api/v2 (longer prefix), not /api - {ok, 200, _Headers1, Body1} = hackney:request(get, "http://127.0.0.1:18080/api/v2/users", [], <<>>, []), + {ok, #{status := 200, body := {full, Body1}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/api/v2/users", #{}), Response1 = jsx:decode(Body1, [return_maps]), ?assertEqual(<<"admin">>, maps:get(<<"app">>, Response1)), ?assertEqual(<<"/users">>, maps:get(<<"path_info">>, Response1)), ?assertEqual(<<"/api/v2">>, maps:get(<<"script_name">>, Response1)), %% /api/v1/users should match /api - {ok, 200, _Headers2, Body2} = hackney:request(get, "http://127.0.0.1:18080/api/v1/users", [], <<>>, []), + {ok, #{status := 200, body := {full, Body2}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/api/v1/users", #{}), Response2 = jsx:decode(Body2, [return_maps]), ?assertEqual(<<"api">>, maps:get(<<"app">>, Response2)), ?assertEqual(<<"/v1/users">>, maps:get(<<"path_info">>, Response2)), @@ -191,7 +190,7 @@ test_script_name_path_info(_Config) -> lists:foreach(fun({Path, ExpectedScript, ExpectedPath}) -> Url = "http://127.0.0.1:18080" ++ Path, - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), Response = jsx:decode(Body, [return_maps]), ?assertEqual(ExpectedScript, maps:get(<<"script_name">>, Response), {path, Path, expected_script, ExpectedScript}), @@ -211,7 +210,7 @@ test_no_match_returns_404(_Config) -> }), %% Request to unmatched path should return 404 - {ok, 404, _Headers, _Body} = hackney:request(get, "http://127.0.0.1:18080/other", [], <<>>, []). + {ok, #{status := 404}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/other", #{}). test_backward_compat_single_app(_Config) -> %% Test that single-app mode still works @@ -220,7 +219,7 @@ test_backward_compat_single_app(_Config) -> }), %% Should work as before - {ok, 200, _Headers, Body} = hackney:request(get, "http://127.0.0.1:18080/", [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/", #{}), ?assertEqual(<<"Hello from Hornbeam WSGI!\n">>, Body). test_validation_invalid_prefix(_Config) -> diff --git a/test/hornbeam_test_certs.erl b/test/hornbeam_test_certs.erl new file mode 100644 index 0000000..a986ea0 --- /dev/null +++ b/test/hornbeam_test_certs.erl @@ -0,0 +1,46 @@ +%% Copyright 2026 Benoit Chesneau +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. + +%%% @doc Self-signed certificate for the TLS suites. +%%% +%%% Generated into the suite's `priv_dir' at setup rather than vendored, +%%% so nothing long-lived sits in the repo and the cert cannot expire. +-module(hornbeam_test_certs). + +-export([generate/1]). + +%% @doc Write `cert.pem' and `key.pem' under `Dir', returning their paths. +%% +%% Returns `{error, no_openssl}' when openssl is not on PATH, so a suite +%% can skip rather than fail on a machine without it. +-spec generate(file:filename()) -> + {ok, file:filename(), file:filename()} | {error, term()}. +generate(Dir) -> + CertFile = filename:join(Dir, "cert.pem"), + KeyFile = filename:join(Dir, "key.pem"), + case os:find_executable("openssl") of + false -> + {error, no_openssl}; + OpenSsl -> + Cmd = lists:flatten(io_lib:format( + "~s req -x509 -newkey rsa:2048 -sha256 -days 1 -nodes " + "-keyout ~s -out ~s -subj /CN=localhost " + "-addext subjectAltName=DNS:localhost,IP:127.0.0.1 2>&1", + [OpenSsl, KeyFile, CertFile])), + Output = os:cmd(Cmd), + case {filelib:is_regular(CertFile), filelib:is_regular(KeyFile)} of + {true, true} -> {ok, CertFile, KeyFile}; + _ -> {error, {openssl_failed, Output}} + end + end. diff --git a/test/hornbeam_test_http.erl b/test/hornbeam_test_http.erl new file mode 100644 index 0000000..fa184ab --- /dev/null +++ b/test/hornbeam_test_http.erl @@ -0,0 +1,51 @@ +%% Copyright 2026 Benoit Chesneau +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. + +%%% @doc HTTP helper for the test suites. +%%% +%%% Exists only so the suites do not rebuild a client at every call site. +%%% The result is `livery_client''s own response, so suites match on +%%% `#{status := _, headers := _, body := {full, _}}' directly; response +%%% headers keep their wire case, and `livery_client:header/2,3' looks +%%% them up case-insensitively. +-module(hornbeam_test_http). + +-export([request/2, request/3, request/4]). + +-spec request(atom(), iodata()) -> livery_client:result(). +request(Method, Url) -> + request(Method, Url, #{}, #{}). + +-spec request(atom(), iodata(), map()) -> livery_client:result(). +request(Method, Url, ReqOpts) -> + request(Method, Url, ReqOpts, #{}). + +%% @doc `ClientOpts' reaches `livery_client:new/1' (adapter, layer stack, +%% TLS options); `ReqOpts' reaches the request (headers, body, timeout). +-spec request(atom(), iodata(), map(), map()) -> livery_client:result(). +request(Method, Url, ReqOpts, ClientOpts) -> + Client = livery_client:new(unpooled(ClientOpts)), + livery_client:request(Client, Method, iolist_to_binary(Url), ReqOpts). + +%% @private +%% Every request gets a fresh connection. The suites stop and start the +%% server between groups, and a pooled keep-alive connection held across a +%% restart fails the next request on it - intermittently, and as whatever +%% hackney makes of the dead socket rather than as anything the test is +%% about. Pooling is the client's business, not what these suites check. +unpooled(ClientOpts) -> + AdapterOpts = maps:get(adapter_opts, ClientOpts, #{}), + Hackney = maps:get(hackney, AdapterOpts, []), + ClientOpts#{adapter_opts => + AdapterOpts#{hackney => [{pool, false} | Hackney]}}. diff --git a/test/hornbeam_tls_SUITE.erl b/test/hornbeam_tls_SUITE.erl new file mode 100644 index 0000000..a96404f --- /dev/null +++ b/test/hornbeam_tls_SUITE.erl @@ -0,0 +1,274 @@ +%% Copyright 2026 Benoit Chesneau +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. + +%%% @doc TLS, HTTP/2, HTTP/3 and request-limit tests. +%%% +%%% The certificate is generated into `priv_dir' at setup, so these +%%% suites need openssl on PATH and skip without it. +-module(hornbeam_tls_SUITE). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +-export([ + all/0, + groups/0, + init_per_suite/1, + end_per_suite/1, + init_per_group/2, + end_per_group/2 +]). + +-export([ + test_h1_over_tls/1, + test_h2_over_tls/1, + test_h1_on_the_h2_port/1, + test_info_reports_h1_and_h2/1, + test_h3_listener_and_alt_svc/1, + test_h3_request/1, + test_h2_requires_ssl/1, + test_h3_requires_ssl/1, + test_invalid_http_version/1, + test_max_headers/1, + test_max_header_size/1, + test_max_request_line_size/1 +]). + +all() -> + [{group, tls_h1}, + {group, alpn}, + {group, h3}, + {group, validation}, + {group, limits}]. + +groups() -> + [{tls_h1, [sequence], [ + test_h1_over_tls + ]}, + {alpn, [sequence], [ + test_h2_over_tls, + test_h1_on_the_h2_port, + test_info_reports_h1_and_h2 + ]}, + {h3, [sequence], [ + test_h3_listener_and_alt_svc, + test_h3_request + ]}, + {validation, [sequence], [ + test_h2_requires_ssl, + test_h3_requires_ssl, + test_invalid_http_version + ]}, + {limits, [sequence], [ + test_max_headers, + test_max_header_size, + test_max_request_line_size + ]}]. + +init_per_suite(Config) -> + {ok, _} = application:ensure_all_started(hornbeam), + {ok, _} = application:ensure_all_started(livery), + case hornbeam_test_certs:generate(?config(priv_dir, Config)) of + {ok, CertFile, KeyFile} -> + [{certfile, list_to_binary(CertFile)}, + {keyfile, list_to_binary(KeyFile)} | Config]; + {error, Reason} -> + {skip, {no_certificate, Reason}} + end. + +end_per_suite(_Config) -> + application:stop(hornbeam), + ok. + +init_per_group(validation, Config) -> + Config; +init_per_group(Group, Config) -> + Port = 18500 + erlang:phash2(Group, 200), + ok = hornbeam:start("asgi_test_app:application", + maps:merge(base_opts(Config, Port), group_opts(Group))), + timer:sleep(800), + [{port, Port} | Config]. + +end_per_group(validation, _Config) -> + ok; +end_per_group(_Group, _Config) -> + hornbeam:stop(), + timer:sleep(300), + ok. + +%% HTTP/1.1 alone over TLS keeps the `http' listener and only changes its +%% transport; the other groups take the ALPN listener instead. +group_opts(tls_h1) -> + #{http_version => ['HTTP/1.1']}; +group_opts(alpn) -> + #{http_version => ['HTTP/1.1', 'HTTP/2']}; +group_opts(h3) -> + #{http_version => ['HTTP/1.1', 'HTTP/2', 'HTTP/3']}; +group_opts(limits) -> + #{http_version => ['HTTP/1.1'], + max_headers => 20, + max_header_size => 512, + max_request_line_size => 512}. + +base_opts(Config, Port) -> + #{ + bind => iolist_to_binary(io_lib:format("127.0.0.1:~p", [Port])), + worker_class => asgi, + ssl => true, + certfile => ?config(certfile, Config), + keyfile => ?config(keyfile, Config), + pythonpath => [list_to_binary(test_apps_dir())] + }. + +%%% ============================================================================ +%%% TLS and protocol negotiation +%%% ============================================================================ + +test_h1_over_tls(Config) -> + ?assertEqual({<<"1.1">>, <<"https">>}, scope_via(Config, [http1])). + +test_h2_over_tls(Config) -> + ?assertEqual({<<"2">>, <<"https">>}, scope_via(Config, [http2])). + +%% The whole point of the ALPN listener: a client that will not speak h2 is +%% served HTTP/1.1 from the same TLS port rather than refused. +test_h1_on_the_h2_port(Config) -> + ?assertEqual({<<"1.1">>, <<"https">>}, scope_via(Config, [http1])). + +test_info_reports_h1_and_h2(Config) -> + Port = ?config(port, Config), + #{running := true, listeners := Listeners} = hornbeam:info(), + ?assertEqual([Port], maps:get(h1, Listeners, undefined)), + ?assertEqual([Port], maps:get(h2, Listeners, undefined)). + +%%% ============================================================================ +%%% HTTP/3 +%%% ============================================================================ + +test_h3_listener_and_alt_svc(Config) -> + Port = ?config(port, Config), + #{listeners := Listeners} = hornbeam:info(), + ?assertEqual([Port], maps:get(h3, Listeners, undefined)), + + %% h1 and h2 responses point clients at the QUIC port + {ok, Resp} = request(Config, <<"/">>, [http2]), + AltSvc = livery_client:header(<<"alt-svc">>, Resp), + ?assertNotEqual(undefined, AltSvc), + ?assertNotEqual(nomatch, binary:match(AltSvc, <<"h3=">>)). + +test_h3_request(Config) -> + case scope_via(Config, [http3]) of + {<<"3">>, <<"https">>} -> + ok; + Other -> + ct:pal("HTTP/3 request did not negotiate h3: ~p", [Other]), + {skip, "no working HTTP/3 client transport"} + end. + +%%% ============================================================================ +%%% Config validation +%%% ============================================================================ + +test_h2_requires_ssl(_Config) -> + ?assertEqual({error, {http_version_requires_ssl, 'HTTP/2'}}, + hornbeam:start("asgi_test_app:application", + #{bind => <<"127.0.0.1:18999">>, + http_version => ['HTTP/1.1', 'HTTP/2']})). + +test_h3_requires_ssl(_Config) -> + ?assertEqual({error, {http_version_requires_ssl, 'HTTP/3'}}, + hornbeam:start("asgi_test_app:application", + #{bind => <<"127.0.0.1:18999">>, + http_version => ['HTTP/3']})). + +test_invalid_http_version(_Config) -> + ?assertEqual({error, {invalid_http_version, 'HTTP/4'}}, + hornbeam:start("asgi_test_app:application", + #{bind => <<"127.0.0.1:18999">>, + ssl => true, + http_version => ['HTTP/4']})), + ?assertEqual({error, {invalid_http_version, []}}, + hornbeam:start("asgi_test_app:application", + #{bind => <<"127.0.0.1:18999">>, + http_version => []})). + +%%% ============================================================================ +%%% Request limits +%%% ============================================================================ + +%% h1 answers a limit breach rather than closing: 431 for the header +%% block, 414 for the request line. +test_max_headers(Config) -> + Headers = [{iolist_to_binary(io_lib:format("x-n~p", [N])), <<"v">>} + || N <- lists:seq(1, 40)], + {ok, #{status := Status}} = request(Config, <<"/">>, [http1], Headers), + ?assertEqual(431, Status). + +test_max_header_size(Config) -> + Big = binary:copy(<<"a">>, 2048), + {ok, #{status := Status}} = request(Config, <<"/">>, [http1], [{<<"x-big">>, Big}]), + ?assertEqual(431, Status). + +test_max_request_line_size(Config) -> + Long = <<"/?q=", (binary:copy(<<"a">>, 2048))/binary>>, + {ok, #{status := Status}} = request(Config, Long, [http1]), + ?assertEqual(414, Status). + +%%% ============================================================================ +%%% Helpers +%%% ============================================================================ + +%% The ASGI app echoes its scope, so the protocol the server actually used +%% is observable rather than inferred from the client's request. +scope_via(Config, Protocols) -> + case request(Config, <<"/info">>, Protocols) of + {ok, #{status := 200, body := {full, Body}}} -> + Info = jsx:decode(Body, [return_maps]), + {maps:get(<<"http_version">>, Info), maps:get(<<"scheme">>, Info)}; + Other -> + Other + end. + +request(Config, Path, Protocols) -> + request(Config, Path, Protocols, []). + +request(Config, Path, Protocols, Headers) -> + Port = ?config(port, Config), + Url = iolist_to_binary([<<"https://127.0.0.1:">>, integer_to_binary(Port), Path]), + hornbeam_test_http:request(get, Url, #{headers => Headers}, client_opts(Protocols)). + +%% Self-signed certificate, so the client must not verify the chain. +%% It has to be hackney's own `insecure' flag nested inside `ssl_options': +%% a bare `{verify, verify_none}' there still fails, because hackney merges +%% its default `verify_fun' over the top, and `insecure' at the option-list +%% top level never reaches the TLS layer. `protocols' pins which HTTP +%% version hackney offers, so each test drives the wire it means to. +client_opts(Protocols) -> + #{adapter_opts => #{hackney => [ + {ssl_options, [{insecure, true}]}, + {protocols, Protocols} + ]}}. + +test_apps_dir() -> + EbinDir = filename:dirname(code:which(hornbeam)), + LibDir = filename:dirname(EbinDir), + SrcLink = filename:join(LibDir, "src"), + case file:read_link(SrcLink) of + {ok, RelPath} -> + ActualSrc = filename:join(LibDir, RelPath), + ActualSrcDir = filename:dirname(filename:absname(ActualSrc)), + filename:join(ActualSrcDir, "test/test_apps"); + {error, _} -> + filename:join(LibDir, "test/test_apps") + end. diff --git a/test/hornbeam_wsgi_SUITE.erl b/test/hornbeam_wsgi_SUITE.erl index 86c6a3a..38bb4f2 100644 --- a/test/hornbeam_wsgi_SUITE.erl +++ b/test/hornbeam_wsgi_SUITE.erl @@ -142,11 +142,11 @@ groups() -> init_per_suite(Config) -> {ok, _} = application:ensure_all_started(hornbeam), - {ok, _} = application:ensure_all_started(hackney), + {ok, _} = application:ensure_all_started(livery), Config. end_per_suite(_Config) -> - application:stop(hackney), + ok, application:stop(hornbeam), ok. @@ -221,20 +221,20 @@ make_url(Config, Path) -> test_hello_world(Config) -> Url = make_url(Config, <<"/">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assertEqual(<<"Hello from WSGI Test App!\n">>, Body). test_not_found(Config) -> Url = make_url(Config, <<"/nonexistent">>), - {ok, 404, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 404, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assertEqual(<<"Not Found\n">>, Body). test_request_info(Config) -> Url = make_url(Config, <<"/info?foo=bar&baz=qux">>), - {ok, 200, Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}} = Resp} = hornbeam_test_http:request(get, Url, #{}), %% Should be JSON - ContentType = proplists:get_value(<<"content-type">>, Headers), + ContentType = livery_client:header(<<"content-type">>, Resp), ?assertEqual(<<"application/json">>, ContentType), %% Parse JSON and check fields @@ -247,11 +247,11 @@ test_post_echo(Config) -> Url = make_url(Config, <<"/echo">>), ReqBody = <<"Hello, Echo!">>, Headers = [{<<"Content-Type">>, <<"text/plain">>}], - {ok, 200, RespHeaders, RespBody} = hackney:request(post, Url, Headers, ReqBody, []), + {ok, #{status := 200, body := {full, RespBody}} = Resp} = hornbeam_test_http:request(post, Url, #{headers => Headers, body => ReqBody}), ?assertEqual(ReqBody, RespBody), %% Check echo length header - EchoLength = proplists:get_value(<<"x-echo-length">>, RespHeaders), + EchoLength = livery_client:header(<<"x-echo-length">>, Resp), ?assertEqual(<<"12">>, EchoLength). %%% ============================================================================ @@ -260,41 +260,41 @@ test_post_echo(Config) -> test_method_get(Config) -> Url = make_url(Config, <<"/methods/GET">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assert(binary:match(Body, <<"Method GET OK">>) =/= nomatch). test_method_post(Config) -> Url = make_url(Config, <<"/methods/POST">>), - {ok, 200, _Headers, Body} = hackney:request(post, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(post, Url, #{}), ?assert(binary:match(Body, <<"Method POST OK">>) =/= nomatch). test_method_put(Config) -> Url = make_url(Config, <<"/methods/PUT">>), - {ok, 200, _Headers, Body} = hackney:request(put, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(put, Url, #{}), ?assert(binary:match(Body, <<"Method PUT OK">>) =/= nomatch). test_method_delete(Config) -> Url = make_url(Config, <<"/methods/DELETE">>), - {ok, 200, _Headers, Body} = hackney:request(delete, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(delete, Url, #{}), ?assert(binary:match(Body, <<"Method DELETE OK">>) =/= nomatch). test_method_patch(Config) -> Url = make_url(Config, <<"/methods/PATCH">>), - {ok, 200, _Headers, Body} = hackney:request(patch, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(patch, Url, #{}), ?assert(binary:match(Body, <<"Method PATCH OK">>) =/= nomatch). test_method_head(Config) -> Url = make_url(Config, <<"/methods/HEAD">>), - %% HEAD request returns no body, hackney returns 3-tuple - {ok, 200, Headers} = hackney:request(head, Url, [], <<>>, []), + %% HEAD request returns no body, client returns 3-tuple + {ok, #{status := 200} = Resp} = hornbeam_test_http:request(head, Url, #{}), %% Verify headers are present - ?assert(proplists:get_value(<<"content-type">>, Headers) =/= undefined), - ?assert(proplists:get_value(<<"content-length">>, Headers) =/= undefined). + ?assert(livery_client:header(<<"content-type">>, Resp) =/= undefined), + ?assert(livery_client:header(<<"content-length">>, Resp) =/= undefined). test_method_options(Config) -> Url = make_url(Config, <<"/methods/OPTIONS">>), - {ok, 200, Headers, _Body} = hackney:request(options, Url, [], <<>>, []), - Allow = proplists:get_value(<<"allow">>, Headers), + {ok, #{status := 200} = Resp} = hornbeam_test_http:request(options, Url, #{}), + Allow = livery_client:header(<<"allow">>, Resp), ?assert(Allow =/= undefined), ?assert(binary:match(Allow, <<"GET">>) =/= nomatch). @@ -304,41 +304,41 @@ test_method_options(Config) -> test_status_200(Config) -> Url = make_url(Config, <<"/status?code=200">>), - {ok, 200, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 200}} = hornbeam_test_http:request(get, Url, #{}). test_status_201(Config) -> Url = make_url(Config, <<"/status?code=201">>), - {ok, 201, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 201}} = hornbeam_test_http:request(get, Url, #{}). test_status_204(Config) -> Url = make_url(Config, <<"/status?code=204">>), - {ok, 204, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 204, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assertEqual(<<>>, Body). test_status_301(Config) -> Url = make_url(Config, <<"/status?code=301&location=/redirected">>), %% Don't follow redirects - {ok, 301, Headers, _Body} = hackney:request(get, Url, [], <<>>, [{follow_redirect, false}]), - Location = proplists:get_value(<<"location">>, Headers), + {ok, #{status := 301} = Resp} = hornbeam_test_http:request(get, Url, #{}), + Location = livery_client:header(<<"location">>, Resp), ?assertEqual(<<"/redirected">>, Location). test_status_302(Config) -> Url = make_url(Config, <<"/status?code=302&location=/found">>), - {ok, 302, Headers, _Body} = hackney:request(get, Url, [], <<>>, [{follow_redirect, false}]), - Location = proplists:get_value(<<"location">>, Headers), + {ok, #{status := 302} = Resp} = hornbeam_test_http:request(get, Url, #{}), + Location = livery_client:header(<<"location">>, Resp), ?assertEqual(<<"/found">>, Location). test_status_400(Config) -> Url = make_url(Config, <<"/status?code=400">>), - {ok, 400, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 400}} = hornbeam_test_http:request(get, Url, #{}). test_status_404(Config) -> Url = make_url(Config, <<"/status?code=404">>), - {ok, 404, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 404}} = hornbeam_test_http:request(get, Url, #{}). test_status_500(Config) -> Url = make_url(Config, <<"/status?code=500">>), - {ok, 500, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []). + {ok, #{status := 500}} = hornbeam_test_http:request(get, Url, #{}). %%% ============================================================================ %%% Header tests @@ -346,21 +346,21 @@ test_status_500(Config) -> test_custom_headers(Config) -> Url = make_url(Config, <<"/headers?custom=test-value">>), - {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []), - CustomHeader = proplists:get_value(<<"x-custom-header">>, Headers), + {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}), + CustomHeader = livery_client:header(<<"x-custom-header">>, Resp), ?assertEqual(<<"test-value">>, CustomHeader). test_multiple_headers(Config) -> Url = make_url(Config, <<"/headers?multi=yes">>), - {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}), %% Multiple headers with same name should be present - MultiHeaders = [V || {K, V} <- Headers, K =:= <<"x-multi">>], + MultiHeaders = [V || {K, V} <- livery_client:headers(Resp), string:lowercase(K) =:= <<"x-multi">>], ?assert(length(MultiHeaders) >= 1). test_cache_headers(Config) -> Url = make_url(Config, <<"/headers?cache=3600">>), - {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []), - CacheControl = proplists:get_value(<<"cache-control">>, Headers), + {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}), + CacheControl = livery_client:header(<<"cache-control">>, Resp), ?assertEqual(<<"max-age=3600">>, CacheControl). %%% ============================================================================ @@ -371,9 +371,9 @@ test_json_request(Config) -> Url = make_url(Config, <<"/json">>), ReqBody = <<"{\"key\": \"value\", \"number\": 42}">>, Headers = [{<<"Content-Type">>, <<"application/json">>}], - {ok, 200, RespHeaders, RespBody} = hackney:request(post, Url, Headers, ReqBody, []), + {ok, #{status := 200, body := {full, RespBody}} = Resp} = hornbeam_test_http:request(post, Url, #{headers => Headers, body => ReqBody}), - ContentType = proplists:get_value(<<"content-type">>, RespHeaders), + ContentType = livery_client:header(<<"content-type">>, Resp), ?assertEqual(<<"application/json">>, ContentType), Response = jsx:decode(RespBody, [return_maps]), @@ -384,19 +384,19 @@ test_json_request(Config) -> test_large_body(Config) -> Url = make_url(Config, <<"/large?size=102400">>), % 100KB - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assertEqual(102400, byte_size(Body)). test_empty_body(Config) -> Url = make_url(Config, <<"/echo">>), - {ok, 200, _Headers, Body} = hackney:request(post, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(post, Url, #{}), ?assertEqual(<<>>, Body). test_unicode_body(Config) -> Url = make_url(Config, <<"/unicode">>), - {ok, 200, Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}} = Resp} = hornbeam_test_http:request(get, Url, #{}), - ContentType = proplists:get_value(<<"content-type">>, Headers), + ContentType = livery_client:header(<<"content-type">>, Resp), ?assert(binary:match(ContentType, <<"utf-8">>) =/= nomatch), %% Check various unicode characters are present @@ -410,7 +410,7 @@ test_unicode_body(Config) -> test_streaming_response(Config) -> Url = make_url(Config, <<"/streaming?chunks=3&size=50">>), - {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), %% Should have 3 chunks ?assert(binary:match(Body, <<"Chunk 1">>) =/= nomatch), @@ -423,5 +423,5 @@ test_streaming_response(Config) -> test_error_exception(Config) -> Url = make_url(Config, <<"/error?type=exception">>), - {ok, 500, _Headers, Body} = hackney:request(get, Url, [], <<>>, []), + {ok, #{status := 500, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}), ?assert(binary:match(Body, <<"Internal Server Error">>) =/= nomatch). diff --git a/test/test_apps/asgi_test_app.py b/test/test_apps/asgi_test_app.py index 2c50558..6b37242 100644 --- a/test/test_apps/asgi_test_app.py +++ b/test/test_apps/asgi_test_app.py @@ -494,9 +494,11 @@ async def handle_unicode(scope, receive, send): async def handle_early_hints(scope, receive, send): """Test 103 Early Hints response.""" - # Send 103 Early Hints (informational response) + # Send 103 Early Hints ahead of the final response. Interim + # responses go through http.response.informational, not + # http.response.start, which is reserved for the final head. await send({ - 'type': 'http.response.start', + 'type': 'http.response.informational', 'status': 103, 'headers': [ [b'link', b'; rel=preload; as=style'],