diff --git a/NEWS.md b/NEWS.md index c03a3b70..23551131 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,16 @@ # NEWS +4.5.0 - 2026-07-04 +------------------ + +### Added + +- HTTP QUERY method (RFC 10008) as a first-class method: `hackney:query/1..4` + helpers and `hackney:request(query, ...)`. QUERY is safe and idempotent and + carries a request body like POST. It works over HTTP/1.1, HTTP/2, and + HTTP/3 with every request body mode (binary, streamed, async, connection + API). + 4.4.5 - 2026-06-18 ------------------ diff --git a/README.md b/README.md index 80a1010d..0406d157 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,19 @@ hackney:delete(URL). hackney:head(URL). hackney:options(URL). hackney:patch(URL, Headers, Body). +hackney:query(URL, Headers, Body). +``` + +`QUERY` (RFC 10008) is a safe, idempotent method for read-only operations +that need a request body: + +```erlang +hackney:query( + URL, + [{<<"content-type">>, <<"application/json">>}], + Json, + Options +). ``` ### Connection Pooling diff --git a/guides/http_guide.md b/guides/http_guide.md index 06de2575..fe1414de 100644 --- a/guides/http_guide.md +++ b/guides/http_guide.md @@ -112,6 +112,31 @@ ok = hackney:finish_send_body(Ref), {ok, Status, RespHeaders, Ref} = hackney:start_response(Ref). ``` +## QUERY Requests + +QUERY (RFC 10008) is a safe, idempotent method for read-only operations that +need a request body, for example a structured query too large or too +sensitive for the URL. Use it like a POST: the body travels the same way +over HTTP/1.1, HTTP/2, and HTTP/3. + +```erlang +hackney:query( + URL, + [{<<"content-type">>, <<"application/json">>}], + Json, + Options +). +``` + +The generic form works too: + +```erlang +hackney:request(query, URL, Headers, Body, Options). +``` + +Every request body format above (binary, form, multipart, streaming) works +with QUERY. + ## Response Handling ### Read Full Body diff --git a/src/hackney.app.src b/src/hackney.app.src index 792bed29..452e5e93 100644 --- a/src/hackney.app.src +++ b/src/hackney.app.src @@ -4,7 +4,7 @@ {application, hackney, [ {description, "Simple HTTP client with HTTP/1.1, HTTP/2, and HTTP/3 support"}, - {vsn, "4.4.5"}, + {vsn, "4.5.0"}, {registered, [hackney_pool]}, {applications, [kernel, stdlib, diff --git a/src/hackney.erl b/src/hackney.erl index ad006517..e3897512 100644 --- a/src/hackney.erl +++ b/src/hackney.erl @@ -560,7 +560,9 @@ request(Method, URL, Headers, Body) -> %% @doc Make a request. %% %% Args: -%% - Method: HTTP method (get, post, put, delete, etc.) +%% - Method: HTTP method (get, post, put, delete, query, etc.). +%% `query' is the RFC 10008 QUERY method: safe and idempotent, with a +%% request body allowed like post. %% - URL: Full URL or parsed hackney_url record %% - Headers: List of headers %% - Body: Request body (binary, iolist, {form, KVs}, {file, Path}, etc.) diff --git a/src/hackney_h3.erl b/src/hackney_h3.erl index 081338cc..75febc3e 100644 --- a/src/hackney_h3.erl +++ b/src/hackney_h3.erl @@ -609,6 +609,7 @@ method_to_binary(delete) -> <<"DELETE">>; method_to_binary(head) -> <<"HEAD">>; method_to_binary(options) -> <<"OPTIONS">>; method_to_binary(patch) -> <<"PATCH">>; +method_to_binary(query) -> <<"QUERY">>; method_to_binary(Method) when is_atom(Method) -> string:uppercase(atom_to_binary(Method)); method_to_binary(Method) when is_binary(Method) -> diff --git a/src/hackney_methods.hrl b/src/hackney_methods.hrl index 6aad9ce2..c49d6595 100644 --- a/src/hackney_methods.hrl +++ b/src/hackney_methods.hrl @@ -35,4 +35,7 @@ ?METHOD_TPL(patch). ?METHOD_TPL(purge). +%% RFC-10008 +?METHOD_TPL(query). + -undef(METHOD_TPL). diff --git a/test/hackney_query_tests.erl b/test/hackney_query_tests.erl new file mode 100644 index 00000000..ddbc8354 --- /dev/null +++ b/test/hackney_query_tests.erl @@ -0,0 +1,335 @@ +%%% Tests for the RFC 10008 QUERY method: helper API, generic request/5, and +%%% QUERY with a request body over HTTP/1.1, HTTP/2, and HTTP/3. Each fixture +%%% runs a local server that echoes the received method, so every assertion +%%% pins the wire method to <<"QUERY">>. +-module(hackney_query_tests). + +-include_lib("eunit/include/eunit.hrl"). + +-define(H1_PORT, 8127). +-define(H3_SERVER, hackney_query_h3_test_server). + +%% Resolve the test cert dir from the module's beam location so paths work +%% regardless of where eunit is run from. +cert_dir() -> + BeamDir = filename:dirname(code:which(?MODULE)), + Root = filename:join([BeamDir, "..", "..", "..", "..", ".."]), + filename:join([filename:absname(Root), "test", "certs"]). + +%%==================================================================== +%% HTTP/1.1 - local cowboy server, test_http_resource echoes the wire +%% method in the JSON "method" field and the body in "data". +%%==================================================================== + +query_http1_test_() -> + {setup, + fun start_h1/0, + fun stop_h1/1, + fun({ok, _}) -> + {inorder, + [fun query_helper_arity1/0, + fun query_helper_arity2/0, + fun query_helper_arity3/0, + fun query_helper_arity4/0, + fun generic_request_query/0, + fun query_json_body/0, + fun query_streamed_request_body/0, + fun async_query_request/0, + fun connection_api_query/0]} + end}. + +start_h1() -> + error_logger:tty(false), + {ok, _} = application:ensure_all_started(cowboy), + {ok, _} = application:ensure_all_started(hackney), + Routes = [{"/[...]", test_http_resource, []}], + Dispatch = cowboy_router:compile([{'_', Routes}]), + cowboy:start_clear(query_test_server, [{port, ?H1_PORT}], + #{env => #{dispatch => Dispatch}}). + +stop_h1({ok, _Pid}) -> + cowboy:stop_listener(query_test_server), + error_logger:tty(true), + ok. + +url(Path) -> + <<"http://localhost:", (integer_to_binary(?H1_PORT))/binary, Path/binary>>. + +query_helper_arity1() -> + {ok, 200, _, Body} = hackney:query(url(<<"/query">>)), + Obj = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"QUERY">>, maps:get(<<"method">>, Obj)), + ?assertEqual(<<>>, maps:get(<<"data">>, Obj)). + +query_helper_arity2() -> + Headers = [{<<"Accept">>, <<"application/json">>}], + {ok, 200, _, Body} = hackney:query(url(<<"/query">>), Headers), + Obj = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"QUERY">>, maps:get(<<"method">>, Obj)). + +query_helper_arity3() -> + Headers = [{<<"Content-Type">>, <<"text/plain">>}], + {ok, 200, _, Body} = hackney:query(url(<<"/query">>), Headers, <<"a=1&b=2">>), + Obj = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"QUERY">>, maps:get(<<"method">>, Obj)), + ?assertEqual(<<"a=1&b=2">>, maps:get(<<"data">>, Obj)). + +query_helper_arity4() -> + Headers = [{<<"Content-Type">>, <<"text/plain">>}], + {ok, 200, _, Body} = + hackney:query(url(<<"/query">>), Headers, <<"a=1&b=2">>, [{pool, false}]), + Obj = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"QUERY">>, maps:get(<<"method">>, Obj)), + ?assertEqual(<<"a=1&b=2">>, maps:get(<<"data">>, Obj)). + +generic_request_query() -> + Headers = [{<<"Content-Type">>, <<"text/plain">>}], + {ok, 200, _, Body} = + hackney:request(query, url(<<"/query">>), Headers, <<"payload">>, []), + Obj = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"QUERY">>, maps:get(<<"method">>, Obj)), + ?assertEqual(<<"payload">>, maps:get(<<"data">>, Obj)). + +query_json_body() -> + Headers = [{<<"content-type">>, <<"application/json">>}], + ReqBody = jsx:encode(#{<<"filter">> => <<"name">>, <<"limit">> => 10}), + {ok, 200, _, RespBody} = + hackney:query(url(<<"/query">>), Headers, ReqBody, [{pool, false}]), + Obj = jsx:decode(RespBody, [return_maps]), + ?assertEqual(<<"QUERY">>, maps:get(<<"method">>, Obj)), + ReqHeaders = maps:get(<<"headers">>, Obj), + ?assertEqual(<<"application/json">>, maps:get(<<"content-type">>, ReqHeaders)), + Data = jsx:decode(maps:get(<<"data">>, Obj), [return_maps]), + ?assertEqual(<<"name">>, maps:get(<<"filter">>, Data)), + ?assertEqual(10, maps:get(<<"limit">>, Data)). + +query_streamed_request_body() -> + Headers = [{<<"content-type">>, <<"text/plain">>}], + {ok, ConnPid} = + hackney:request(query, url(<<"/query">>), Headers, stream, [{pool, false}]), + ok = hackney:send_body(ConnPid, <<"select ">>), + ok = hackney:send_body(ConnPid, <<"name">>), + ok = hackney:finish_send_body(ConnPid), + {ok, 200, _RespHeaders, ConnPid} = hackney:start_response(ConnPid), + {ok, RespBody} = hackney:body(ConnPid), + Obj = jsx:decode(RespBody, [return_maps]), + ?assertEqual(<<"QUERY">>, maps:get(<<"method">>, Obj)), + ?assertEqual(<<"select name">>, maps:get(<<"data">>, Obj)). + +async_query_request() -> + {ok, Ref} = hackney:query(url(<<"/query">>), [], <<"q=async">>, [async]), + Dict = receive_response(Ref, orddict:new()), + ?assertEqual(200, orddict:fetch(status, Dict)), + Body = iolist_to_binary(orddict:fetch(body, Dict)), + Obj = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"QUERY">>, maps:get(<<"method">>, Obj)), + ?assertEqual(<<"q=async">>, maps:get(<<"data">>, Obj)). + +connection_api_query() -> + {ok, ConnPid} = hackney:connect(hackney_tcp, "localhost", ?H1_PORT, []), + Headers = [{<<"Host">>, <<"localhost:", (integer_to_binary(?H1_PORT))/binary>>}, + {<<"Content-Type">>, <<"text/plain">>}], + {ok, 200, _RespHeaders, ConnPid} = + hackney:send_request(ConnPid, {query, <<"/query">>, Headers, <<"q=conn">>}), + {ok, Body} = hackney:body(ConnPid), + Obj = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"QUERY">>, maps:get(<<"method">>, Obj)), + ?assertEqual(<<"q=conn">>, maps:get(<<"data">>, Obj)), + hackney:close(ConnPid). + +receive_response(Ref, Dict0) -> + receive + {hackney_response, Ref, {status, Status, _Reason}} -> + receive_response(Ref, orddict:store(status, Status, Dict0)); + {hackney_response, Ref, {headers, Headers}} -> + receive_response(Ref, orddict:store(headers, Headers, Dict0)); + {hackney_response, Ref, done} -> Dict0; + {hackney_response, Ref, Bin} -> + receive_response(Ref, orddict:append(body, Bin, Dict0)) + after 10000 -> + {error, timeout} + end. + +%%==================================================================== +%% HTTP/2 - local h2-over-TLS server. The handler echoes the received +%% :method pseudo-header in the x-echo-method response header and the +%% request body verbatim. +%%==================================================================== + +query_http2_test_() -> + {setup, + fun setup_h2/0, + fun cleanup_h2/1, + fun(Ctx) -> + [{"h2 one-shot QUERY with body", + {timeout, 30, fun() -> t_h2_query_oneshot(Ctx) end}}, + {"h2 streamed QUERY body", + {timeout, 30, fun() -> t_h2_query_streamed(Ctx) end}}] + end}. + +setup_h2() -> + _ = application:ensure_all_started(hackney), + _ = application:ensure_all_started(h2), + Certs = cert_dir(), + {ok, Server} = h2:start_server(0, #{ + cert => filename:join(Certs, "server.pem"), + key => filename:join(Certs, "server.key"), + handler => fun h2_echo_handler/5, + settings => #{max_concurrent_streams => unlimited} + }), + Port = h2:server_port(Server), + #{server => Server, port => Port}. + +cleanup_h2(#{server := Server}) -> + try h2:stop_server(Server) catch _:_ -> ok end, + ok. + +h2_echo_handler(Conn, Sid, Method, _Path, _Headers) -> + ok = h2:set_stream_handler(Conn, Sid, self()), + Body = h2_recv_body(Conn, Sid, <<>>), + ok = h2:send_response(Conn, Sid, 200, + [{<<"content-type">>, <<"application/octet-stream">>}, + {<<"x-echo-method">>, Method}]), + ok = h2:send_data(Conn, Sid, Body, true). + +h2_recv_body(Conn, Sid, Acc) -> + receive + {h2, Conn, {data, Sid, Data, true}} -> + <>; + {h2, Conn, {data, Sid, Data, false}} -> + h2_recv_body(Conn, Sid, <>) + after 10000 -> + Acc + end. + +t_h2_query_oneshot(#{port := Port}) -> + {ok, 200, RespHeaders, RespBody} = + hackney:request(query, h2_url(Port, <<"/query">>), [], <<"select name">>, + h2_opts()), + ?assertEqual(<<"QUERY">>, proplists:get_value(<<"x-echo-method">>, RespHeaders)), + ?assertEqual(<<"select name">>, RespBody). + +t_h2_query_streamed(#{port := Port}) -> + {ok, ConnPid} = + hackney:request(query, h2_url(Port, <<"/query">>), [], stream, h2_opts()), + ok = hackney:send_body(ConnPid, <<"select ">>), + ok = hackney:send_body(ConnPid, <<"name">>), + ok = hackney:finish_send_body(ConnPid), + {ok, 200, RespHeaders, ConnPid} = hackney:start_response(ConnPid), + ?assertEqual(<<"QUERY">>, proplists:get_value(<<"x-echo-method">>, RespHeaders)), + {ok, Echoed} = hackney:body(ConnPid), + ?assertEqual(<<"select name">>, Echoed). + +h2_opts() -> + [{pool, false}, + {protocols, [http2]}, + {recv_timeout, 10000}, + {ssl_options, [{insecure, true}, {verify, verify_none}]}]. + +h2_url(Port, Path) -> + iolist_to_binary([<<"https://localhost:">>, integer_to_list(Port), Path]). + +%%==================================================================== +%% HTTP/3 - local in-process quic_h3 server. The handler answers 200 +%% only when the :method pseudo-header is exactly <<"QUERY">> (404 +%% otherwise), so a 200 proves the wire method over real QUIC. +%%==================================================================== + +query_http3_test_() -> + {setup, + fun setup_h3/0, + fun cleanup_h3/1, + fun(Ctx) -> + [{"h3 QUERY with body via hackney_h3", + {timeout, 30, fun() -> t_h3_module_query(Ctx) end}}, + {"h3 QUERY with body via hackney:request", + {timeout, 30, fun() -> t_h3_full_stack_query(Ctx) end}}] + end}. + +setup_h3() -> + _ = application:ensure_all_started(hackney), + Certs = cert_dir(), + {ok, CertPem} = file:read_file(filename:join(Certs, "server.pem")), + {ok, KeyPem} = file:read_file(filename:join(Certs, "server.key")), + [{'Certificate', CertDer, _} | _] = public_key:pem_decode(CertPem), + Opts = #{cert => CertDer, key => decode_key(KeyPem), + handler => fun h3_query_handler/5}, + {ok, _Pid} = quic_h3:start_server(?H3_SERVER, 0, Opts), + {ok, Port} = quic:get_server_port(?H3_SERVER), + #{port => Port}. + +cleanup_h3(_) -> + try quic_h3:stop_server(?H3_SERVER) catch _:_ -> ok end, + ok. + +h3_query_handler(Conn, StreamId, <<"QUERY">>, _Path, _Headers) -> + Body = h3_read_body(Conn, StreamId), + quic_h3:send_response(Conn, StreamId, 200, [ + {<<"content-type">>, <<"application/octet-stream">>}, + {<<"content-length">>, integer_to_binary(byte_size(Body))} + ]), + quic_h3:send_data(Conn, StreamId, Body, true); +h3_query_handler(Conn, StreamId, _Method, _Path, _Headers) -> + quic_h3:send_response(Conn, StreamId, 404, [{<<"content-length">>, <<"0">>}]), + quic_h3:send_data(Conn, StreamId, <<>>, true). + +%% Body DATA received before the handler registered is returned buffered; +%% the rest arrives as messages until Fin. +h3_read_body(Conn, StreamId) -> + case quic_h3:set_stream_handler(Conn, StreamId, self()) of + ok -> + h3_recv_body(Conn, StreamId, <<>>); + {ok, Buffered} -> + case h3_drain_buffered(Buffered, <<>>) of + {fin, Acc} -> Acc; + {more, Acc} -> h3_recv_body(Conn, StreamId, Acc) + end + end. + +h3_drain_buffered([], Acc) -> + {more, Acc}; +h3_drain_buffered([{Data, true} | _], Acc) -> + {fin, <>}; +h3_drain_buffered([{Data, false} | Rest], Acc) -> + h3_drain_buffered(Rest, <>). + +h3_recv_body(Conn, StreamId, Acc) -> + receive + {quic_h3, Conn, {data, StreamId, Data, true}} -> + <>; + {quic_h3, Conn, {data, StreamId, Data, false}} -> + h3_recv_body(Conn, StreamId, <>) + after 10000 -> + Acc + end. + +t_h3_module_query(#{port := Port}) -> + {ok, Status, _Hdrs, Body} = + hackney_h3:request(query, h3_url(Port), [], <<"select name">>, + #{insecure_skip_verify => true}), + ?assertEqual(200, Status), + ?assertEqual(<<"select name">>, Body). + +t_h3_full_stack_query(#{port := Port}) -> + Opts = [{pool, false}, + {protocols, [http3]}, + {recv_timeout, 10000}, + {ssl_options, [{insecure, true}]}], + {ok, 200, _RespHeaders, Body} = + hackney:request(query, h3_url(Port), [], <<"select name">>, Opts), + ?assertEqual(<<"select name">>, Body). + +h3_url(Port) -> + iolist_to_binary(["https://127.0.0.1:", integer_to_list(Port), "/query"]). + +decode_key(KeyPem) -> + case public_key:pem_decode(KeyPem) of + [{'RSAPrivateKey', Der, not_encrypted}] -> + public_key:der_decode('RSAPrivateKey', Der); + [{'ECPrivateKey', Der, not_encrypted}] -> + public_key:der_decode('ECPrivateKey', Der); + [{'PrivateKeyInfo', Der, not_encrypted}] -> + public_key:der_decode('PrivateKeyInfo', Der); + [{_Type, Der, not_encrypted}] -> + Der + end. diff --git a/test/test_http_resource.erl b/test/test_http_resource.erl index 8ff0e840..8daa4c79 100644 --- a/test/test_http_resource.erl +++ b/test/test_http_resource.erl @@ -4,6 +4,7 @@ %% Supported routes: %% GET /get - return request info as JSON %% POST /post - echo request info with body as JSON +%% QUERY /query - echo request info with body as JSON %% HEAD /* - return headers only %% GET /status/:code - return specific status code %% GET /redirect-to - redirect to ?url= (optional ?status_code=) @@ -33,6 +34,13 @@ handle_request(<<"POST">>, <<"/post">>, Req0, State) -> Info2 = Info#{<<"data">> => Body}, reply_json(200, Info2, Req, State); +%% QUERY /query - echo request info with body (RFC 10008: safe method with body) +handle_request(<<"QUERY">>, <<"/query">>, Req0, State) -> + {ok, Body, Req} = cowboy_req:read_body(Req0), + Info = request_info(Req), + Info2 = Info#{<<"data">> => Body}, + reply_json(200, Info2, Req, State); + %% HEAD requests - return headers only (no body) handle_request(<<"HEAD">>, _Path, Req, State) -> Req2 = cowboy_req:reply(200, #{