Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTP controller API #441

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
%% Copyright 2021, Travelping GmbH <[email protected]>

%% 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]),
Acc#{K => 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
%% Copyright 2021, Travelping GmbH <[email protected]>

%% 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.
Loading