Skip to content

Commit

Permalink
HTTP controller API
Browse files Browse the repository at this point in the history
  • Loading branch information
vk committed Oct 21, 2021
1 parent 41696f9 commit 0c1a7df
Show file tree
Hide file tree
Showing 7 changed files with 1,433 additions and 5 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Expand Up @@ -16,3 +16,5 @@ ergw.config
data.ergw*
log.ergw*
apps/ergw_core/priv*
data
ergw@*
20 changes: 15 additions & 5 deletions apps/ergw/src/ergw_config.erl
Expand Up @@ -13,7 +13,7 @@
-compile({no_auto_import,[put/2]}).

%% API
-export([load/0, apply/1, serialize_config/1]).
-export([load/0, apply/1, serialize_config/1, reload_config/1, ergw_core_init/2]).

-ifdef(TEST).
-export([config_meta/0,
Expand Down Expand Up @@ -45,6 +45,14 @@
%%% API
%%%===================================================================

reload_config(#{} = Config) ->
load_typespecs(),
do([error_m ||
load_schemas(),
validate_config_with_schema(Config),
return(coerce_config(Config))
]).

load() ->
load_typespecs(),
do([error_m ||
Expand Down Expand Up @@ -129,8 +137,9 @@ ergw_aaa_init(apps, #{apps := Apps0}) ->
Apps = ergw_aaa_config:validate_options(fun ergw_aaa_config:validate_app/2, Apps0, []),
maps:map(fun ergw_aaa:add_application/2, Apps),
Apps;
ergw_aaa_init(_, _) ->
ok.
ergw_aaa_init(K, _) ->
?LOG(warning, "The key ~p is missed in config of erGW-AAA", [K]),
{error, unhandled}.

ergw_sbi_client_init(Opts) ->
ergw_sbi_client_config:validate_options(fun ergw_sbi_client_config:validate_option/2, Opts).
Expand Down Expand Up @@ -185,8 +194,9 @@ ergw_core_init(proxy_map, #{proxy_map := Map}) ->
ok = ergw_core:setopts(proxy_map, Map);
ergw_core_init(http_api, #{http_api := Opts}) ->
ergw_http_api:init(Opts);
ergw_core_init(_K, _) ->
ok.
ergw_core_init(K, _) ->
?LOG(warning, "The key ~p is missed in config of erGW", [K]),
{error, unhandled}.

ergw_charging_init(rules, #{rules := Rules}) ->
maps:map(fun ergw_core:add_charging_rule/2, Rules);
Expand Down
2 changes: 2 additions & 0 deletions apps/ergw/src/ergw_http_api.erl
Expand Up @@ -39,6 +39,8 @@ start_http_listener(#{enabled := true} = Opts) ->
{"/status/[...]", http_status_handler, []},
%% 5G SBI APIs
{"/sbi/nbsf-management/v1/pcfBindings", sbi_nbsf_handler, []},
%% HTTP controller
{"/api/v1/controller", http_controller_handler, []},
%% serves static files for swagger UI
{"/api/v1/spec/ui", swagger_ui_handler, []},
{"/api/v1/spec/ui/[...]", cowboy_static, {priv_dir, ergw_core, "static"}}]}
Expand Down
154 changes: 154 additions & 0 deletions apps/ergw/src/http_controller_handler.erl
@@ -0,0 +1,154 @@
%% Copyright 2021, Travelping GmbH <info@travelping.com>

%% This program is free software; you can redistribute it and/or
%% modify it under the terms of the GNU General Public License
%% as published by the Free Software Foundation; either version
%% 2 of the License, or (at your option) any later version.

-module(http_controller_handler).

-behavior(cowboy_rest).

-export([init/2, content_types_provided/2, handle_request_json/2,
allowed_methods/2, delete_resource/2, content_types_accepted/2]).

-ignore_xref([handle_request_json/2]).

-include_lib("kernel/include/logger.hrl").

-define(CONTENT_TYPE_PROBLEM_JSON, #{<<"content-type">> => "application/problem+json"}).

init(Req0, State) ->
case cowboy_req:version(Req0) of
'HTTP/2' ->
{cowboy_rest, Req0, State};
_ ->
Body = jsx:encode(#{
title => <<"HTTP/2 is mandatory.">>,
status => 505,
cause => <<"UNSUPPORTED_HTTP_VERSION">>
}),
Req = cowboy_req:reply(505, ?CONTENT_TYPE_PROBLEM_JSON, Body, Req0),
{ok, Req, done}
end.

allowed_methods(Req, State) ->
{[<<"POST">>], Req, State}.

content_types_provided(Req, State) ->
{[{<<"application/json">>, handle_request_json}], Req, State}.

content_types_accepted(Req, State) ->
{[{'*', handle_request_json}], Req, State}.

delete_resource(Req, State) ->
Path = cowboy_req:path(Req),
Method = cowboy_req:method(Req),
handle_request(Method, Path, Req, State).

handle_request_json(Req, State) ->
Path = cowboy_req:path(Req),
Method = cowboy_req:method(Req),
handle_request(Method, Path, Req, State).

%%%===================================================================
%%% Handler of request
%%%===================================================================

handle_request(<<"POST">>, <<"/api/v1/controller">>, Req0, State) ->
{ok, Body, Req} = read_body(Req0),
case validate_json_req(Body) of
{ok, Response} ->
reply(200, Req, jsx:encode(Response), State);
{error, invalid_json} ->
Response = rfc7807(<<"Invalid JSON">>, <<"INVALID_JSON">>, []),
reply(400, Req, Response, State);
{error, InvalidParams} ->
Response = rfc7807(<<"Invalid JSON params">>, <<"INVALID_JSON_PARAM">>, InvalidParams),
reply(400, Req, Response, State)
end.

%%%===================================================================
%%% Helper functions
%%%===================================================================

validate_json_req(JsonBin) ->
case json_to_map(JsonBin) of
{ok, Map} ->
apply_config(Map);
Error ->
Error
end.

% Jesse errors: https://github.com/for-GET/jesse/blob/1.5.6/src/jesse_error.erl#L40
apply_config(Map) ->
case catch ergw_config:reload_config(Map) of
{ok, Config} ->
_ = maps:fold(fun add_config_part/3, #{}, Config),
% @TODO for response collect all keys of config what was successfully applied
{ok, #{type => <<"success">>}};
{error, [_|_] = Errors} = Reason ->
?LOG(warning, "~p", [Reason]),
Params = lists:map(fun build_error_params/1, Errors),
{error, Params};
Error ->
?LOG(warning, "Unhandled error ~p~nfor config ~p", [Error, Map]),
{error, []}
end.

build_error_params({Type, Schema, Error, Data, Path}) ->
#{
type => atom_to_binary(Type),
schema => Schema,
error => atom_to_binary(Error),
data => Data,
path => Path
}.

add_config_part(K, V, Acc) ->
Result = ergw_config:ergw_core_init(K, #{K => V}),
?LOG(info, "The ~p added with result ~p", [K, Result]),
maps:merge(Acc, Result).

json_to_map(JsonBin) ->
case catch jsx:decode(JsonBin) of
#{} = Map ->
{ok, Map};
_ ->
{error, invalid_json}
end.

read_body(Req) ->
read_body(Req, <<>>).

read_body(Req0, Acc) ->
case cowboy_req:read_body(Req0) of
{ok, Data, Req} ->
{ok, <<Acc/binary, Data/binary>>, Req};
{more, Data, Req} ->
read_body(Req, <<Acc/binary, Data/binary>>)
end.

reply(StatusCode, Req0, Body, State) ->
Req = case StatusCode of
200 ->
cowboy_req:reply(StatusCode, #{}, Body, Req0);
400 ->
cowboy_req:reply(StatusCode, ?CONTENT_TYPE_PROBLEM_JSON, Body, Req0)
end,
{stop, Req, State}.

%% https://datatracker.ietf.org/doc/html/rfc7807
rfc7807(Title, Cause, []) ->
jsx:encode(#{
title => Title,
status => 400,
cause => Cause
});
rfc7807(Title, Cause, InvalidParams) ->
jsx:encode(#{
title => Title,
status => 400,
cause => Cause,
'invalid-params' => InvalidParams
}).
180 changes: 180 additions & 0 deletions apps/ergw/test/http_controller_handler_SUITE.erl
@@ -0,0 +1,180 @@
%% Copyright 2021, Travelping GmbH <info@travelping.com>

%% This program is free software; you can redistribute it and/or
%% modify it under the terms of the GNU General Public License
%% as published by the Free Software Foundation; either version
%% 2 of the License, or (at your option) any later version.

-module(http_controller_handler_SUITE).

-compile(export_all).

-include("smc_test_lib.hrl").
-include("smc_ggsn_test_lib.hrl").
-include_lib("ergw_core/include/ergw.hrl").
-include_lib("gtplib/include/gtp_packet.hrl").
-include_lib("common_test/include/ct.hrl").

-define(HUT, ggsn_gn).

all() ->
[http_controller_handler_post_ip_pools,
http_controller_handler_post_ip_pools_invalid,
http_controller_handler_post_apns,
http_controller_handler_post_apns_invalid,
http_controller_handler_post_upf_nodes,
http_controller_handler_post_upf_nodes_invalid,
http_controller_handler_post_invalid_json,
http_controller_handler_check_http2_support].

%%%===================================================================
%%% Tests
%%%===================================================================

init_per_suite(Config0) ->
Config1 = smc_test_lib:init_ets(Config0),
Config2 = [{handler_under_test, ?HUT}|Config1],
Config = smc_test_lib:group_config(ipv4, Config2),

[application:load(App) || App <- [cowboy, ergw_core, ergw_aaa]],
smc_test_lib:meck_init(Config),

Dir = ?config(data_dir, Config),
application:load(ergw),
CfgSet = #{type => json, file => filename:join(Dir, "ggsn.json")},
application:set_env(ergw, config, CfgSet),
{ok, Started} = application:ensure_all_started(ergw),
ct:pal("Started: ~p", [Started]),

%ergw:wait_till_running(), %% @TODO ...
inets:start(),

{ok, JsonBin} = file:read_file(filename:join(Dir, "post_test_data.json")),
TestsPostData = {test_post_data, jsx:decode(JsonBin)},

[TestsPostData|Config].

end_per_suite(Config) ->
smc_test_lib:meck_unload(Config),
?config(table_owner, Config) ! stop,
[application:stop(App) || App <- [ergw_core, ergw_aaa, ergw_cluster, ergw]],
inets:stop(),
ok.

http_controller_handler_post_ip_pools() ->
[{doc, "Check /api/v1/controller success POST API ip_pools"}].
http_controller_handler_post_ip_pools(Config) ->
Body = prepare_json_body(<<"ip_pools">>, Config),
Resp = gun_post(Body),
ct:pal("Request~p~nResponse ~p~n", [Body, Resp]),
?match(#{status := 200}, Resp).

http_controller_handler_post_ip_pools_invalid() ->
[{doc, "Check /api/v1/controller invalid POST API ip_pools"}].
http_controller_handler_post_ip_pools_invalid(_Config) ->
Body = <<"{\"ip_pools\": \"test\"}">>,
Resp = gun_post(Body),
ct:pal("Request~p~nResponse ~p~n", [Body, Resp]),
#{headers := Headers} = Resp,
?match(<<"application/problem+json">>, proplists:get_value(<<"content-type">>, Headers)),
?match(#{status := 400}, Resp).

http_controller_handler_post_apns() ->
[{doc, "Check /api/v1/controller success POST API apns"}].
http_controller_handler_post_apns(Config) ->
Body = prepare_json_body(<<"apns">>, Config),
Resp = gun_post(Body),
ct:pal("Request~p~nResponse ~p~n", [Body, Resp]),
?match(#{status := 200}, Resp).

http_controller_handler_post_apns_invalid() ->
[{doc, "Check /api/v1/controller invalid POST API apns"}].
http_controller_handler_post_apns_invalid(_Config) ->
Body = <<"{\"apns\": \"test\"}">>,
Resp = gun_post(Body),
#{headers := Headers} = Resp,
?match(<<"application/problem+json">>, proplists:get_value(<<"content-type">>, Headers)),
ct:pal("Request~p~nResponse ~p~n", [Body, Resp]),
?match(#{status := 400}, Resp).

http_controller_handler_post_upf_nodes() ->
[{doc, "Check /api/v1/controller success POST API UPF nodes"}].
http_controller_handler_post_upf_nodes(Config) ->
Body = prepare_json_body(<<"upf_nodes">>, Config),
Resp = gun_post(Body),
ct:pal("Request~p~nResponse ~p~n", [Body, Resp]),
?match(#{status := 200}, Resp).

http_controller_handler_post_upf_nodes_invalid() ->
[{doc, "Check /api/v1/controller invalid POST API UPF nodes"}].
http_controller_handler_post_upf_nodes_invalid(_Config) ->
Body = <<"{\"upf_nodes\": \"test\"}">>,
Resp = gun_post(Body),
#{headers := Headers} = Resp,
?match(<<"application/problem+json">>, proplists:get_value(<<"content-type">>, Headers)),
ct:pal("Request~p~nResponse ~p~n", [Body, Resp]),
?match(#{status := 400}, Resp).

http_controller_handler_post_invalid_json() ->
[{doc, "Check /api/v1/controller invalid POST API"}].
http_controller_handler_post_invalid_json(_Config) ->
Body = <<"text">>,
Resp = gun_post(Body),
#{headers := Headers} = Resp,
?match(<<"application/problem+json">>, proplists:get_value(<<"content-type">>, Headers)),
ct:pal("Request~p~nResponse ~p~n", [Body, Resp]),
?match(#{status := 400}, Resp).

http_controller_handler_check_http2_support() ->
[{doc, "Check /api/v1/controller that the POST API is supported HTTP/2 only"}].
http_controller_handler_check_http2_support(Config) ->
URL = get_test_url(),
ContentType = "application/json",
Body = prepare_json_body(<<"ip_pools">>, Config),
Resp = httpc:request(post, {URL, [], ContentType, Body}, [], []),
ct:pal("Request~p~nResponse ~p~n", [Body, Resp]),
?match({ok, {{_, 505, _}, _, _}}, Resp).

%%%===================================================================
%%% Internal functions
%%%===================================================================

get_test_url() ->
Port = ranch:get_port(ergw_http_listener),
Path = "/api/v1/controller",
lists:flatten(io_lib:format("http://localhost:~w~s", [Port, Path])).

prepare_json_body(Name, Config) ->
#{Name := Data} = proplists:get_value(test_post_data, Config),
jsx:encode(#{Name => Data}).

gun_post(Body) ->
Pid = gun_http2(),
Headers = [{<<"content-type">>, <<"application/json">>}],
StreamRef = gun:post(Pid, <<"/api/v1/controller">>, Headers, Body),
Resp = gun_reponse(#{pid => Pid, stream_ref => StreamRef, acc => <<>>}),
ok = gun:close(Pid),
maps:without([pid, stream_ref], Resp).

gun_http2() ->
Port = ranch:get_port(ergw_http_listener),
Opts = #{http2_opts => #{keepalive => infinity}, protocols => [http2]},
{ok, Pid} = gun:open("localhost", Port, Opts),
{ok, http2} = gun:await_up(Pid),
Pid.

gun_reponse(#{pid := Pid, stream_ref := StreamRef, acc := Acc} = Opts) ->
case gun:await(Pid, StreamRef) of
{response, fin, Status, Headers} ->
Opts#{status => Status, headers => Headers};
{response, nofin, Status, Headers} ->
gun_reponse(Opts#{status => Status, headers => Headers});
{data, nofin, Data} ->
gun_reponse(Opts#{acc => <<Acc/binary, Data/binary>>});
{data, fin, Data} ->
Opts#{acc => <<Acc/binary, Data/binary>>};
{error, timeout} = Response ->
Response;
{error, _Reason} = Response ->
Response
end.

0 comments on commit 0c1a7df

Please sign in to comment.