Current section

Files

Jump to
h2 src h2_client.erl
Raw

src/h2_client.erl

%% @doc HTTP/2 Standalone Client Escript
%%
%% A standalone HTTP/2 client that can be run from the command line.
%%
%% Usage:
%% ```
%% h2_client URL [options]
%%
%% Options:
%% -X, --method METHOD HTTP method (default: GET)
%% -H, --header K:V Add request header (repeatable)
%% -d, --data DATA Request body
%% --cert FILE Client certificate (PEM)
%% --key FILE Client private key (PEM)
%% --cacerts FILE CA certificates (PEM)
%% --insecure Skip certificate verification
%% -o, --output FILE Write response to file
%% --timeout SEC Connection timeout (default: 30)
%% -v, --verbose Show detailed output
%% -h, --help Show this help
%% '''
%%
-module(h2_client).
-export([main/1]).
-define(DEFAULT_TIMEOUT, 30).
%% ============================================================================
%% Escript Entry Point
%% ============================================================================
-spec main([string()]) -> no_return().
main(Args) ->
case parse_args(Args) of
{ok, Opts} ->
run_client(Opts);
{error, Reason} ->
io:format(standard_error, "Error: ~s~n~n", [Reason]),
usage(),
halt(1);
help ->
usage(),
halt(0)
end.
%% ============================================================================
%% Argument Parsing
%% ============================================================================
parse_args(Args) ->
parse_args(Args, #{
method => <<"GET">>,
headers => [],
timeout => ?DEFAULT_TIMEOUT,
verbose => false,
insecure => false
}).
parse_args([], #{url := _} = Opts) ->
%% Reverse headers to maintain order
{ok, Opts#{headers => lists:reverse(maps:get(headers, Opts))}};
parse_args([], _Opts) ->
{error, "Missing URL argument"};
parse_args(["-h"|_], _Opts) -> help;
parse_args(["--help"|_], _Opts) -> help;
parse_args(["-X", Method|Rest], Opts) ->
parse_args(Rest, Opts#{method => list_to_binary(string:uppercase(Method))});
parse_args(["--method", Method|Rest], Opts) ->
parse_args(Rest, Opts#{method => list_to_binary(string:uppercase(Method))});
parse_args(["-H", Header|Rest], Opts) ->
parse_args(["--header", Header|Rest], Opts);
parse_args(["--header", Header|Rest], #{headers := Headers} = Opts) ->
case string:split(Header, ":") of
[Name, Value] ->
H = {list_to_binary(string:trim(Name)), list_to_binary(string:trim(Value))},
parse_args(Rest, Opts#{headers => [H|Headers]});
_ ->
{error, io_lib:format("Invalid header format: ~s (expected Name:Value)", [Header])}
end;
parse_args(["-d", Data|Rest], Opts) ->
parse_args(Rest, Opts#{data => list_to_binary(Data)});
parse_args(["--data", Data|Rest], Opts) ->
parse_args(Rest, Opts#{data => list_to_binary(Data)});
parse_args(["--cert", File|Rest], Opts) ->
case filelib:is_regular(File) of
true -> parse_args(Rest, Opts#{cert => File});
false -> {error, io_lib:format("Certificate file not found: ~s", [File])}
end;
parse_args(["--key", File|Rest], Opts) ->
case filelib:is_regular(File) of
true -> parse_args(Rest, Opts#{key => File});
false -> {error, io_lib:format("Key file not found: ~s", [File])}
end;
parse_args(["--cacerts", File|Rest], Opts) ->
case filelib:is_regular(File) of
true -> parse_args(Rest, Opts#{cacerts => File});
false -> {error, io_lib:format("CA certificates file not found: ~s", [File])}
end;
parse_args(["--insecure"|Rest], Opts) ->
parse_args(Rest, Opts#{insecure => true});
parse_args(["-o", File|Rest], Opts) ->
parse_args(Rest, Opts#{output => File});
parse_args(["--output", File|Rest], Opts) ->
parse_args(Rest, Opts#{output => File});
parse_args(["--timeout", Timeout|Rest], Opts) ->
case catch list_to_integer(Timeout) of
N when is_integer(N), N > 0 ->
parse_args(Rest, Opts#{timeout => N});
_ ->
{error, io_lib:format("Invalid timeout: ~s", [Timeout])}
end;
parse_args(["-v"|Rest], Opts) ->
parse_args(Rest, Opts#{verbose => true});
parse_args(["--verbose"|Rest], Opts) ->
parse_args(Rest, Opts#{verbose => true});
parse_args([[$-|_] = Unknown|_], _Opts) ->
{error, io_lib:format("Unknown option: ~s", [Unknown])};
parse_args([URL|Rest], Opts) ->
{ok, ParsedURL} = parse_url(URL),
parse_args(Rest, maps:merge(Opts, ParsedURL)).
parse_url(URL) ->
%% Simple URL parser for https://host:port/path
case URL of
"https://" ++ Rest ->
parse_url_parts(Rest, https);
"http://" ++ Rest ->
parse_url_parts(Rest, http);
_ ->
%% Assume https
parse_url_parts(URL, https)
end.
parse_url_parts(Rest, Scheme) ->
%% Split host:port/path
{HostPort, Path} = case string:split(Rest, "/") of
[HP] -> {HP, "/"};
[HP, PathPart] -> {HP, "/" ++ PathPart}
end,
%% Split host:port
{Host, Port} = case string:split(HostPort, ":") of
[H] ->
DefaultPort = case Scheme of https -> 443; http -> 80 end,
{H, DefaultPort};
[H, PortStr] ->
case catch list_to_integer(PortStr) of
N when is_integer(N) -> {H, N};
_ -> {H, 443}
end
end,
{ok, #{
url => Rest,
scheme => Scheme,
host => Host,
port => Port,
path => Path
}}.
usage() ->
io:format(
"Usage: h2_client URL [OPTIONS]~n"
"~n"
"HTTP/2 reference client~n"
"~n"
"Arguments:~n"
" URL Target URL (https://host:port/path)~n"
"~n"
"Options:~n"
" -X, --method METHOD HTTP method (default: GET)~n"
" -H, --header K:V Add request header (can be repeated)~n"
" -d, --data DATA Request body~n"
" --cert FILE Client certificate (PEM)~n"
" --key FILE Client private key (PEM)~n"
" --cacerts FILE CA certificates (PEM)~n"
" --insecure Skip certificate verification~n"
" -o, --output FILE Write response body to file~n"
" --timeout SEC Connection timeout (default: ~p)~n"
" -v, --verbose Show detailed output~n"
" -h, --help Show this help~n"
"~n"
"Examples:~n"
" h2_client https://localhost:8443/~n"
" h2_client https://example.com/ -v~n"
" h2_client https://example.com/api -X POST -d '{\"key\":\"value\"}'~n"
" h2_client https://localhost:8443/ --insecure~n"
"~n",
[?DEFAULT_TIMEOUT]).
%% ============================================================================
%% Client Implementation
%% ============================================================================
run_client(Opts) ->
%% Start required applications
ok = application:ensure_started(crypto),
ok = application:ensure_started(asn1),
ok = application:ensure_started(public_key),
ok = application:ensure_started(ssl),
Host = maps:get(host, Opts),
Port = maps:get(port, Opts),
Path = maps:get(path, Opts),
Method = maps:get(method, Opts),
Headers = maps:get(headers, Opts),
Timeout = maps:get(timeout, Opts) * 1000,
Verbose = maps:get(verbose, Opts),
log(Verbose, "Connecting to ~s:~p~n", [Host, Port]),
%% Build SSL options
SSLOpts = build_ssl_opts(Opts),
%% Connect
ConnectOpts = #{
transport => ssl,
ssl_opts => SSLOpts,
timeout => Timeout
},
case h2:connect(Host, Port, ConnectOpts) of
{ok, Conn} ->
log(Verbose, "Connected, sending request~n", []),
%% Add host header if not present
Headers1 = case proplists:is_defined(<<"host">>, Headers) of
true -> Headers;
false -> [{<<"host">>, list_to_binary(Host)} | Headers]
end,
%% Send request
Result = case maps:find(data, Opts) of
{ok, Data} ->
h2:request(Conn, Method, list_to_binary(Path), Headers1, Data);
error ->
h2:request(Conn, Method, list_to_binary(Path), Headers1)
end,
case Result of
{ok, StreamId} ->
log(Verbose, "Request sent on stream ~p~n", [StreamId]),
receive_response(Conn, StreamId, Opts);
{error, Reason} ->
io:format(standard_error, "Request failed: ~p~n", [Reason]),
h2:close(Conn),
halt(1)
end;
{error, Reason} ->
io:format(standard_error, "Connection failed: ~p~n", [Reason]),
halt(1)
end.
build_ssl_opts(Opts) ->
BaseOpts = case maps:get(insecure, Opts) of
true ->
[{verify, verify_none}];
false ->
[{verify, verify_peer},
{depth, 10},
{cacerts, public_key:cacerts_get()}]
end,
%% Add client certificate if provided
CertOpts = case maps:find(cert, Opts) of
{ok, CertFile} -> [{certfile, CertFile}];
error -> []
end,
KeyOpts = case maps:find(key, Opts) of
{ok, KeyFile} -> [{keyfile, KeyFile}];
error -> []
end,
%% Add custom CA certificates if provided
CAOpts = case maps:find(cacerts, Opts) of
{ok, CAFile} -> [{cacertfile, CAFile}];
error -> []
end,
%% Server name indication
Host = maps:get(host, Opts),
SNIOpts = [{server_name_indication, Host}],
BaseOpts ++ CertOpts ++ KeyOpts ++ CAOpts ++ SNIOpts.
receive_response(Conn, StreamId, Opts) ->
Verbose = maps:get(verbose, Opts),
receive_response_loop(Conn, StreamId, Opts, Verbose, <<>>, undefined).
receive_response_loop(Conn, StreamId, Opts, Verbose, BodyAcc, Status) ->
Timeout = maps:get(timeout, Opts) * 1000,
receive
{h2, Conn, {response, StreamId, ResponseStatus, Headers}} ->
log(Verbose, "~n< HTTP/2 ~p~n", [ResponseStatus]),
print_headers(Verbose, Headers),
receive_response_loop(Conn, StreamId, Opts, Verbose, BodyAcc, ResponseStatus);
{h2, Conn, {data, StreamId, Data, true}} ->
%% Final data
FullBody = <<BodyAcc/binary, Data/binary>>,
handle_response_body(FullBody, Status, Opts, Verbose),
h2:close(Conn),
case Status of
S when S >= 200, S < 400 -> halt(0);
_ -> halt(1)
end;
{h2, Conn, {data, StreamId, Data, false}} ->
%% More data coming
NewAcc = <<BodyAcc/binary, Data/binary>>,
receive_response_loop(Conn, StreamId, Opts, Verbose, NewAcc, Status);
{h2, Conn, {trailers, StreamId, Trailers}} ->
log(Verbose, "~n< Trailers:~n", []),
print_headers(Verbose, Trailers),
receive_response_loop(Conn, StreamId, Opts, Verbose, BodyAcc, Status);
{h2, Conn, {stream_reset, StreamId, ErrorCode}} ->
io:format(standard_error, "~nStream reset: ~p~n", [ErrorCode]),
h2:close(Conn),
halt(1);
{h2, Conn, {goaway, _LastStreamId, ErrorCode}} ->
io:format(standard_error, "~nConnection closed: ~p~n", [ErrorCode]),
h2:close(Conn),
halt(1);
{h2, Conn, closed} ->
io:format(standard_error, "~nConnection closed unexpectedly~n", []),
halt(1)
after Timeout ->
io:format(standard_error, "~nTimeout waiting for response~n", []),
h2:close(Conn),
halt(1)
end.
handle_response_body(Body, Status, Opts, Verbose) ->
case maps:find(output, Opts) of
{ok, OutputFile} ->
case file:write_file(OutputFile, Body) of
ok ->
log(Verbose, "~nWrote ~p bytes to ~s~n", [byte_size(Body), OutputFile]);
{error, Reason} ->
io:format(standard_error, "Failed to write output: ~p~n", [Reason])
end;
error ->
%% Print to stdout
case Verbose of
true -> io:format("~n~s~n", [Body]);
false ->
case Status of
S when S >= 200, S < 400 ->
io:format("~s", [Body]);
_ ->
io:format(standard_error, "~s", [Body])
end
end
end.
print_headers(true, Headers) ->
lists:foreach(fun({Name, Value}) ->
io:format("< ~s: ~s~n", [Name, Value])
end, Headers);
print_headers(false, _) ->
ok.
log(true, Format, Args) ->
io:format(standard_error, "* " ++ Format, Args);
log(false, _Format, _Args) ->
ok.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
parse_url_test_() ->
[
?_assertMatch({ok, #{host := "example.com", port := 443, path := "/"}},
parse_url("https://example.com/")),
?_assertMatch({ok, #{host := "example.com", port := 8443, path := "/api"}},
parse_url("https://example.com:8443/api")),
?_assertMatch({ok, #{host := "localhost", port := 443, path := "/"}},
parse_url("localhost"))
].
parse_args_test_() ->
[
?_assertMatch({ok, #{method := <<"GET">>, host := "example.com"}},
parse_args(["https://example.com/"], #{method => <<"GET">>, headers => [], timeout => 30, verbose => false, insecure => false})),
?_assertMatch({ok, #{method := <<"POST">>}},
parse_args(["-X", "post", "https://example.com/"], #{method => <<"GET">>, headers => [], timeout => 30, verbose => false, insecure => false}))
].
-endif.