Skip to content

Commit d642c30

Browse files
committed
refactor: consolidate hackney_http3 into hackney_h3
- Merge hackney_http3 functionality into hackney_h3 - Use hackney_url:parse_url instead of local implementation - Remove redundant hackney_http3 module - Update tests to use hackney_h3
1 parent 9738c16 commit d642c30

File tree

3 files changed

+202
-324
lines changed

3 files changed

+202
-324
lines changed

src/hackney_h3.erl

Lines changed: 195 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,38 @@
55
%%%
66
%%% Copyright (c) 2024-2026 Benoit Chesneau
77
%%%
8-
%%% @doc HTTP/3 thin wrapper for hackney_conn.
8+
%%% @doc HTTP/3 support for hackney.
99
%%%
10-
%%% This module provides HTTP/3-specific operations to be used by hackney_conn.
10+
%%% This module provides HTTP/3 functionality using the QUIC NIF.
1111
%%% It handles stream management, header encoding, and request/response
1212
%%% handling for HTTP/3 connections over QUIC.
1313
%%%
14+
%%% == Usage ==
15+
%%%
16+
%%% ```
17+
%%% %% Check if HTTP/3 is available
18+
%%% hackney_h3:is_available() -> boolean()
19+
%%%
20+
%%% %% Make a simple GET request
21+
%%% {ok, Status, Headers, Body} = hackney_h3:request(get, "https://cloudflare.com/")
22+
%%%
23+
%%% %% Make a request with options
24+
%%% {ok, Status, Headers, Body} = hackney_h3:request(get, "https://example.com/",
25+
%%% [{<<"user-agent">>, <<"hackney/2.0">>}], <<>>, #{timeout => 30000})
26+
%%% '''
27+
%%%
1428

1529
-module(hackney_h3).
1630

31+
-include("hackney_lib.hrl").
32+
1733
-export([
18-
%% Request operations
34+
%% High-level API
35+
is_available/0,
36+
request/2, request/3, request/4, request/5,
37+
connect/2, connect/3,
38+
await_response/2,
39+
%% Request operations (used by hackney_conn)
1940
send_request/6,
2041
send_request_headers/5,
2142
send_body_chunk/4,
@@ -35,14 +56,92 @@
3556
-type h3_conn() :: reference().
3657
-type stream_id() :: non_neg_integer().
3758
-type method() :: get | post | put | delete | head | options | patch | atom() | binary().
59+
-type url() :: binary() | string().
3860
-type headers() :: [{binary(), binary()}].
61+
-type body() :: binary() | iodata().
62+
-type response() :: {ok, integer(), headers(), binary()} | {error, term()}.
3963
-type stream_state() :: waiting_headers | {receiving_body, integer(), headers(), binary()} | done.
4064
-type streams_map() :: #{stream_id() => {term(), stream_state()}}.
4165

4266
-export_type([h3_conn/0, stream_id/0, stream_state/0, streams_map/0]).
4367

4468
%%====================================================================
45-
%% API
69+
%% High-level API
70+
%%====================================================================
71+
72+
%% @doc Check if HTTP/3/QUIC support is available.
73+
-spec is_available() -> boolean().
74+
is_available() ->
75+
hackney_quic:is_available().
76+
77+
%% @doc Make an HTTP/3 request with default options.
78+
-spec request(method(), url()) -> response().
79+
request(Method, Url) ->
80+
request(Method, Url, [], <<>>, #{}).
81+
82+
%% @doc Make an HTTP/3 request with headers.
83+
-spec request(method(), url(), headers()) -> response().
84+
request(Method, Url, Headers) ->
85+
request(Method, Url, Headers, <<>>, #{}).
86+
87+
%% @doc Make an HTTP/3 request with headers and body.
88+
-spec request(method(), url(), headers(), body()) -> response().
89+
request(Method, Url, Headers, Body) ->
90+
request(Method, Url, Headers, Body, #{}).
91+
92+
%% @doc Make an HTTP/3 request with all options.
93+
%%
94+
%% Options:
95+
%% - timeout: Request timeout in milliseconds (default: 30000)
96+
%% - recv_timeout: Response receive timeout (default: 30000)
97+
%%
98+
-spec request(method(), url(), headers(), body(), map()) -> response().
99+
request(Method, Url, Headers, Body, Opts) ->
100+
#hackney_url{host = Host, port = Port, path = Path} = hackney_url:parse_url(Url),
101+
HostBin = list_to_binary(Host),
102+
PathBin = case Path of
103+
<<>> -> <<"/">>;
104+
_ -> Path
105+
end,
106+
Timeout = maps:get(timeout, Opts, 30000),
107+
case connect(HostBin, Port, Opts) of
108+
{ok, Conn} ->
109+
try
110+
do_request(Conn, Method, HostBin, PathBin, Headers, Body, Timeout)
111+
after
112+
close(Conn)
113+
end;
114+
{error, _} = Error ->
115+
Error
116+
end.
117+
118+
%% @doc Connect to an HTTP/3 server.
119+
-spec connect(binary() | string(), inet:port_number()) -> {ok, reference()} | {error, term()}.
120+
connect(Host, Port) ->
121+
connect(Host, Port, #{}).
122+
123+
%% @doc Connect to an HTTP/3 server with options.
124+
%% lsquic handles its own UDP socket creation and DNS resolution.
125+
-spec connect(binary() | string(), inet:port_number(), map()) -> {ok, reference()} | {error, term()}.
126+
connect(Host, Port, Opts) when is_list(Host) ->
127+
connect(list_to_binary(Host), Port, Opts);
128+
connect(Host, Port, Opts) when is_binary(Host) ->
129+
Timeout = maps:get(timeout, Opts, 30000),
130+
case hackney_quic:connect(Host, Port, Opts, self()) of
131+
{ok, ConnRef} ->
132+
wait_connected(ConnRef, Timeout, erlang:monotonic_time(millisecond));
133+
{error, _} = Error ->
134+
Error
135+
end.
136+
137+
%% @doc Wait for an HTTP/3 response.
138+
-spec await_response(reference(), non_neg_integer()) ->
139+
{ok, integer(), headers(), binary()} | {error, term()}.
140+
await_response(ConnRef, StreamId) ->
141+
await_response_loop(ConnRef, StreamId, 30000, undefined, [], <<>>, erlang:monotonic_time(millisecond)).
142+
143+
%%====================================================================
144+
%% Request operations (used by hackney_conn)
46145
%%====================================================================
47146

48147
%% @doc Send a complete HTTP/3 request (headers + body).
@@ -107,6 +206,10 @@ finish_send_body(ConnRef, StreamId, Streams) ->
107206
Error
108207
end.
109208

209+
%%====================================================================
210+
%% Stream management
211+
%%====================================================================
212+
110213
%% @doc Open a new stream for a request.
111214
-spec new_stream(h3_conn()) -> {ok, stream_id()} | {error, term()}.
112215
new_stream(ConnRef) ->
@@ -116,7 +219,6 @@ new_stream(ConnRef) ->
116219
-spec close_stream(h3_conn(), stream_id()) -> ok.
117220
close_stream(_ConnRef, _StreamId) ->
118221
%% HTTP/3 streams are closed when fin is sent/received
119-
%% No explicit close needed
120222
ok.
121223

122224
%% @doc Get the state of a specific stream.
@@ -134,6 +236,10 @@ get_stream_state(StreamId, Streams) ->
134236
update_stream_state(StreamId, State, Streams) ->
135237
maps:put(StreamId, State, Streams).
136238

239+
%%====================================================================
240+
%% Response parsing
241+
%%====================================================================
242+
137243
%% @doc Parse response headers from a QUIC stream_headers event.
138244
%% Returns {ok, Status, ResponseHeaders} or {error, Reason}.
139245
-spec parse_response_headers(headers()) -> {ok, integer(), headers()} | {error, term()}.
@@ -146,6 +252,10 @@ parse_response_headers(Headers) ->
146252
Error
147253
end.
148254

255+
%%====================================================================
256+
%% Connection close
257+
%%====================================================================
258+
149259
%% @doc Close the HTTP/3 connection.
150260
-spec close(h3_conn()) -> ok.
151261
close(ConnRef) ->
@@ -160,6 +270,75 @@ close(ConnRef, Reason) ->
160270
%% Internal functions
161271
%%====================================================================
162272

273+
%% Drive QUIC event loop until connected
274+
wait_connected(ConnRef, Timeout, StartTime) ->
275+
Elapsed = erlang:monotonic_time(millisecond) - StartTime,
276+
Remaining = max(0, Timeout - Elapsed),
277+
receive
278+
{select, _Resource, _Ref, ready_input} ->
279+
_ = hackney_quic:process(ConnRef),
280+
wait_connected(ConnRef, Timeout, StartTime);
281+
{quic, ConnRef, {connected, _Info}} ->
282+
{ok, ConnRef};
283+
{quic, ConnRef, {closed, Reason}} ->
284+
{error, {connection_closed, Reason}};
285+
{quic, ConnRef, {transport_error, Code, Msg}} ->
286+
{error, {transport_error, Code, Msg}}
287+
after Remaining ->
288+
hackney_quic:close(ConnRef, timeout),
289+
{error, timeout}
290+
end.
291+
292+
do_request(ConnRef, Method, Host, Path, Headers, Body, Timeout) ->
293+
case hackney_quic:open_stream(ConnRef) of
294+
{ok, StreamId} ->
295+
AllHeaders = build_request_headers(Method, Host, Path, Headers),
296+
HasBody = Body =/= <<>> andalso Body =/= [],
297+
Fin = not HasBody,
298+
case hackney_quic:send_headers(ConnRef, StreamId, AllHeaders, Fin) of
299+
ok when HasBody ->
300+
case hackney_quic:send_data(ConnRef, StreamId, Body, true) of
301+
ok ->
302+
await_response_loop(ConnRef, StreamId, Timeout, undefined, [], <<>>, erlang:monotonic_time(millisecond));
303+
{error, _} = Error ->
304+
Error
305+
end;
306+
ok ->
307+
await_response_loop(ConnRef, StreamId, Timeout, undefined, [], <<>>, erlang:monotonic_time(millisecond));
308+
{error, _} = Error ->
309+
Error
310+
end;
311+
{error, _} = Error ->
312+
Error
313+
end.
314+
315+
await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, AccBody, StartTime) ->
316+
Elapsed = erlang:monotonic_time(millisecond) - StartTime,
317+
Remaining = max(0, Timeout - Elapsed),
318+
receive
319+
{select, _Resource, _Ref, ready_input} ->
320+
_ = hackney_quic:process(ConnRef),
321+
await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, AccBody, StartTime);
322+
{quic, ConnRef, {stream_headers, StreamId, RespHeaders, _Fin}} ->
323+
NewStatus = get_status(RespHeaders),
324+
FilteredHeaders = filter_pseudo_headers(RespHeaders),
325+
await_response_loop(ConnRef, StreamId, Timeout, NewStatus, FilteredHeaders, AccBody, StartTime);
326+
{quic, ConnRef, {stream_data, StreamId, Data, Fin}} ->
327+
NewBody = <<AccBody/binary, Data/binary>>,
328+
case Fin of
329+
true ->
330+
{ok, Status, Headers, NewBody};
331+
false ->
332+
await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, NewBody, StartTime)
333+
end;
334+
{quic, ConnRef, {stream_reset, StreamId, _ErrorCode}} ->
335+
{error, stream_reset};
336+
{quic, ConnRef, {closed, Reason}} ->
337+
{error, {connection_closed, Reason}}
338+
after Remaining ->
339+
{error, timeout}
340+
end.
341+
163342
%% @private Build HTTP/3 request headers including pseudo-headers.
164343
-spec build_request_headers(method(), binary(), binary(), headers()) -> headers().
165344
build_request_headers(Method, Host, Path, Headers) ->
@@ -197,7 +376,17 @@ ensure_binary(B) when is_binary(B) -> B;
197376
ensure_binary(L) when is_list(L) -> list_to_binary(L);
198377
ensure_binary(A) when is_atom(A) -> atom_to_binary(A).
199378

200-
%% @private Extract status from HTTP/3 response headers.
379+
%% @private Extract status from HTTP/3 response headers (returns integer or 0).
380+
-spec get_status(headers()) -> integer().
381+
get_status(Headers) ->
382+
case lists:keyfind(<<":status">>, 1, Headers) of
383+
{_, StatusBin} ->
384+
binary_to_integer(StatusBin);
385+
false ->
386+
0
387+
end.
388+
389+
%% @private Extract status from HTTP/3 response headers (returns ok/error tuple).
201390
-spec get_status_from_headers(headers()) -> {ok, integer()} | {error, no_status | {invalid_status, binary()}}.
202391
get_status_from_headers(Headers) ->
203392
case lists:keyfind(<<":status">>, 1, Headers) of

0 commit comments

Comments
 (0)