From 817df395ac1ea34e7b17a529e1184f38760d6d99 Mon Sep 17 00:00:00 2001 From: "Jose E. Cribeiro Aneiros" Date: Tue, 23 Dec 2025 09:05:03 +0100 Subject: [PATCH] feat: Add cowboy HTTP server backend --- README.md | 61 ++- rebar.config | 18 +- rebar.lock | 6 +- src/erf.app.src | 4 +- .../erf_http_server_cowboy.erl | 407 ++++++++++++++++++ src/erf_http_server/erf_http_server_elli.erl | 2 +- test/erf_http_server_SUITE.erl | 354 +++++++++++++++ test/erf_test_callback.erl | 28 ++ test/erf_test_middleware.erl | 32 ++ test/erf_test_stop_middleware.erl | 27 ++ 10 files changed, 914 insertions(+), 25 deletions(-) create mode 100644 src/erf_http_server/erf_http_server_cowboy.erl create mode 100644 test/erf_http_server_SUITE.erl create mode 100644 test/erf_test_callback.erl create mode 100644 test/erf_test_middleware.erl create mode 100644 test/erf_test_stop_middleware.erl diff --git a/README.md b/README.md index ff12b5e..0867b9b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ [![erf ci](https://github.com/nomasystems/erf/actions/workflows/ci.yml/badge.svg)](https://github.com/nomasystems/erf/actions/workflows/ci.yml) [![erf docs](https://github.com/nomasystems/erf/actions/workflows/docs.yml/badge.svg)](https://nomasystems.github.io/erf) -`erf` is a design-first Erlang REST framework. It provides an interface to spawn specification-driven HTTP servers with several automated features that aim to ease the development, operation and maintenance of design-first RESTful services. Its HTTP protocol features are provided as a wrapper of the [elli](https://github.com/elli-lib/elli) HTTP 1.1 server. +`erf` is a design-first Erlang REST framework. It provides an interface to spawn specification-driven HTTP servers with several automated features that aim to ease the development, operation and maintenance of design-first RESTful services. + +`erf` supports multiple HTTP server backends: +- [elli](https://github.com/elli-lib/elli) - HTTP/1.1 server +- [cowboy](https://github.com/ninenines/cowboy) - HTTP/1.1 and HTTP/2 server ## What is design-first? @@ -18,10 +22,18 @@ Design-first is an approach to API development that prioritises the design of th 1. Design your API using OpenAPI 3.0. For example: [users.openapi.json](examples/users/priv/users.openapi.json). -2. Add `erf` as a dependency in your `rebar3` project. +2. Add `erf` and your chosen HTTP server backend as dependencies in your `rebar3` project. ```erl +%% With elli (HTTP/1.1) +{deps, [ + {erf, {git, "git@github.com:nomasystems/erf.git", {branch, "main"}}}, + {elli, {git, "https://github.com/elli-lib/elli.git", {branch, "main"}}} +]}. + +%% Or with cowboy (HTTP/1.1 and HTTP/2) {deps, [ - {erf, {git, "git@github.com:nomasystems/erf.git", {branch, "main"}}} + {erf, {git, "git@github.com:nomasystems/erf.git", {branch, "main"}}}, + {cowboy, {git, "git@github.com:ninenines/cowboy.git", {tag, "2.13.0"}}} ]}. ``` @@ -102,6 +114,9 @@ init([]) -> preprocess_middlewares => [users_preprocess], postprocess_middlewares => [users_postprocess], port => 8080 + %% Optionally specify the HTTP server backend: + %% http_server => {erf_http_server_elli, #{}} % default + %% http_server => {erf_http_server_cowboy, #{}} % for HTTP/2 support }, UsersChildSpec = { public_api_server, @@ -156,12 +171,7 @@ The configuration is provided as map with the following type spec: keyfile => binary(), static_routes => [static_route()], swagger_ui => boolean(), - min_acceptors => pos_integer(), - accept_timeout => pos_integer(), - request_timeout => pos_integer(), - header_timeout => pos_integer(), - body_timeout => pos_integer(), - max_body_size => pos_integer(), + http_server => {module(), map()}, log_level => logger:level() }. ``` @@ -179,14 +189,35 @@ A detailed description of each parameter can be found in the following list: - `keyfile`: Path to the SSL key file. Defaults to `undefined`. - `static_routes`: List of routes that serve static files. Defaults to `[]`. - `swagger_ui`: Boolean flag that enables/disables the Swagger UI. Defaults to `false`. -- `min_acceptors`: Minimum number of acceptor processes. Defaults to `20`. -- `accept_timeout`: Timeout in ms for accepting an incoming request. Defaults to `10000`. -- `request_timeout`: Timeout in ms for receiving more packets when waiting for the request line. Defaults to `60000`. -- `header_timeout`: Timeout in ms for receiving more packets when waiting for the headers. Defaults to `10000`. -- `body_timeout`: Timeout in ms for receiving more packets when waiting for the body. Defaults to `30000`. -- `max_body_size`: Maximum size in bytes for the body of allowed received messages. Defaults to `1024000`. +- `http_server`: HTTP server backend and its configuration. Defaults to `{erf_http_server_elli, #{}}`. - `log_level`: Severity associated to logged messages. Defaults to `error`. +### HTTP Server Backends + +#### elli (default) + +```erl +http_server => {erf_http_server_elli, #{ + min_acceptors => 20, % Minimum number of acceptor processes + accept_timeout => 10000, % Timeout in ms for accepting requests + request_timeout => 60000, % Timeout in ms for the request line + header_timeout => 10000, % Timeout in ms for headers + body_timeout => 30000, % Timeout in ms for body + max_body_size => 1024000 % Maximum body size in bytes +}} +``` + +#### cowboy + +```erl +http_server => {erf_http_server_cowboy, #{ + num_acceptors => 100, % Number of acceptor processes + max_connections => infinity % Maximum number of connections +}} +``` + +Cowboy supports both HTTP/1.1 and HTTP/2 protocols. + ## Callback modules & middlewares `erf` dynamically generates a router that type check the received requests against the API specification. If the request passes the validation, it is deconstructed and passed to the middleware and callback modules. But, how do those middleware and callback modules must look like? diff --git a/rebar.config b/rebar.config index 1fb4c0e..3b82f6e 100644 --- a/rebar.config +++ b/rebar.config @@ -4,7 +4,6 @@ ]}. {deps, [ - {elli, {git, "https://github.com/elli-lib/elli.git", {branch, "main"}}}, {ndto, {git, "https://github.com/nomasystems/ndto.git", {branch, "main"}}}, {njson, {git, "https://github.com/nomasystems/njson.git", {branch, "main"}}} ]}. @@ -33,6 +32,16 @@ {erlfmt, [write]}. {profiles, [ + {elli, [ + {deps, [ + {elli, {git, "https://github.com/elli-lib/elli.git", {branch, "main"}}} + ]} + ]}, + {cowboy, [ + {deps, [ + {cowboy, {git, "git@github.com:ninenines/cowboy.git", {tag, "2.13.0"}}} + ]} + ]}, {examples, [ {project_app_dirs, ["examples/users", "."]}, {shell, [ @@ -42,6 +51,9 @@ {test, [ {erl_opts, [nowarn_export_all]}, {deps, [ + {cowboy, {git, "https://github.com/ninenines/cowboy.git", {tag, "2.13.0"}}}, + {elli, {git, "https://github.com/elli-lib/elli.git", {branch, "main"}}}, + {gun, {git, "https://github.com/ninenines/gun.git", {tag, "2.1.0"}}}, {meck, {git, "https://github.com/eproxus/meck.git", {branch, "master"}}}, {nct_util, {git, "https://github.com/nomasystems/nct_util.git", {branch, "main"}}} ]} @@ -89,8 +101,8 @@ {xref_ignores, [ erf, {erf_http_server, start_link, 4}, - {erf_http_server_elli, handle, 2}, - {erf_http_server_elli, handle_event, 3}, + erf_http_server_cowboy, + erf_http_server_elli, {erf_router, handle, 2}, {erf_static, mime_type, 1}, erf_telemetry diff --git a/rebar.lock b/rebar.lock index 689c23a..dd5a222 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,8 +1,4 @@ -[{<<"elli">>, - {git,"https://github.com/elli-lib/elli.git", - {ref,"c874c42b5d5c1fb7477bc1655134d1de9cbe0223"}}, - 0}, - {<<"ncalendar">>, +[{<<"ncalendar">>, {git,"https://github.com/nomasystems/ncalendar.git", {ref,"3a36a9cfe85da197f5032ce9e4c0a4a4dea9e38e"}}, 1}, diff --git a/src/erf.app.src b/src/erf.app.src index 373f03d..ee73651 100644 --- a/src/erf.app.src +++ b/src/erf.app.src @@ -2,8 +2,10 @@ {description, "A design-first Erlang REST Framework"}, {vsn, "0.1.2"}, {registered, []}, - {applications, [kernel, stdlib, compiler, syntax_tools, elli, ndto, njson]}, + {applications, [kernel, stdlib, compiler, syntax_tools, ndto, njson]}, {optional_applications, [ + elli, + cowboy, telemetry ]}, {env, []} diff --git a/src/erf_http_server/erf_http_server_cowboy.erl b/src/erf_http_server/erf_http_server_cowboy.erl new file mode 100644 index 0000000..c95dddc --- /dev/null +++ b/src/erf_http_server/erf_http_server_cowboy.erl @@ -0,0 +1,407 @@ +%%% Copyright 2024 Nomasystems, S.L. http://www.nomasystems.com +%% +%% 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 A cowboy implementation for erf_http_server. +-module(erf_http_server_cowboy). + +%%% BEHAVIOURS +-behaviour(erf_http_server). +%% -behaviour(cowboy_handler). +-behaviour(gen_server). + +%%% INCLUDE FILES +-include_lib("kernel/include/logger.hrl"). + +%%% ERF HTTP SERVER EXPORTS +-export([ + start_link/3 +]). + +%%% COWBOY HANDLER EXPORTS +-export([ + init/2 +]). + +%%% GEN_SERVER EXPORTS +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2 +]). + +%%% TYPES +-type extra_conf() :: #{ + num_acceptors => pos_integer(), + max_connections => pos_integer() | infinity +}. + +-record(state, { + listener_name :: atom() +}). + +%%% TYPE EXPORTS +-export_type([ + extra_conf/0 +]). + +%%% MACROS +-define(HTTP_SERVER_NAME(Name), + (erlang:binary_to_atom(<<"erf_http_server_", (erlang:atom_to_binary(Name))/binary>>)) +). + +%%%----------------------------------------------------------------------------- +%%% ERF HTTP SERVER EXPORTS +%%%----------------------------------------------------------------------------- +-spec start_link(Name, Conf, ExtraConf) -> Result when + Name :: atom(), + Conf :: erf_http_server:conf(), + ExtraConf :: extra_conf(), + Result :: supervisor:startlink_ret(). +start_link(Name, Conf, ExtraConf) -> + gen_server:start_link(?MODULE, {Name, Conf, ExtraConf}, []). + +%%%----------------------------------------------------------------------------- +%%% GEN_SERVER EXPORTS +%%%----------------------------------------------------------------------------- +init({Name, Conf, ExtraConf}) -> + process_flag(trap_exit, true), + ListenerName = ?HTTP_SERVER_NAME(Name), + Port = maps:get(port, Conf, 8080), + SSL = maps:get(ssl, Conf, false), + NumAcceptors = maps:get(num_acceptors, ExtraConf, 100), + MaxConnections = maps:get(max_connections, ExtraConf, infinity), + TransportOpts = transport_opts(Port, Conf, SSL, NumAcceptors, MaxConnections), + Dispatch = cowboy_router:compile([ + {'_', [{'_', ?MODULE, [Name]}]} + ]), + ProtocolOpts = #{ + env => #{dispatch => Dispatch} + }, + Result = + case SSL of + true -> + cowboy:start_tls(ListenerName, TransportOpts, ProtocolOpts); + false -> + cowboy:start_clear(ListenerName, TransportOpts, ProtocolOpts) + end, + case Result of + {ok, _ListenerPid} -> + {ok, #state{listener_name = ListenerName}}; + {error, Reason} -> + {stop, Reason} + end. + +handle_call(_Request, _From, State) -> + {reply, ok, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, #state{listener_name = ListenerName}) -> + cowboy:stop_listener(ListenerName), + ok. + +%%%----------------------------------------------------------------------------- +%%% COWBOY HANDLER EXPORTS +%%%----------------------------------------------------------------------------- +-spec init(Req, State) -> Result when + Req :: cowboy_req:req(), + State :: [Name :: atom()], + Result :: {ok, cowboy_req:req(), State}. +init(CowboyReq0, [Name] = State) -> + StartTime = erlang:monotonic_time(), + {CowboyReq1, ErfRequest} = preprocess(Name, CowboyReq0), + erf_telemetry:event( + {request_start, #{monotonic_time => StartTime}}, + Name, + ErfRequest, + undefined + ), + try + ErfResponse = erf_router:handle(Name, ErfRequest), + {CowboyReq2, StatusCode, Headers, Body} = postprocess(ErfRequest, ErfResponse, CowboyReq1), + EndTime = erlang:monotonic_time(), + ReqBodyLength = cowboy_req:body_length(CowboyReq0), + RespBodyLength = resp_body_length(Body), + Metrics = #{ + duration => EndTime - StartTime, + monotonic_time => EndTime, + req_body_duration => 0, + req_body_length => case ReqBodyLength of undefined -> 0; L -> L end, + resp_body_length => RespBodyLength, + resp_duration => EndTime - StartTime + }, + erf_telemetry:event({request_complete, Metrics}, Name, ErfRequest, {StatusCode, Headers, Body}), + {ok, CowboyReq2, State} + catch + Class:Reason:Stacktrace -> + handle_exception(Name, ErfRequest, Class, Reason, Stacktrace), + CowboyReqErr = cowboy_req:reply(500, #{}, <<>>, CowboyReq1), + {ok, CowboyReqErr, State} + end. + +%%%----------------------------------------------------------------------------- +%%% INTERNAL FUNCTIONS +%%%----------------------------------------------------------------------------- +-spec transport_opts(Port, Conf, SSL, NumAcceptors, MaxConnections) -> TransportOpts when + Port :: inet:port_number(), + Conf :: erf_http_server:conf(), + SSL :: boolean(), + NumAcceptors :: pos_integer(), + MaxConnections :: pos_integer() | infinity, + TransportOpts :: map(). +transport_opts(Port, Conf, true, NumAcceptors, MaxConnections) -> + CertFile = maps:get(certfile, Conf), + KeyFile = maps:get(keyfile, Conf), + #{ + socket_opts => [ + {port, Port}, + {certfile, CertFile}, + {keyfile, KeyFile} + ], + num_acceptors => NumAcceptors, + max_connections => MaxConnections + }; +transport_opts(Port, _Conf, false, NumAcceptors, MaxConnections) -> + #{ + socket_opts => [{port, Port}], + num_acceptors => NumAcceptors, + max_connections => MaxConnections + }. + +-spec preprocess(Name, Req) -> {Req, Request} when + Name :: atom(), + Req :: cowboy_req:req(), + Request :: erf:request(). +preprocess(Name, Req0) -> + Scheme = cowboy_req:scheme(Req0), + Host = cowboy_req:host(Req0), + Port = cowboy_req:port(Req0), + Path = cowboy_req:path_info(Req0), + PathBin = cowboy_req:path(Req0), + PathSegments = + case Path of + undefined -> + % path_info is undefined if no [...] in route, parse manually + [P || P <- binary:split(PathBin, <<"/">>, [global]), P =/= <<>>]; + P -> + P + end, + Method = preprocess_method(cowboy_req:method(Req0)), + QsVals = cowboy_req:parse_qs(Req0), + Headers = maps:to_list(cowboy_req:headers(Req0)), + Peer = peer_to_binary(cowboy_req:peer(Req0)), + {Req1, RawBody} = read_body(Req0), + JoinPath = erlang:iolist_to_binary([<<"/">>, lists:join(<<"/">>, PathSegments)]), + Route = + case erf:match_route(Name, JoinPath) of + {ok, R} -> + R; + {error, not_found} -> + JoinPath + end, + Request = #{ + scheme => Scheme, + host => Host, + port => Port, + path => PathSegments, + method => Method, + query_parameters => QsVals, + headers => Headers, + body => RawBody, + peer => Peer, + route => Route + }, + {Req1, Request}. + +-spec preprocess_method(Method) -> Result when + Method :: binary(), + Result :: erf:method(). +preprocess_method(<<"GET">>) -> + get; +preprocess_method(<<"POST">>) -> + post; +preprocess_method(<<"PUT">>) -> + put; +preprocess_method(<<"DELETE">>) -> + delete; +preprocess_method(<<"PATCH">>) -> + patch; +preprocess_method(<<"HEAD">>) -> + head; +preprocess_method(<<"OPTIONS">>) -> + options; +preprocess_method(<<"TRACE">>) -> + trace; +preprocess_method(<<"CONNECT">>) -> + connect. + +-spec read_body(Req) -> {Req, Body} when + Req :: cowboy_req:req(), + Body :: undefined | binary(). +read_body(Req0) -> + case cowboy_req:has_body(Req0) of + false -> + {Req0, undefined}; + true -> + read_body_loop(Req0, <<>>) + end. + +-spec read_body_loop(Req, Acc) -> {Req, Body} when + Req :: cowboy_req:req(), + Acc :: binary(), + Body :: undefined | binary(). +read_body_loop(Req0, Acc) -> + case cowboy_req:read_body(Req0) of + {ok, Data, Req1} -> + Body = <>, + case Body of + <<>> -> {Req1, undefined}; + _ -> {Req1, Body} + end; + {more, Data, Req1} -> + read_body_loop(Req1, <>) + end. + +-spec peer_to_binary(Peer) -> Result when + Peer :: {inet:ip_address(), inet:port_number()}, + Result :: binary(). +peer_to_binary({IP, _Port}) -> + erlang:list_to_binary(inet:ntoa(IP)). + +-spec postprocess(Request, Response, Req) -> {Req, StatusCode, Headers, Body} when + Request :: erf:request(), + Response :: erf:response(), + Req :: cowboy_req:req(), + StatusCode :: pos_integer(), + Headers :: [{binary(), binary()}], + Body :: undefined | binary() | {file, binary()}. +postprocess( + #{headers := ReqHeaders} = _Request, + {Status, Headers, {file, File}}, + Req0 +) -> + HeadersMap = maps:from_list(Headers), + case file:read_file_info(File) of + {ok, FileInfo} -> + FileSize = element(2, FileInfo), + Range = parse_range(ReqHeaders, FileSize), + case Range of + {partial, Start, End} -> + Length = End - Start + 1, + RangeHeaders = HeadersMap#{ + <<"content-range">> => iolist_to_binary([ + <<"bytes ">>, + integer_to_binary(Start), + <<"-">>, + integer_to_binary(End), + <<"/">>, + integer_to_binary(FileSize) + ]), + <<"content-length">> => integer_to_binary(Length) + }, + Req1 = cowboy_req:reply(206, RangeHeaders, {sendfile, Start, Length, File}, Req0), + {Req1, 206, Headers, {file, File}}; + full -> + Req1 = cowboy_req:reply(Status, HeadersMap, {sendfile, 0, FileSize, File}, Req0), + {Req1, Status, Headers, {file, File}} + end; + {error, _Reason} -> + Req1 = cowboy_req:reply(404, #{}, <<>>, Req0), + {Req1, 404, [], undefined} + end; +postprocess(_Request, {Status, Headers, Body}, Req0) -> + HeadersMap = maps:from_list(Headers), + RespBody = case Body of undefined -> <<>>; B -> B end, + Req1 = cowboy_req:reply(Status, HeadersMap, RespBody, Req0), + {Req1, Status, Headers, Body}. + +-spec parse_range(Headers, FileSize) -> Result when + Headers :: [{binary(), binary()}], + FileSize :: non_neg_integer(), + Result :: full | {partial, Start :: non_neg_integer(), End :: non_neg_integer()}. +parse_range(Headers, FileSize) -> + case proplists:get_value(<<"range">>, Headers) of + undefined -> + full; + RangeHeader -> + case binary:match(RangeHeader, <<"bytes=">>) of + {0, 6} -> + RangeSpec = binary:part(RangeHeader, 6, byte_size(RangeHeader) - 6), + parse_range_spec(RangeSpec, FileSize); + _ -> + full + end + end. + +-spec parse_range_spec(RangeSpec, FileSize) -> Result when + RangeSpec :: binary(), + FileSize :: non_neg_integer(), + Result :: full | {partial, Start :: non_neg_integer(), End :: non_neg_integer()}. +parse_range_spec(RangeSpec, FileSize) -> + case binary:split(RangeSpec, <<"-">>) of + [<<>>, SuffixLength] -> + % suffix-byte-range-spec: -500 means last 500 bytes + Suffix = binary_to_integer(SuffixLength), + Start = max(0, FileSize - Suffix), + {partial, Start, FileSize - 1}; + [StartBin, <<>>] -> + % byte-range-spec: 500- means from 500 to end + Start = binary_to_integer(StartBin), + {partial, Start, FileSize - 1}; + [StartBin, EndBin] -> + Start = binary_to_integer(StartBin), + End = min(binary_to_integer(EndBin), FileSize - 1), + {partial, Start, End}; + _ -> + full + end. + +-spec resp_body_length(Body) -> Length when + Body :: undefined | binary() | {file, binary()}, + Length :: non_neg_integer(). +resp_body_length(undefined) -> + 0; +resp_body_length({file, File}) -> + case file:read_file_info(File) of + {ok, FileInfo} -> + element(2, FileInfo); + {error, _} -> + 0 + end; +resp_body_length(Body) when is_binary(Body) -> + byte_size(Body). + +-spec handle_exception(Name, Request, Class, Reason, Stacktrace) -> ok when + Name :: atom(), + Request :: erf:request(), + Class :: error | exit | throw, + Reason :: term(), + Stacktrace :: list(). +handle_exception(Name, Request, Class, Reason, Stacktrace) -> + {ok, LogLevel} = erf_conf:log_level(Name), + ?LOG(LogLevel, "[erf] Request ~p ~p with exception ~p.~nStacktrace:~n~p", [ + Class, Request, Reason, Stacktrace + ]), + ExceptionData = #{ + error => erlang:list_to_binary(io_lib:format("~p", [Reason])), + monotonic_time => erlang:monotonic_time(), + stacktrace => erlang:list_to_binary(io_lib:format("~p", [Stacktrace])) + }, + erf_telemetry:event({request_exception, ExceptionData}, Name, Request, {500, [], undefined}). diff --git a/src/erf_http_server/erf_http_server_elli.erl b/src/erf_http_server/erf_http_server_elli.erl index 3ed75ab..e472cc1 100644 --- a/src/erf_http_server/erf_http_server_elli.erl +++ b/src/erf_http_server/erf_http_server_elli.erl @@ -17,7 +17,7 @@ %%% BEHAVIOURS -behaviour(erf_http_server). --behaviour(elli_handler). +%% -behaviour(elli_handler). %%% INCLUDE FILES -include_lib("kernel/include/logger.hrl"). diff --git a/test/erf_http_server_SUITE.erl b/test/erf_http_server_SUITE.erl new file mode 100644 index 0000000..bce7ed4 --- /dev/null +++ b/test/erf_http_server_SUITE.erl @@ -0,0 +1,354 @@ +%%% Copyright 2024 Nomasystems, S.L. http://www.nomasystems.com +%% +%% 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 Test suite for erf HTTP servers (elli and cowboy). +%% +%% This suite runs the same tests against both backends to ensure +%% consistent behavior. +-module(erf_http_server_SUITE). + +%%% INCLUDE FILES +-include_lib("stdlib/include/assert.hrl"). +-include_lib("common_test/include/ct.hrl"). + +%%% EXTERNAL EXPORTS +-compile([export_all, nowarn_export_all]). + +%%% MACROS +-define(PORT, 8791). +-define(HOST, "localhost"). + +%%%----------------------------------------------------------------------------- +%%% SUITE EXPORTS +%%%----------------------------------------------------------------------------- +all() -> + [ + {group, elli}, + {group, cowboy} + ]. + +groups() -> + Tests = [ + basic_get, + basic_post, + query_parameters, + invalid_query_parameters, + middlewares, + stop_middleware, + static_file, + static_dir, + range_request, + swagger_ui, + not_found + ], + [ + {elli, [], Tests}, + {cowboy, [], Tests} + ]. + +%%%----------------------------------------------------------------------------- +%%% INIT SUITE EXPORTS +%%%----------------------------------------------------------------------------- +init_per_suite(Conf) -> + {ok, _} = application:ensure_all_started(cowboy), + {ok, _} = application:ensure_all_started(gun), + nct_util:setup_suite(Conf). + +%%%----------------------------------------------------------------------------- +%%% END SUITE EXPORTS +%%%----------------------------------------------------------------------------- +end_per_suite(Conf) -> + nct_util:teardown_suite(Conf). + +%%%----------------------------------------------------------------------------- +%%% INIT GROUP EXPORTS +%%%----------------------------------------------------------------------------- +init_per_group(elli, Conf) -> + [{http_server, {erf_http_server_elli, #{}}} | Conf]; +init_per_group(cowboy, Conf) -> + [{http_server, {erf_http_server_cowboy, #{}}} | Conf]. + +%%%----------------------------------------------------------------------------- +%%% END GROUP EXPORTS +%%%----------------------------------------------------------------------------- +end_per_group(_Group, Conf) -> + Conf. + +%%%----------------------------------------------------------------------------- +%%% INIT CASE EXPORTS +%%%----------------------------------------------------------------------------- +init_per_testcase(Case, Conf) -> + ct:print("Starting test case ~p", [Case]), + nct_util:init_traces(Case), + Conf. + +%%%----------------------------------------------------------------------------- +%%% END CASE EXPORTS +%%%----------------------------------------------------------------------------- +end_per_testcase(Case, Conf) -> + nct_util:end_traces(Case), + ct:print("Test case ~p completed", [Case]), + Conf. + +%%%----------------------------------------------------------------------------- +%%% TEST CASES +%%%----------------------------------------------------------------------------- +basic_get(Conf) -> + HttpServer = ?config(http_server, Conf), + {ok, _Pid} = start_server(HttpServer, #{}), + + {200, _, Body} = request(get, "/1/foo", [], HttpServer), + ?assertEqual(<<"\"bar\"">>, Body), + + ok = erf:stop(erf_test_server). + +basic_post(Conf) -> + HttpServer = ?config(http_server, Conf), + {ok, _Pid} = start_server(HttpServer, #{}), + + {201, _, Body} = request(post, "/1/foo", [], <<"\"bar\"">>, <<"application/json">>, HttpServer), + ?assertEqual(<<"\"bar\"">>, Body), + + ok = erf:stop(erf_test_server). + +query_parameters(Conf) -> + HttpServer = ?config(http_server, Conf), + {ok, _Pid} = start_server(HttpServer, #{}), + + % Valid string + {200, _, _} = request(get, "/1/foo?string=test", [], HttpServer), + % Valid integer + {200, _, _} = request(get, "/1/foo?page=1", [], HttpServer), + % Valid number + {200, _, _} = request(get, "/1/foo?price=1.25", [], HttpServer), + % Valid boolean + {200, _, _} = request(get, "/1/foo?enabled=true", [], HttpServer), + % Valid array + {200, _, _} = request(get, "/1/foo?integerArray=1&integerArray=2", [], HttpServer), + + ok = erf:stop(erf_test_server). + +invalid_query_parameters(Conf) -> + HttpServer = ?config(http_server, Conf), + {ok, _Pid} = start_server(HttpServer, #{}), + + % Invalid integer (float) + {400, _, _} = request(get, "/1/foo?page=2.5", [], HttpServer), + % Invalid integer (string) + {400, _, _} = request(get, "/1/foo?page=test", [], HttpServer), + % Invalid boolean + {400, _, _} = request(get, "/1/foo?enabled=2", [], HttpServer), + % Invalid array element + {400, _, _} = request(get, "/1/foo?integerArray=1&integerArray=true", [], HttpServer), + + ok = erf:stop(erf_test_server). + +middlewares(Conf) -> + HttpServer = ?config(http_server, Conf), + {ok, _Pid} = start_server(HttpServer, #{ + preprocess_middlewares => [erf_test_middleware], + postprocess_middlewares => [erf_test_middleware] + }), + + % Middleware rewrites /2/foo to /1/foo and adds content-type + {200, Headers, Body} = request(get, "/2/foo", [], HttpServer), + ?assertEqual(<<"\"bar\"">>, Body), + ?assertNotEqual(undefined, get_header(<<"content-type">>, Headers)), + + ok = erf:stop(erf_test_server). + +stop_middleware(Conf) -> + HttpServer = ?config(http_server, Conf), + {ok, _Pid} = start_server(HttpServer, #{ + preprocess_middlewares => [erf_test_stop_middleware] + }), + + % DELETE should be stopped with 403 + {403, _, _} = request(delete, "/1/foo", [], HttpServer), + + ok = erf:stop(erf_test_server). + +static_file(Conf) -> + HttpServer = ?config(http_server, Conf), + FilePath = list_to_binary( + filename:join([code:lib_dir(erf), "test", "fixtures", "common_oas_3_0_spec.json"]) + ), + {ok, Expected} = file:read_file(FilePath), + + {ok, _Pid} = start_server(HttpServer, #{ + static_routes => [{<<"/common">>, {file, FilePath}}] + }), + + {200, _, Body} = request(get, "/common", [], HttpServer), + ?assertEqual(Expected, Body), + + ok = erf:stop(erf_test_server). + +static_dir(Conf) -> + HttpServer = ?config(http_server, Conf), + DirPath = list_to_binary(filename:join([code:lib_dir(erf), "test", "fixtures"])), + FilePath = filename:join(DirPath, "common_oas_3_0_spec.json"), + {ok, Expected} = file:read_file(FilePath), + + {ok, _Pid} = start_server(HttpServer, #{ + static_routes => [{<<"/static">>, {dir, DirPath}}] + }), + + {200, _, Body} = request(get, "/static/common_oas_3_0_spec.json", [], HttpServer), + ?assertEqual(Expected, Body), + + ok = erf:stop(erf_test_server). + +range_request(Conf) -> + HttpServer = ?config(http_server, Conf), + DirPath = list_to_binary(filename:join([code:lib_dir(erf), "test", "fixtures"])), + + {ok, _Pid} = start_server(HttpServer, #{ + static_routes => [{<<"/static">>, {dir, DirPath}}] + }), + + % First byte only + {206, _, Body1} = request( + get, "/static/common_oas_3_0_spec.json", [{<<"range">>, <<"bytes=0-0">>}], HttpServer + ), + ?assertEqual(<<"{">>, Body1), + + % First 10 bytes + {206, Headers, Body2} = request( + get, "/static/common_oas_3_0_spec.json", [{<<"range">>, <<"bytes=0-9">>}], HttpServer + ), + ?assertEqual(10, byte_size(Body2)), + ?assertNotEqual(undefined, get_header(<<"content-range">>, Headers)), + + ok = erf:stop(erf_test_server). + +swagger_ui(Conf) -> + HttpServer = ?config(http_server, Conf), + SpecPath = list_to_binary( + filename:join([code:priv_dir(erf), "oas", "3.0", "examples", "petstore.json"]) + ), + {ok, Expected} = file:read_file(SpecPath), + + {ok, _Pid} = erf:start_link(#{ + spec_path => SpecPath, + callback => erf_test_callback, + port => ?PORT, + name => erf_test_server, + swagger_ui => true, + http_server => HttpServer + }), + + {200, _, SwaggerBody} = request(get, "/swagger", [], HttpServer), + ?assertMatch(<<">, SwaggerBody), + + {200, _, SpecBody} = request(get, "/swagger/spec.json", [], HttpServer), + ?assertEqual(Expected, SpecBody), + + ok = erf:stop(erf_test_server). + +not_found(Conf) -> + HttpServer = ?config(http_server, Conf), + {ok, _Pid} = start_server(HttpServer, #{}), + + {404, _, _} = request(get, "/not/found", [], HttpServer), + + ok = erf:stop(erf_test_server). + +%%%----------------------------------------------------------------------------- +%%% INTERNAL FUNCTIONS +%%%----------------------------------------------------------------------------- +start_server(HttpServer, ExtraOpts) -> + BaseOpts = #{ + spec_path => spec_path(), + callback => erf_test_callback, + port => ?PORT, + name => erf_test_server, + http_server => HttpServer + }, + erf:start_link(maps:merge(BaseOpts, ExtraOpts)). + +spec_path() -> + list_to_binary( + filename:join([code:lib_dir(erf), "test", "fixtures", "with_refs_oas_3_0_spec.json"]) + ). + +request(Method, Path, Headers, HttpServer) -> + request(Method, Path, Headers, <<>>, undefined, HttpServer). + +request(Method, Path, Headers, Body, ContentType, {erf_http_server_cowboy, _}) -> + http2_request(Method, Path, Headers, Body, ContentType); +request(Method, Path, Headers, Body, ContentType, {erf_http_server_elli, _}) -> + http1_request(Method, Path, Headers, Body, ContentType). + +%% HTTP/1.1 request using httpc +http1_request(Method, Path, Headers, Body, ContentType) -> + Url = "http://" ++ ?HOST ++ ":" ++ integer_to_list(?PORT) ++ Path, + HttpcHeaders = [{binary_to_list(K), binary_to_list(V)} || {K, V} <- Headers], + Request = + case {Method, ContentType} of + {get, _} -> {Url, HttpcHeaders}; + {delete, _} -> {Url, HttpcHeaders}; + {head, _} -> {Url, HttpcHeaders}; + {options, _} -> {Url, HttpcHeaders}; + {_, CT} -> {Url, HttpcHeaders, binary_to_list(CT), Body} + end, + case httpc:request(Method, Request, [], [{body_format, binary}]) of + {ok, {{_, Status, _}, RespHeaders, RespBody}} -> + NormHeaders = [{list_to_binary(K), list_to_binary(V)} || {K, V} <- RespHeaders], + {Status, NormHeaders, RespBody} + end. + +%% HTTP/2 request using gun +http2_request(Method, Path, Headers, Body, ContentType) -> + {ok, ConnPid} = gun:open(?HOST, ?PORT, #{ + protocols => [http2], + http2_opts => #{preface_timeout => 10000} + }), + {ok, http2} = gun:await_up(ConnPid, 5000), + + Headers1 = + case ContentType of + undefined -> Headers; + CT -> [{<<"content-type">>, CT} | Headers] + end, + + StreamRef = + case Method of + get -> gun:get(ConnPid, Path, Headers1); + post -> gun:post(ConnPid, Path, Headers1, Body); + put -> gun:put(ConnPid, Path, Headers1, Body); + delete -> gun:delete(ConnPid, Path, Headers1); + head -> gun:head(ConnPid, Path, Headers1); + options -> gun:options(ConnPid, Path, Headers1); + patch -> gun:patch(ConnPid, Path, Headers1, Body) + end, + + Result = + case gun:await(ConnPid, StreamRef, 5000) of + {response, fin, Status, RespHeaders} -> + {Status, RespHeaders, <<>>}; + {response, nofin, Status, RespHeaders} -> + {ok, RespBody} = gun:await_body(ConnPid, StreamRef, 5000), + {Status, RespHeaders, RespBody} + end, + + gun:close(ConnPid), + Result. + +get_header(Name, Headers) -> + LowerName = string:lowercase(Name), + case lists:keyfind(LowerName, 1, [{string:lowercase(K), V} || {K, V} <- Headers]) of + {_, Value} -> Value; + false -> undefined + end. diff --git a/test/erf_test_callback.erl b/test/erf_test_callback.erl new file mode 100644 index 0000000..aafe255 --- /dev/null +++ b/test/erf_test_callback.erl @@ -0,0 +1,28 @@ +%%% Copyright 2024 Nomasystems, S.L. http://www.nomasystems.com +%% +%% 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 Test callback module for erf tests. +-module(erf_test_callback). + +%%% EXPORTS +-export([ + get_foo/1, + create_foo/1 +]). + +get_foo(_Request) -> + {200, [], <<"bar">>}. + +create_foo(#{body := Body}) -> + {201, [], Body}. diff --git a/test/erf_test_middleware.erl b/test/erf_test_middleware.erl new file mode 100644 index 0000000..a531f49 --- /dev/null +++ b/test/erf_test_middleware.erl @@ -0,0 +1,32 @@ +%%% Copyright 2024 Nomasystems, S.L. http://www.nomasystems.com +%% +%% 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 Test middleware module for erf tests. +-module(erf_test_middleware). + +%%% EXPORTS +-export([ + preprocess/1, + postprocess/2 +]). + +%% @doc Rewrites version path parameter to "1" +preprocess(#{path := [_Version | Path]} = Request) -> + Request#{path => [<<"1">> | Path]}; +preprocess(Request) -> + Request. + +%% @doc Adds content-type header +postprocess(_Req, {StatusCode, Headers, Body}) -> + {StatusCode, [{<<"content-type">>, <<"application/json">>} | Headers], Body}. diff --git a/test/erf_test_stop_middleware.erl b/test/erf_test_stop_middleware.erl new file mode 100644 index 0000000..802c1e4 --- /dev/null +++ b/test/erf_test_stop_middleware.erl @@ -0,0 +1,27 @@ +%%% Copyright 2024 Nomasystems, S.L. http://www.nomasystems.com +%% +%% 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 Test stop middleware module for erf tests. +-module(erf_test_stop_middleware). + +%%% EXPORTS +-export([ + preprocess/1 +]). + +%% @doc Stops DELETE requests with 403 +preprocess(#{method := delete}) -> + {stop, {403, [], undefined}}; +preprocess(Request) -> + Request.