Merge pull request #6342 from terry-xiaoyu/improve_rule_api_swagger

Improve HTTP APIs for /rules, /bridges and /connectors
This commit is contained in:
Shawn 2021-12-07 11:13:19 +08:00 committed by GitHub
commit 51f772aade
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 974 additions and 653 deletions

View File

@ -17,220 +17,266 @@
-behaviour(minirest_api). -behaviour(minirest_api).
-export([api_spec/0]). -include_lib("typerefl/include/types.hrl").
-export([ list_create_bridges_in_cluster/2 -import(hoconsc, [mk/2, array/1, enum/1]).
, list_local_bridges/1
, crud_bridges_in_cluster/2 %% Swagger specs from hocon schema
, manage_bridges/2 -export([api_spec/0, paths/0, schema/1, namespace/0]).
%% API callbacks
-export(['/bridges'/2, '/bridges/:id'/2,
'/nodes/:node/bridges/:id/operation/:operation'/2]).
-export([ list_local_bridges/1
, lookup_from_local_node/2 , lookup_from_local_node/2
]). ]).
-define(TYPES, [mqtt, http]). -define(TYPES, [mqtt, http]).
-define(CONN_TYPES, [mqtt]).
-define(TRY_PARSE_ID(ID, EXPR), -define(TRY_PARSE_ID(ID, EXPR),
try emqx_bridge:parse_bridge_id(Id) of try emqx_bridge:parse_bridge_id(Id) of
{BridgeType, BridgeName} -> EXPR {BridgeType, BridgeName} -> EXPR
catch catch
error:{invalid_bridge_id, Id0} -> error:{invalid_bridge_id, Id0} ->
{400, #{code => 'INVALID_ID', message => <<"invalid_bridge_id: ", Id0/binary, {400, #{code => 'INVALID_ID', message => <<"invalid_bridge_id: ", Id0/binary,
". Bridge ID must be of format 'bridge_type:name'">>}} ". Bridge Ids must be of format {type}:{name}">>}}
end). end).
-define(METRICS(MATCH, SUCC, FAILED, RATE, RATE_5, RATE_MAX), -define(METRICS(MATCH, SUCC, FAILED, RATE, RATE_5, RATE_MAX),
#{ matched => MATCH, #{ matched => MATCH,
success => SUCC, success => SUCC,
failed => FAILED, failed => FAILED,
speed => RATE, rate => RATE,
speed_last5m => RATE_5, rate_last5m => RATE_5,
speed_max => RATE_MAX rate_max => RATE_MAX
}). }).
-define(metrics(MATCH, SUCC, FAILED, RATE, RATE_5, RATE_MAX), -define(metrics(MATCH, SUCC, FAILED, RATE, RATE_5, RATE_MAX),
#{ matched := MATCH, #{ matched := MATCH,
success := SUCC, success := SUCC,
failed := FAILED, failed := FAILED,
speed := RATE, rate := RATE,
speed_last5m := RATE_5, rate_last5m := RATE_5,
speed_max := RATE_MAX rate_max := RATE_MAX
}). }).
req_schema() -> namespace() -> "bridge".
Schema = [
case maps:to_list(emqx:get_raw_config([bridges, T], #{})) of
%% the bridge is not configured, so we have no method to get the schema
[] -> #{};
[{_K, Conf} | _] ->
emqx_mgmt_api_configs:gen_schema(Conf)
end
|| T <- ?TYPES],
#{'oneOf' => Schema}.
node_schema() ->
#{type => string, example => "emqx@127.0.0.1"}.
status_schema() ->
#{type => string, enum => [connected, disconnected]}.
metrics_schema() ->
#{ type => object
, properties => #{
matched => #{type => integer, example => "0"},
success => #{type => integer, example => "0"},
failed => #{type => integer, example => "0"},
speed => #{type => number, format => float, example => "0.0"},
speed_last5m => #{type => number, format => float, example => "0.0"},
speed_max => #{type => number, format => float, example => "0.0"}
}
}.
per_node_schema(Key, Schema) ->
#{
type => array,
items => #{
type => object,
properties => #{
node => node_schema(),
Key => Schema
}
}
}.
resp_schema() ->
AddMetadata = fun(Prop) ->
Prop#{status => status_schema(),
node_status => per_node_schema(status, status_schema()),
metrics => metrics_schema(),
node_metrics => per_node_schema(metrics, metrics_schema()),
id => #{type => string, example => "http:my_http_bridge"},
bridge_type => #{type => string, enum => ?TYPES},
node => node_schema()
}
end,
more_props_resp_schema(AddMetadata).
more_props_resp_schema(AddMetadata) ->
#{'oneOf' := Schema} = req_schema(),
Schema1 = [S#{properties => AddMetadata(Prop)}
|| S = #{properties := Prop} <- Schema],
#{'oneOf' => Schema1}.
api_spec() -> api_spec() ->
{bridge_apis(), []}. emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false}).
bridge_apis() -> paths() -> ["/bridges", "/bridges/:id", "/nodes/:node/bridges/:id/operation/:operation"].
[list_all_bridges_api(), crud_bridges_apis(), operation_apis()].
list_all_bridges_api() -> error_schema(Code, Message) ->
ReqSchema = more_props_resp_schema(fun(Prop) -> [ {code, mk(string(), #{example => Code})}
Prop#{id => #{type => string, required => true}} , {message, mk(string(), #{example => Message})}
end), ].
RespSchema = resp_schema(),
Metadata = #{ get_response_body_schema() ->
emqx_dashboard_swagger:schema_with_examples(emqx_bridge_schema:get_response(),
bridge_info_examples(get)).
param_path_node() ->
path_param(node, binary(), atom_to_binary(node(), utf8)).
param_path_operation() ->
path_param(operation, enum([start, stop, restart]), <<"start">>).
param_path_id() ->
path_param(id, binary(), <<"http:my_http_bridge">>).
path_param(Name, Type, Example) ->
{Name, mk(Type,
#{ in => path
, required => true
, example => Example
})}.
bridge_info_array_example(Method) ->
[Config || #{value := Config} <- maps:values(bridge_info_examples(Method))].
bridge_info_examples(Method) ->
maps:merge(conn_bridge_examples(Method), #{
<<"http_bridge">> => #{
summary => <<"HTTP Bridge">>,
value => info_example(http, awesome, Method)
}
}).
conn_bridge_examples(Method) ->
lists:foldl(fun(Type, Acc) ->
SType = atom_to_list(Type),
KeyIngress = bin(SType ++ "_ingress"),
KeyEgress = bin(SType ++ "_egress"),
maps:merge(Acc, #{
KeyIngress => #{
summary => bin(string:uppercase(SType) ++ " Ingress Bridge"),
value => info_example(Type, ingress, Method)
},
KeyEgress => #{
summary => bin(string:uppercase(SType) ++ " Egress Bridge"),
value => info_example(Type, egress, Method)
}
})
end, #{}, ?CONN_TYPES).
info_example(Type, Direction, Method) ->
maps:merge(info_example_basic(Type, Direction),
method_example(Type, Direction, Method)).
method_example(Type, Direction, get) ->
SType = atom_to_list(Type),
SDir = atom_to_list(Direction),
SName = "my_" ++ SDir ++ "_" ++ SType ++ "_bridge",
#{
id => bin(SType ++ ":" ++ SName),
type => bin(SType),
name => bin(SName)
};
method_example(Type, Direction, post) ->
SType = atom_to_list(Type),
SDir = atom_to_list(Direction),
SName = "my_" ++ SDir ++ "_" ++ SType ++ "_bridge",
#{
type => bin(SType),
name => bin(SName)
};
method_example(_Type, _Direction, put) ->
#{}.
info_example_basic(http, _) ->
#{
url => <<"http://localhost:9901/messages/${topic}">>,
request_timeout => <<"30s">>,
connect_timeout => <<"30s">>,
max_retries => 3,
retry_interval => <<"10s">>,
pool_type => <<"random">>,
pool_size => 4,
enable_pipelining => true,
ssl => #{enable => false},
from_local_topic => <<"emqx_http/#">>,
method => post,
body => <<"${payload}">>
};
info_example_basic(mqtt, ingress) ->
#{
connector => <<"mqtt:my_mqtt_connector">>,
direction => ingress,
from_remote_topic => <<"aws/#">>,
subscribe_qos => 1,
to_local_topic => <<"from_aws/${topic}">>,
payload => <<"${payload}">>,
qos => <<"${qos}">>,
retain => <<"${retain}">>
};
info_example_basic(mqtt, egress) ->
#{
connector => <<"mqtt:my_mqtt_connector">>,
direction => egress,
from_local_topic => <<"emqx/#">>,
to_remote_topic => <<"from_emqx/${topic}">>,
payload => <<"${payload}">>,
qos => 1,
retain => false
}.
schema("/bridges") ->
#{
operationId => '/bridges',
get => #{ get => #{
tags => [<<"bridges">>],
summary => <<"List Bridges">>,
description => <<"List all created bridges">>, description => <<"List all created bridges">>,
responses => #{ responses => #{
<<"200">> => emqx_mgmt_util:array_schema(resp_schema(), 200 => emqx_dashboard_swagger:schema_with_example(
<<"A list of the bridges">>) array(emqx_bridge_schema:get_response()),
bridge_info_array_example(get))
} }
}, },
post => #{ post => #{
tags => [<<"bridges">>],
summary => <<"Create Bridge">>,
description => <<"Create a new bridge">>, description => <<"Create a new bridge">>,
'requestBody' => emqx_mgmt_util:schema(ReqSchema), requestBody => emqx_dashboard_swagger:schema_with_examples(
emqx_bridge_schema:post_request(),
bridge_info_examples(post)),
responses => #{ responses => #{
<<"201">> => emqx_mgmt_util:schema(RespSchema, <<"Bridge created">>), 201 => get_response_body_schema(),
<<"400">> => emqx_mgmt_util:error_schema(<<"Create bridge failed">>, 400 => error_schema('BAD_ARG', "Create bridge failed")
['UPDATE_FAILED'])
} }
} }
}, };
{"/bridges/", Metadata, list_create_bridges_in_cluster}.
crud_bridges_apis() -> schema("/bridges/:id") ->
ReqSchema = req_schema(), #{
RespSchema = resp_schema(), operationId => '/bridges/:id',
Metadata = #{
get => #{ get => #{
tags => [<<"bridges">>],
summary => <<"Get Bridge">>,
description => <<"Get a bridge by Id">>, description => <<"Get a bridge by Id">>,
parameters => [param_path_id()], parameters => [param_path_id()],
responses => #{ responses => #{
<<"200">> => emqx_mgmt_util:array_schema(RespSchema, 200 => get_response_body_schema(),
<<"The details of the bridge">>), 404 => error_schema('NOT_FOUND', "Bridge not found")
<<"404">> => emqx_mgmt_util:error_schema(<<"Bridge not found">>, ['NOT_FOUND'])
} }
}, },
put => #{ put => #{
tags => [<<"bridges">>],
summary => <<"Update Bridge">>,
description => <<"Update a bridge">>, description => <<"Update a bridge">>,
parameters => [param_path_id()], parameters => [param_path_id()],
'requestBody' => emqx_mgmt_util:schema(ReqSchema), requestBody => emqx_dashboard_swagger:schema_with_examples(
emqx_bridge_schema:put_request(),
bridge_info_examples(put)),
responses => #{ responses => #{
<<"200">> => emqx_mgmt_util:array_schema(RespSchema, <<"Bridge updated">>), 200 => get_response_body_schema(),
<<"400">> => emqx_mgmt_util:error_schema(<<"Update bridge failed">>, 400 => error_schema('BAD_ARG', "Update bridge failed")
['UPDATE_FAILED'])
} }
}, },
delete => #{ delete => #{
tags => [<<"bridges">>],
summary => <<"Delete Bridge">>,
description => <<"Delete a bridge">>, description => <<"Delete a bridge">>,
parameters => [param_path_id()], parameters => [param_path_id()],
responses => #{ responses => #{
<<"204">> => emqx_mgmt_util:schema(<<"Bridge deleted">>), 204 => <<"Bridge deleted">>
<<"404">> => emqx_mgmt_util:error_schema(<<"Bridge not found">>, ['NOT_FOUND'])
} }
} }
}, };
{"/bridges/:id", Metadata, crud_bridges_in_cluster}.
operation_apis() -> schema("/nodes/:node/bridges/:id/operation/:operation") ->
Metadata = #{ #{
operationId => '/nodes/:node/bridges/:id/operation/:operation',
post => #{ post => #{
tags => [<<"bridges">>],
summary => <<"Start/Stop/Restart Bridge">>,
description => <<"Start/Stop/Restart bridges on a specific node">>, description => <<"Start/Stop/Restart bridges on a specific node">>,
parameters => [ parameters => [
param_path_node(), param_path_node(),
param_path_id(), param_path_id(),
param_path_operation()], param_path_operation()
],
responses => #{ responses => #{
<<"500">> => emqx_mgmt_util:error_schema(<<"Operation Failed">>, 500 => error_schema('INTERNAL_ERROR', "Operation Failed"),
['INTERNAL_ERROR']), 200 => <<"Operation success">>
<<"200">> => emqx_mgmt_util:schema(<<"Operation success">>)}}}, }
{"/nodes/:node/bridges/:id/operation/:operation", Metadata, manage_bridges}. }
param_path_node() ->
#{
name => node,
in => path,
schema => #{type => string},
required => true,
example => node()
}. }.
param_path_id() -> '/bridges'(post, #{body := #{<<"type">> := BridgeType} = Conf}) ->
#{ BridgeName = maps:get(<<"name">>, Conf, emqx_misc:gen_id()),
name => id, case emqx_bridge:lookup(BridgeType, BridgeName) of
in => path, {ok, _} -> {400, #{code => 'ALREADY_EXISTS', message => <<"bridge already exists">>}};
schema => #{type => string}, {error, not_found} ->
required => true case ensure_bridge_created(BridgeType, BridgeName, Conf) of
}. ok -> lookup_from_all_nodes(BridgeType, BridgeName, 201);
{error, Error} -> {400, Error}
param_path_operation()-> end
#{ end;
name => operation, '/bridges'(get, _Params) ->
in => path,
required => true,
schema => #{
type => string,
enum => [start, stop, restart]},
example => restart
}.
list_create_bridges_in_cluster(post, #{body := #{<<"id">> := Id} = Conf}) ->
?TRY_PARSE_ID(Id,
case emqx_bridge:lookup(BridgeType, BridgeName) of
{ok, _} -> {400, #{code => 'ALREADY_EXISTS', message => <<"bridge already exists">>}};
{error, not_found} ->
case ensure_bridge(BridgeType, BridgeName, maps:remove(<<"id">>, Conf)) of
ok -> lookup_from_all_nodes(Id, BridgeType, BridgeName, 201);
{error, Error} -> {400, Error}
end
end);
list_create_bridges_in_cluster(get, _Params) ->
{200, zip_bridges([list_local_bridges(Node) || Node <- mria_mnesia:running_nodes()])}. {200, zip_bridges([list_local_bridges(Node) || Node <- mria_mnesia:running_nodes()])}.
list_local_bridges(Node) when Node =:= node() -> list_local_bridges(Node) when Node =:= node() ->
@ -238,22 +284,22 @@ list_local_bridges(Node) when Node =:= node() ->
list_local_bridges(Node) -> list_local_bridges(Node) ->
rpc_call(Node, list_local_bridges, [Node]). rpc_call(Node, list_local_bridges, [Node]).
crud_bridges_in_cluster(get, #{bindings := #{id := Id}}) -> '/bridges/:id'(get, #{bindings := #{id := Id}}) ->
?TRY_PARSE_ID(Id, lookup_from_all_nodes(Id, BridgeType, BridgeName, 200)); ?TRY_PARSE_ID(Id, lookup_from_all_nodes(BridgeType, BridgeName, 200));
crud_bridges_in_cluster(put, #{bindings := #{id := Id}, body := Conf}) -> '/bridges/:id'(put, #{bindings := #{id := Id}, body := Conf}) ->
?TRY_PARSE_ID(Id, ?TRY_PARSE_ID(Id,
case emqx_bridge:lookup(BridgeType, BridgeName) of case emqx_bridge:lookup(BridgeType, BridgeName) of
{ok, _} -> {ok, _} ->
case ensure_bridge(BridgeType, BridgeName, Conf) of case ensure_bridge_created(BridgeType, BridgeName, Conf) of
ok -> lookup_from_all_nodes(Id, BridgeType, BridgeName, 200); ok -> lookup_from_all_nodes(BridgeType, BridgeName, 200);
{error, Error} -> {400, Error} {error, Error} -> {400, Error}
end; end;
{error, not_found} -> {error, not_found} ->
{404, #{code => 'NOT_FOUND', message => <<"bridge not found">>}} {404, #{code => 'NOT_FOUND', message => <<"bridge not found">>}}
end); end);
crud_bridges_in_cluster(delete, #{bindings := #{id := Id}}) -> '/bridges/:id'(delete, #{bindings := #{id := Id}}) ->
?TRY_PARSE_ID(Id, ?TRY_PARSE_ID(Id,
case emqx_conf:remove(emqx_bridge:config_key_path() ++ [BridgeType, BridgeName], case emqx_conf:remove(emqx_bridge:config_key_path() ++ [BridgeType, BridgeName],
#{override_to => cluster}) of #{override_to => cluster}) of
@ -262,12 +308,12 @@ crud_bridges_in_cluster(delete, #{bindings := #{id := Id}}) ->
{500, #{code => 102, message => emqx_resource_api:stringify(Reason)}} {500, #{code => 102, message => emqx_resource_api:stringify(Reason)}}
end). end).
lookup_from_all_nodes(Id, BridgeType, BridgeName, SuccCode) -> lookup_from_all_nodes(BridgeType, BridgeName, SuccCode) ->
case rpc_multicall(lookup_from_local_node, [BridgeType, BridgeName]) of case rpc_multicall(lookup_from_local_node, [BridgeType, BridgeName]) of
{ok, [{ok, _} | _] = Results} -> {ok, [{ok, _} | _] = Results} ->
{SuccCode, format_bridge_info([R || {ok, R} <- Results])}; {SuccCode, format_bridge_info([R || {ok, R} <- Results])};
{ok, [{error, not_found} | _]} -> {ok, [{error, not_found} | _]} ->
{404, error_msg('NOT_FOUND', <<"not_found: ", Id/binary>>)}; {404, error_msg('NOT_FOUND', <<"not_found">>)};
{error, ErrL} -> {error, ErrL} ->
{500, error_msg('UNKNOWN_ERROR', ErrL)} {500, error_msg('UNKNOWN_ERROR', ErrL)}
end. end.
@ -278,7 +324,8 @@ lookup_from_local_node(BridgeType, BridgeName) ->
Error -> Error Error -> Error
end. end.
manage_bridges(post, #{bindings := #{node := Node, id := Id, operation := Op}}) -> '/nodes/:node/bridges/:id/operation/:operation'(post, #{bindings :=
#{node := Node, id := Id, operation := Op}}) ->
OperFun = OperFun =
fun (<<"start">>) -> start; fun (<<"start">>) -> start;
(<<"stop">>) -> stop; (<<"stop">>) -> stop;
@ -292,9 +339,10 @@ manage_bridges(post, #{bindings := #{node := Node, id := Id, operation := Op}})
{500, #{code => 102, message => emqx_resource_api:stringify(Reason)}} {500, #{code => 102, message => emqx_resource_api:stringify(Reason)}}
end). end).
ensure_bridge(BridgeType, BridgeName, Conf) -> ensure_bridge_created(BridgeType, BridgeName, Conf) ->
case emqx_conf:update(emqx_bridge:config_key_path() ++ [BridgeType, BridgeName], Conf, Conf1 = maps:without([<<"type">>, <<"name">>], Conf),
#{override_to => cluster}) of case emqx_conf:update(emqx_bridge:config_key_path() ++ [BridgeType, BridgeName],
Conf1, #{override_to => cluster}) of
{ok, _} -> ok; {ok, _} -> ok;
{error, Reason} -> {error, Reason} ->
{error, error_msg('BAD_ARG', Reason)} {error, error_msg('BAD_ARG', Reason)}
@ -346,12 +394,14 @@ aggregate_metrics(AllMetrics) ->
end, InitMetrics, AllMetrics). end, InitMetrics, AllMetrics).
format_resp(#{id := Id, raw_config := RawConf, format_resp(#{id := Id, raw_config := RawConf,
resource_data := #{mod := Mod, status := Status, metrics := Metrics}}) -> resource_data := #{status := Status, metrics := Metrics}}) ->
{Type, Name} = emqx_bridge:parse_bridge_id(Id),
IsConnected = fun(started) -> connected; (_) -> disconnected end, IsConnected = fun(started) -> connected; (_) -> disconnected end,
RawConf#{ RawConf#{
id => Id, id => Id,
type => Type,
name => Name,
node => node(), node => node(),
bridge_type => emqx_bridge:bridge_type(Mod),
status => IsConnected(Status), status => IsConnected(Status),
metrics => Metrics metrics => Metrics
}. }.
@ -378,4 +428,7 @@ rpc_call(Node, Mod, Fun, Args) ->
error_msg(Code, Msg) when is_binary(Msg) -> error_msg(Code, Msg) when is_binary(Msg) ->
#{code => Code, message => Msg}; #{code => Code, message => Msg};
error_msg(Code, Msg) -> error_msg(Code, Msg) ->
#{code => Code, message => list_to_binary(io_lib:format("~p", [Msg]))}. #{code => Code, message => bin(io_lib:format("~p", [Msg]))}.
bin(S) when is_list(S) ->
list_to_binary(S).

View File

@ -0,0 +1,95 @@
-module(emqx_bridge_http_schema).
-include_lib("typerefl/include/types.hrl").
-import(hoconsc, [mk/2, enum/1]).
-export([roots/0, fields/1]).
%%======================================================================================
%% Hocon Schema Definitions
roots() -> [].
fields("bridge") ->
basic_config() ++
[ {url, mk(binary(),
#{ nullable => false
, desc =>"""
The URL of the HTTP Bridge.<br>
Template with variables is allowed in the path, but variables cannot be used in the scheme, host,
or port part.<br>
For example, <code> http://localhost:9901/${topic} </code> is allowed, but
<code> http://${host}:9901/message </code> or <code> http://localhost:${port}/message </code>
is not allowed.
"""
})}
, {from_local_topic, mk(binary(),
#{ desc =>"""
The MQTT topic filter to be forwarded to the HTTP server. All MQTT PUBLISH messages which topic
match the from_local_topic will be forwarded.<br>
NOTE: if this bridge is used as the output of a rule (emqx rule engine), and also from_local_topic is configured, then both the data got from the rule and the MQTT messages that matches
from_local_topic will be forwarded.
"""
})}
, {method, mk(method(),
#{ default => post
, desc =>"""
The method of the HTTP request. All the available methods are: post, put, get, delete.<br>
Template with variables is allowed.<br>
"""
})}
, {headers, mk(map(),
#{ default => #{
<<"accept">> => <<"application/json">>,
<<"cache-control">> => <<"no-cache">>,
<<"connection">> => <<"keep-alive">>,
<<"content-type">> => <<"application/json">>,
<<"keep-alive">> => <<"timeout=5">>}
, desc =>"""
The headers of the HTTP request.<br>
Template with variables is allowed.
"""
})
}
, {body, mk(binary(),
#{ default => <<"${payload}">>
, desc =>"""
The body of the HTTP request.<br>
Template with variables is allowed.
"""
})}
, {request_timeout, mk(emqx_schema:duration_ms(),
#{ default => <<"30s">>
, desc =>"""
How long will the HTTP request timeout.
"""
})}
];
fields("post") ->
[ type_field()
, name_field()
] ++ fields("bridge");
fields("put") ->
fields("bridge");
fields("get") ->
[ id_field()
] ++ fields("post").
basic_config() ->
proplists:delete(base_url, emqx_connector_http:fields(config)).
%%======================================================================================
id_field() ->
{id, mk(binary(), #{desc => "The Bridge Id", example => "http:my_http_bridge"})}.
type_field() ->
{type, mk(http, #{desc => "The Bridge Type"})}.
name_field() ->
{name, mk(binary(), #{desc => "The Bridge Name"})}.
method() ->
enum([post, put, get, delete]).

View File

@ -0,0 +1,62 @@
-module(emqx_bridge_mqtt_schema).
-include_lib("typerefl/include/types.hrl").
-import(hoconsc, [mk/2]).
-export([roots/0, fields/1]).
%%======================================================================================
%% Hocon Schema Definitions
roots() -> [].
fields("ingress") ->
[ direction(ingress, emqx_connector_mqtt_schema:ingress_desc())
, emqx_bridge_schema:connector_name()
] ++ proplists:delete(hookpoint, emqx_connector_mqtt_schema:fields("ingress"));
fields("egress") ->
[ direction(egress, emqx_connector_mqtt_schema:egress_desc())
, emqx_bridge_schema:connector_name()
] ++ emqx_connector_mqtt_schema:fields("egress");
fields("post_ingress") ->
[ type_field()
, name_field()
] ++ fields("ingress");
fields("post_egress") ->
[ type_field()
, name_field()
] ++ fields("egress");
fields("put_ingress") ->
fields("ingress");
fields("put_egress") ->
fields("egress");
fields("get_ingress") ->
[ id_field()
] ++ fields("post_ingress");
fields("get_egress") ->
[ id_field()
] ++ fields("post_egress").
%%======================================================================================
direction(Dir, Desc) ->
{direction, mk(Dir,
#{ nullable => false
, desc => "The direction of the bridge. Can be one of 'ingress' or 'egress'.<br>"
++ Desc
})}.
id_field() ->
{id, mk(binary(), #{desc => "The Bridge Id", example => "mqtt:my_mqtt_bridge"})}.
type_field() ->
{type, mk(mqtt, #{desc => "The Bridge Type"})}.
name_field() ->
{name, mk(binary(),
#{ desc => "The Bridge Name"
, example => "some_bridge_name"
})}.

View File

@ -2,123 +2,63 @@
-include_lib("typerefl/include/types.hrl"). -include_lib("typerefl/include/types.hrl").
-import(hoconsc, [mk/2, ref/2]).
-export([roots/0, fields/1]). -export([roots/0, fields/1]).
-export([ get_response/0
, put_request/0
, post_request/0
]).
-export([ connector_name/0
]).
%%====================================================================================== %%======================================================================================
%% Hocon Schema Definitions %% Hocon Schema Definitions
roots() -> [bridges]. -define(CONN_TYPES, [mqtt]).
fields(bridges) -> %%======================================================================================
[ {mqtt, %% For HTTP APIs
sc(hoconsc:map(name, hoconsc:union([ ref("ingress_mqtt_bridge") get_response() ->
, ref("egress_mqtt_bridge") http_schema("get").
])),
#{ desc => "MQTT bridges"
})}
, {http,
sc(hoconsc:map(name, ref("http_bridge")),
#{ desc => "HTTP bridges"
})}
];
fields("ingress_mqtt_bridge") ->
[ direction(ingress, emqx_connector_mqtt_schema:ingress_desc())
, connector_name()
] ++ proplists:delete(hookpoint, emqx_connector_mqtt_schema:fields("ingress"));
fields("egress_mqtt_bridge") ->
[ direction(egress, emqx_connector_mqtt_schema:egress_desc())
, connector_name()
] ++ emqx_connector_mqtt_schema:fields("egress");
fields("http_bridge") ->
basic_config_http() ++
[ {url,
sc(binary(),
#{ nullable => false
, desc =>"""
The URL of the HTTP Bridge.<br>
Template with variables is allowed in the path, but variables cannot be used in the scheme, host,
or port part.<br>
For example, <code> http://localhost:9901/${topic} </code> is allowed, but
<code> http://${host}:9901/message </code> or <code> http://localhost:${port}/message </code>
is not allowed.
"""
})}
, {from_local_topic,
sc(binary(),
#{ desc =>"""
The MQTT topic filter to be forwarded to the HTTP server. All MQTT PUBLISH messages which topic
match the from_local_topic will be forwarded.<br>
NOTE: if this bridge is used as the output of a rule (emqx rule engine), and also from_local_topic is configured, then both the data got from the rule and the MQTT messages that matches
from_local_topic will be forwarded.
"""
})}
, {method,
sc(method(),
#{ default => post
, desc =>"""
The method of the HTTP request. All the available methods are: post, put, get, delete.<br>
Template with variables is allowed.<br>
"""
})}
, {headers,
sc(map(),
#{ default => #{
<<"accept">> => <<"application/json">>,
<<"cache-control">> => <<"no-cache">>,
<<"connection">> => <<"keep-alive">>,
<<"content-type">> => <<"application/json">>,
<<"keep-alive">> => <<"timeout=5">>}
, desc =>"""
The headers of the HTTP request.<br>
Template with variables is allowed.
"""
})
}
, {body,
sc(binary(),
#{ default => <<"${payload}">>
, desc =>"""
The body of the HTTP request.<br>
Template with variables is allowed.
"""
})}
, {request_timeout,
sc(emqx_schema:duration_ms(),
#{ default => <<"30s">>
, desc =>"""
How long will the HTTP request timeout.
"""
})}
].
direction(Dir, Desc) ->
{direction,
sc(Dir,
#{ nullable => false
, desc => "The direction of the bridge. Can be one of 'ingress' or 'egress'.<br>" ++
Desc
})}.
connector_name() -> connector_name() ->
{connector, {connector,
sc(binary(), mk(binary(),
#{ nullable => false #{ nullable => false
, desc =>""" , desc =>"""
The connector name to be used for this bridge. The connector name to be used for this bridge.
Connectors are configured as 'connectors.type.name', Connectors are configured as 'connectors.{type}.{name}',
for example 'connectors.http.mybridge'. for example 'connectors.http.mybridge'.
""" """
})}. })}.
basic_config_http() -> put_request() ->
proplists:delete(base_url, emqx_connector_http:fields(config)). http_schema("put").
method() -> post_request() ->
hoconsc:enum([post, put, get, delete]). http_schema("post").
sc(Type, Meta) -> hoconsc:mk(Type, Meta). http_schema(Method) ->
Schemas = lists:flatmap(fun(Type) ->
[ref(schema_mod(Type), Method ++ "_ingress"),
ref(schema_mod(Type), Method ++ "_egress")]
end, ?CONN_TYPES),
hoconsc:union([ref(emqx_bridge_http_schema, Method)
| Schemas]).
ref(Field) -> hoconsc:ref(?MODULE, Field). %%======================================================================================
%% For config files
roots() -> [bridges].
fields(bridges) ->
[{http, mk(hoconsc:map(name, ref(emqx_bridge_http_schema, "bridge")), #{})}]
++ [{T, mk(hoconsc:map(name, hoconsc:union([
ref(schema_mod(T), "ingress"),
ref(schema_mod(T), "egress")
])), #{})} || T <- ?CONN_TYPES].
schema_mod(Type) ->
list_to_atom(lists:concat(["emqx_bridge_", Type, "_schema"])).

View File

@ -21,7 +21,9 @@
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-include_lib("common_test/include/ct.hrl"). -include_lib("common_test/include/ct.hrl").
-define(CONF_DEFAULT, <<"bridges: {}">>). -define(CONF_DEFAULT, <<"bridges: {}">>).
-define(TEST_ID, <<"http:test_bridge">>). -define(BRIDGE_TYPE, <<"http">>).
-define(BRIDGE_NAME, <<"test_bridge">>).
-define(BRIDGE_ID, <<"http:test_bridge">>).
-define(URL(PORT, PATH), list_to_binary( -define(URL(PORT, PATH), list_to_binary(
io_lib:format("http://localhost:~s/~s", io_lib:format("http://localhost:~s/~s",
[integer_to_list(PORT), PATH]))). [integer_to_list(PORT), PATH]))).
@ -134,11 +136,15 @@ t_http_crud_apis(_) ->
%% POST /bridges/ will create a bridge %% POST /bridges/ will create a bridge
URL1 = ?URL(Port, "path1"), URL1 = ?URL(Port, "path1"),
{ok, 201, Bridge} = request(post, uri(["bridges"]), {ok, 201, Bridge} = request(post, uri(["bridges"]),
?HTTP_BRIDGE(URL1)#{<<"id">> => ?TEST_ID}), ?HTTP_BRIDGE(URL1)#{
<<"type">> => ?BRIDGE_TYPE,
<<"name">> => ?BRIDGE_NAME
}),
%ct:pal("---bridge: ~p", [Bridge]), %ct:pal("---bridge: ~p", [Bridge]),
?assertMatch(#{ <<"id">> := ?TEST_ID ?assertMatch(#{ <<"id">> := ?BRIDGE_ID
, <<"bridge_type">> := <<"http">> , <<"type">> := ?BRIDGE_TYPE
, <<"name">> := ?BRIDGE_NAME
, <<"status">> := _ , <<"status">> := _
, <<"node_status">> := [_|_] , <<"node_status">> := [_|_]
, <<"metrics">> := _ , <<"metrics">> := _
@ -148,7 +154,10 @@ t_http_crud_apis(_) ->
%% create a again returns an error %% create a again returns an error
{ok, 400, RetMsg} = request(post, uri(["bridges"]), {ok, 400, RetMsg} = request(post, uri(["bridges"]),
?HTTP_BRIDGE(URL1)#{<<"id">> => ?TEST_ID}), ?HTTP_BRIDGE(URL1)#{
<<"type">> => ?BRIDGE_TYPE,
<<"name">> => ?BRIDGE_NAME
}),
?assertMatch( ?assertMatch(
#{ <<"code">> := _ #{ <<"code">> := _
, <<"message">> := <<"bridge already exists">> , <<"message">> := <<"bridge already exists">>
@ -156,10 +165,11 @@ t_http_crud_apis(_) ->
%% update the request-path of the bridge %% update the request-path of the bridge
URL2 = ?URL(Port, "path2"), URL2 = ?URL(Port, "path2"),
{ok, 200, Bridge2} = request(put, uri(["bridges", ?TEST_ID]), {ok, 200, Bridge2} = request(put, uri(["bridges", ?BRIDGE_ID]),
?HTTP_BRIDGE(URL2)), ?HTTP_BRIDGE(URL2)),
?assertMatch(#{ <<"id">> := ?TEST_ID ?assertMatch(#{ <<"id">> := ?BRIDGE_ID
, <<"bridge_type">> := <<"http">> , <<"type">> := ?BRIDGE_TYPE
, <<"name">> := ?BRIDGE_NAME
, <<"status">> := _ , <<"status">> := _
, <<"node_status">> := [_|_] , <<"node_status">> := [_|_]
, <<"metrics">> := _ , <<"metrics">> := _
@ -169,8 +179,9 @@ t_http_crud_apis(_) ->
%% list all bridges again, assert Bridge2 is in it %% list all bridges again, assert Bridge2 is in it
{ok, 200, Bridge2Str} = request(get, uri(["bridges"]), []), {ok, 200, Bridge2Str} = request(get, uri(["bridges"]), []),
?assertMatch([#{ <<"id">> := ?TEST_ID ?assertMatch([#{ <<"id">> := ?BRIDGE_ID
, <<"bridge_type">> := <<"http">> , <<"type">> := ?BRIDGE_TYPE
, <<"name">> := ?BRIDGE_NAME
, <<"status">> := _ , <<"status">> := _
, <<"node_status">> := [_|_] , <<"node_status">> := [_|_]
, <<"metrics">> := _ , <<"metrics">> := _
@ -179,9 +190,10 @@ t_http_crud_apis(_) ->
}], jsx:decode(Bridge2Str)), }], jsx:decode(Bridge2Str)),
%% get the bridge by id %% get the bridge by id
{ok, 200, Bridge3Str} = request(get, uri(["bridges", ?TEST_ID]), []), {ok, 200, Bridge3Str} = request(get, uri(["bridges", ?BRIDGE_ID]), []),
?assertMatch(#{ <<"id">> := ?TEST_ID ?assertMatch(#{ <<"id">> := ?BRIDGE_ID
, <<"bridge_type">> := <<"http">> , <<"type">> := ?BRIDGE_TYPE
, <<"name">> := ?BRIDGE_NAME
, <<"status">> := _ , <<"status">> := _
, <<"node_status">> := [_|_] , <<"node_status">> := [_|_]
, <<"metrics">> := _ , <<"metrics">> := _
@ -190,11 +202,11 @@ t_http_crud_apis(_) ->
}, jsx:decode(Bridge3Str)), }, jsx:decode(Bridge3Str)),
%% delete the bridge %% delete the bridge
{ok, 204, <<>>} = request(delete, uri(["bridges", ?TEST_ID]), []), {ok, 204, <<>>} = request(delete, uri(["bridges", ?BRIDGE_ID]), []),
{ok, 200, <<"[]">>} = request(get, uri(["bridges"]), []), {ok, 200, <<"[]">>} = request(get, uri(["bridges"]), []),
%% update a deleted bridge returns an error %% update a deleted bridge returns an error
{ok, 404, ErrMsg2} = request(put, uri(["bridges", ?TEST_ID]), {ok, 404, ErrMsg2} = request(put, uri(["bridges", ?BRIDGE_ID]),
?HTTP_BRIDGE(URL2)), ?HTTP_BRIDGE(URL2)),
?assertMatch( ?assertMatch(
#{ <<"code">> := _ #{ <<"code">> := _
@ -206,11 +218,15 @@ t_start_stop_bridges(_) ->
Port = start_http_server(fun handle_fun_200_ok/1), Port = start_http_server(fun handle_fun_200_ok/1),
URL1 = ?URL(Port, "abc"), URL1 = ?URL(Port, "abc"),
{ok, 201, Bridge} = request(post, uri(["bridges"]), {ok, 201, Bridge} = request(post, uri(["bridges"]),
?HTTP_BRIDGE(URL1)#{<<"id">> => ?TEST_ID}), ?HTTP_BRIDGE(URL1)#{
<<"type">> => ?BRIDGE_TYPE,
<<"name">> => ?BRIDGE_NAME
}),
%ct:pal("the bridge ==== ~p", [Bridge]), %ct:pal("the bridge ==== ~p", [Bridge]),
?assertMatch( ?assertMatch(
#{ <<"id">> := ?TEST_ID #{ <<"id">> := ?BRIDGE_ID
, <<"bridge_type">> := <<"http">> , <<"type">> := ?BRIDGE_TYPE
, <<"name">> := ?BRIDGE_NAME
, <<"status">> := _ , <<"status">> := _
, <<"node_status">> := [_|_] , <<"node_status">> := [_|_]
, <<"metrics">> := _ , <<"metrics">> := _
@ -219,42 +235,42 @@ t_start_stop_bridges(_) ->
}, jsx:decode(Bridge)), }, jsx:decode(Bridge)),
%% stop it %% stop it
{ok, 200, <<>>} = request(post, {ok, 200, <<>>} = request(post,
uri(["nodes", node(), "bridges", ?TEST_ID, "operation", "stop"]), uri(["nodes", node(), "bridges", ?BRIDGE_ID, "operation", "stop"]),
<<"">>), <<"">>),
{ok, 200, Bridge2} = request(get, uri(["bridges", ?TEST_ID]), []), {ok, 200, Bridge2} = request(get, uri(["bridges", ?BRIDGE_ID]), []),
?assertMatch(#{ <<"id">> := ?TEST_ID ?assertMatch(#{ <<"id">> := ?BRIDGE_ID
, <<"status">> := <<"disconnected">> , <<"status">> := <<"disconnected">>
}, jsx:decode(Bridge2)), }, jsx:decode(Bridge2)),
%% start again %% start again
{ok, 200, <<>>} = request(post, {ok, 200, <<>>} = request(post,
uri(["nodes", node(), "bridges", ?TEST_ID, "operation", "start"]), uri(["nodes", node(), "bridges", ?BRIDGE_ID, "operation", "start"]),
<<"">>), <<"">>),
{ok, 200, Bridge3} = request(get, uri(["bridges", ?TEST_ID]), []), {ok, 200, Bridge3} = request(get, uri(["bridges", ?BRIDGE_ID]), []),
?assertMatch(#{ <<"id">> := ?TEST_ID ?assertMatch(#{ <<"id">> := ?BRIDGE_ID
, <<"status">> := <<"connected">> , <<"status">> := <<"connected">>
}, jsx:decode(Bridge3)), }, jsx:decode(Bridge3)),
%% restart an already started bridge %% restart an already started bridge
{ok, 200, <<>>} = request(post, {ok, 200, <<>>} = request(post,
uri(["nodes", node(), "bridges", ?TEST_ID, "operation", "restart"]), uri(["nodes", node(), "bridges", ?BRIDGE_ID, "operation", "restart"]),
<<"">>), <<"">>),
{ok, 200, Bridge3} = request(get, uri(["bridges", ?TEST_ID]), []), {ok, 200, Bridge3} = request(get, uri(["bridges", ?BRIDGE_ID]), []),
?assertMatch(#{ <<"id">> := ?TEST_ID ?assertMatch(#{ <<"id">> := ?BRIDGE_ID
, <<"status">> := <<"connected">> , <<"status">> := <<"connected">>
}, jsx:decode(Bridge3)), }, jsx:decode(Bridge3)),
%% stop it again %% stop it again
{ok, 200, <<>>} = request(post, {ok, 200, <<>>} = request(post,
uri(["nodes", node(), "bridges", ?TEST_ID, "operation", "stop"]), uri(["nodes", node(), "bridges", ?BRIDGE_ID, "operation", "stop"]),
<<"">>), <<"">>),
%% restart a stopped bridge %% restart a stopped bridge
{ok, 200, <<>>} = request(post, {ok, 200, <<>>} = request(post,
uri(["nodes", node(), "bridges", ?TEST_ID, "operation", "restart"]), uri(["nodes", node(), "bridges", ?BRIDGE_ID, "operation", "restart"]),
<<"">>), <<"">>),
{ok, 200, Bridge4} = request(get, uri(["bridges", ?TEST_ID]), []), {ok, 200, Bridge4} = request(get, uri(["bridges", ?BRIDGE_ID]), []),
?assertMatch(#{ <<"id">> := ?TEST_ID ?assertMatch(#{ <<"id">> := ?BRIDGE_ID
, <<"status">> := <<"connected">> , <<"status">> := <<"connected">>
}, jsx:decode(Bridge4)), }, jsx:decode(Bridge4)),
%% delete the bridge %% delete the bridge
{ok, 204, <<>>} = request(delete, uri(["bridges", ?TEST_ID]), []), {ok, 204, <<>>} = request(delete, uri(["bridges", ?BRIDGE_ID]), []),
{ok, 200, <<"[]">>} = request(get, uri(["bridges"]), []). {ok, 200, <<"[]">>} = request(get, uri(["bridges"]), []).
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------

View File

@ -1,4 +1,5 @@
#connectors.mqtt.my_mqtt_connector { #connectors.mqtt.my_mqtt_connector {
# mode = cluster_shareload
# server = "127.0.0.1:1883" # server = "127.0.0.1:1883"
# proto_ver = "v4" # proto_ver = "v4"
# username = "username1" # username = "username1"
@ -8,7 +9,6 @@
# retry_interval = "30s" # retry_interval = "30s"
# max_inflight = 32 # max_inflight = 32
# reconnect_interval = "30s" # reconnect_interval = "30s"
# bridge_mode = true
# replayq { # replayq {
# dir = "{{ platform_data_dir }}/replayq/bridge_mqtt/" # dir = "{{ platform_data_dir }}/replayq/bridge_mqtt/"
# seg_bytes = "100MB" # seg_bytes = "100MB"

View File

@ -30,6 +30,8 @@
%% API callbacks %% API callbacks
-export(['/connectors_test'/2, '/connectors'/2, '/connectors/:id'/2]). -export(['/connectors_test'/2, '/connectors'/2, '/connectors/:id'/2]).
-define(CONN_TYPES, [mqtt]).
-define(TRY_PARSE_ID(ID, EXPR), -define(TRY_PARSE_ID(ID, EXPR),
try emqx_connector:parse_connector_id(Id) of try emqx_connector:parse_connector_id(Id) of
{ConnType, ConnName} -> {ConnType, ConnName} ->
@ -38,7 +40,7 @@
catch catch
error:{invalid_bridge_id, Id0} -> error:{invalid_bridge_id, Id0} ->
{400, #{code => 'INVALID_ID', message => <<"invalid_bridge_id: ", Id0/binary, {400, #{code => 'INVALID_ID', message => <<"invalid_bridge_id: ", Id0/binary,
". Bridge ID must be of format 'bridge_type:name'">>}} ". Bridge Ids must be of format {type}:{name}">>}}
end). end).
namespace() -> "connector". namespace() -> "connector".
@ -53,17 +55,71 @@ error_schema(Code, Message) ->
, {message, mk(string(), #{example => Message})} , {message, mk(string(), #{example => Message})}
]. ].
connector_info() -> put_request_body_schema() ->
hoconsc:union([ ref(emqx_connector_schema, "mqtt_connector_info") emqx_dashboard_swagger:schema_with_examples(
]). emqx_connector_schema:put_request(), connector_info_examples(put)).
connector_test_info() -> post_request_body_schema() ->
hoconsc:union([ ref(emqx_connector_schema, "mqtt_connector_test_info") emqx_dashboard_swagger:schema_with_examples(
]). emqx_connector_schema:post_request(), connector_info_examples(post)).
connector_req() -> get_response_body_schema() ->
hoconsc:union([ ref(emqx_connector_schema, "mqtt_connector") emqx_dashboard_swagger:schema_with_examples(
]). emqx_connector_schema:get_response(), connector_info_examples(get)).
connector_info_array_example(Method) ->
[Config || #{value := Config} <- maps:values(connector_info_examples(Method))].
connector_info_examples(Method) ->
lists:foldl(fun(Type, Acc) ->
SType = atom_to_list(Type),
maps:merge(Acc, #{
Type => #{
summary => bin(string:uppercase(SType) ++ " Connector"),
value => info_example(Type, Method)
}
})
end, #{}, ?CONN_TYPES).
info_example(Type, Method) ->
maps:merge(info_example_basic(Type),
method_example(Type, Method)).
method_example(Type, get) ->
SType = atom_to_list(Type),
SName = "my_" ++ SType ++ "_connector",
#{
id => bin(SType ++ ":" ++ SName),
type => bin(SType),
name => bin(SName)
};
method_example(Type, post) ->
SType = atom_to_list(Type),
SName = "my_" ++ SType ++ "_connector",
#{
type => bin(SType),
name => bin(SName)
};
method_example(_Type, put) ->
#{}.
info_example_basic(mqtt) ->
#{
mode => cluster_shareload,
server => <<"127.0.0.1:1883">>,
reconnect_interval => <<"30s">>,
proto_ver => <<"v4">>,
username => <<"foo">>,
password => <<"bar">>,
clientid => <<"foo">>,
clean_start => true,
keepalive => <<"300s">>,
retry_interval => <<"30s">>,
max_inflight => 100,
ssl => #{
enable => false
}
}.
param_path_id() -> param_path_id() ->
[{id, mk(binary(), #{in => path, example => <<"mqtt:my_mqtt_connector">>})}]. [{id, mk(binary(), #{in => path, example => <<"mqtt:my_mqtt_connector">>})}].
@ -74,9 +130,9 @@ schema("/connectors_test") ->
post => #{ post => #{
tags => [<<"connectors">>], tags => [<<"connectors">>],
description => <<"Test creating a new connector by given Id <br>" description => <<"Test creating a new connector by given Id <br>"
"The ID must be of format 'type:name'">>, "The ID must be of format '{type}:{name}'">>,
summary => <<"Test creating connector">>, summary => <<"Test creating connector">>,
requestBody => connector_test_info(), requestBody => post_request_body_schema(),
responses => #{ responses => #{
200 => <<"Test connector OK">>, 200 => <<"Test connector OK">>,
400 => error_schema('TEST_FAILED', "connector test failed") 400 => error_schema('TEST_FAILED', "connector test failed")
@ -92,17 +148,19 @@ schema("/connectors") ->
description => <<"List all connectors">>, description => <<"List all connectors">>,
summary => <<"List connectors">>, summary => <<"List connectors">>,
responses => #{ responses => #{
200 => mk(array(connector_info()), #{desc => "List of connectors"}) 200 => emqx_dashboard_swagger:schema_with_example(
array(emqx_connector_schema:get_response()),
connector_info_array_example(get))
} }
}, },
post => #{ post => #{
tags => [<<"connectors">>], tags => [<<"connectors">>],
description => <<"Create a new connector by given Id <br>" description => <<"Create a new connector by given Id <br>"
"The ID must be of format 'type:name'">>, "The ID must be of format '{type}:{name}'">>,
summary => <<"Create connector">>, summary => <<"Create connector">>,
requestBody => connector_info(), requestBody => post_request_body_schema(),
responses => #{ responses => #{
201 => connector_info(), 201 => get_response_body_schema(),
400 => error_schema('ALREADY_EXISTS', "connector already exists") 400 => error_schema('ALREADY_EXISTS', "connector already exists")
} }
} }
@ -117,7 +175,7 @@ schema("/connectors/:id") ->
summary => <<"Get connector">>, summary => <<"Get connector">>,
parameters => param_path_id(), parameters => param_path_id(),
responses => #{ responses => #{
200 => connector_info(), 200 => get_response_body_schema(),
404 => error_schema('NOT_FOUND', "Connector not found") 404 => error_schema('NOT_FOUND', "Connector not found")
} }
}, },
@ -126,9 +184,9 @@ schema("/connectors/:id") ->
description => <<"Update an existing connector by Id">>, description => <<"Update an existing connector by Id">>,
summary => <<"Update connector">>, summary => <<"Update connector">>,
parameters => param_path_id(), parameters => param_path_id(),
requestBody => connector_req(), requestBody => put_request_body_schema(),
responses => #{ responses => #{
200 => <<"Update connector successfully">>, 200 => get_response_body_schema(),
400 => error_schema('UPDATE_FAIL', "Update failed"), 400 => error_schema('UPDATE_FAIL', "Update failed"),
404 => error_schema('NOT_FOUND', "Connector not found") 404 => error_schema('NOT_FOUND', "Connector not found")
}}, }},
@ -143,8 +201,8 @@ schema("/connectors/:id") ->
}} }}
}. }.
'/connectors_test'(post, #{body := #{<<"bridge_type">> := ConnType} = Params}) -> '/connectors_test'(post, #{body := #{<<"type">> := ConnType} = Params}) ->
case emqx_connector:create_dry_run(ConnType, maps:remove(<<"bridge_type">>, Params)) of case emqx_connector:create_dry_run(ConnType, maps:remove(<<"type">>, Params)) of
ok -> {200}; ok -> {200};
{error, Error} -> {error, Error} ->
{400, error_msg('BAD_ARG', Error)} {400, error_msg('BAD_ARG', Error)}
@ -153,17 +211,20 @@ schema("/connectors/:id") ->
'/connectors'(get, _Request) -> '/connectors'(get, _Request) ->
{200, emqx_connector:list()}; {200, emqx_connector:list()};
'/connectors'(post, #{body := #{<<"id">> := Id} = Params}) -> '/connectors'(post, #{body := #{<<"type">> := ConnType} = Params}) ->
?TRY_PARSE_ID(Id, ConnName = maps:get(<<"name">>, Params, emqx_misc:gen_id()),
case emqx_connector:lookup(ConnType, ConnName) of case emqx_connector:lookup(ConnType, ConnName) of
{ok, _} -> {ok, _} ->
{400, error_msg('ALREADY_EXISTS', <<"connector already exists">>)}; {400, error_msg('ALREADY_EXISTS', <<"connector already exists">>)};
{error, not_found} -> {error, not_found} ->
case emqx_connector:update(ConnType, ConnName, maps:remove(<<"id">>, Params)) of case emqx_connector:update(ConnType, ConnName,
{ok, #{raw_config := RawConf}} -> {201, RawConf#{<<"id">> => Id}}; maps:without([<<"type">>, <<"name">>], Params)) of
{error, Error} -> {400, error_msg('BAD_ARG', Error)} {ok, #{raw_config := RawConf}} ->
end {201, RawConf#{<<"id">> =>
end). emqx_connector:connector_id(ConnType, ConnName)}};
{error, Error} -> {400, error_msg('BAD_ARG', Error)}
end
end.
'/connectors/:id'(get, #{bindings := #{id := Id}}) -> '/connectors/:id'(get, #{bindings := #{id := Id}}) ->
?TRY_PARSE_ID(Id, ?TRY_PARSE_ID(Id,
@ -200,4 +261,7 @@ schema("/connectors/:id") ->
error_msg(Code, Msg) when is_binary(Msg) -> error_msg(Code, Msg) when is_binary(Msg) ->
#{code => Code, message => Msg}; #{code => Code, message => Msg};
error_msg(Code, Msg) -> error_msg(Code, Msg) ->
#{code => Code, message => list_to_binary(io_lib:format("~p", [Msg]))}. #{code => Code, message => bin(io_lib:format("~p", [Msg]))}.
bin(S) when is_list(S) ->
list_to_binary(S).

View File

@ -40,6 +40,8 @@
-behaviour(hocon_schema). -behaviour(hocon_schema).
-import(hoconsc, [mk/2]).
-export([ roots/0 -export([ roots/0
, fields/1]). , fields/1]).
@ -49,7 +51,25 @@ roots() ->
fields("config"). fields("config").
fields("config") -> fields("config") ->
emqx_connector_mqtt_schema:fields("config"). emqx_connector_mqtt_schema:fields("config");
fields("get") ->
[{id, mk(binary(),
#{ desc => "The connector Id"
, example => <<"mqtt:my_mqtt_connector">>
})}]
++ fields("post");
fields("put") ->
emqx_connector_mqtt_schema:fields("connector");
fields("post") ->
[ {type, mk(mqtt, #{desc => "The Connector Type"})}
, {name, mk(binary(),
#{ desc => "The Connector Name"
, example => <<"my_mqtt_connector">>
})}
] ++ fields("put").
%% =================================================================== %% ===================================================================
%% supervisor APIs %% supervisor APIs
@ -100,7 +120,7 @@ on_start(InstId, Conf) ->
BasicConf = basic_config(Conf), BasicConf = basic_config(Conf),
BridgeConf = BasicConf#{ BridgeConf = BasicConf#{
name => InstanceId, name => InstanceId,
clientid => clientid(InstanceId), clientid => clientid(maps:get(clientid, Conf, InstId)),
subscriptions => make_sub_confs(maps:get(ingress, Conf, undefined)), subscriptions => make_sub_confs(maps:get(ingress, Conf, undefined)),
forwards => make_forward_confs(maps:get(egress, Conf, undefined)) forwards => make_forward_confs(maps:get(egress, Conf, undefined))
}, },
@ -162,7 +182,6 @@ basic_config(#{
server := Server, server := Server,
reconnect_interval := ReconnIntv, reconnect_interval := ReconnIntv,
proto_ver := ProtoVer, proto_ver := ProtoVer,
bridge_mode := BridgeMod,
username := User, username := User,
password := Password, password := Password,
clean_start := CleanStart, clean_start := CleanStart,
@ -177,7 +196,7 @@ basic_config(#{
server => Server, server => Server,
reconnect_interval => ReconnIntv, reconnect_interval => ReconnIntv,
proto_ver => ProtoVer, proto_ver => ProtoVer,
bridge_mode => BridgeMod, bridge_mode => true,
username => User, username => User,
password => Password, password => Password,
clean_start => CleanStart, clean_start => CleanStart,
@ -190,4 +209,4 @@ basic_config(#{
}. }.
clientid(Id) -> clientid(Id) ->
list_to_binary(lists:concat([Id, ":", node()])). iolist_to_binary([Id, ":", atom_to_list(node())]).

View File

@ -4,8 +4,33 @@
-include_lib("typerefl/include/types.hrl"). -include_lib("typerefl/include/types.hrl").
-import(hoconsc, [mk/2, ref/2]).
-export([roots/0, fields/1]). -export([roots/0, fields/1]).
-export([ get_response/0
, put_request/0
, post_request/0
]).
-define(CONN_TYPES, [mqtt]).
%%======================================================================================
%% For HTTP APIs
get_response() ->
http_schema("get").
put_request() ->
http_schema("put").
post_request() ->
http_schema("post").
http_schema(Method) ->
Schemas = [ref(schema_mod(Type), Method) || Type <- ?CONN_TYPES],
hoconsc:union(Schemas).
%%====================================================================================== %%======================================================================================
%% Hocon Schema Definitions %% Hocon Schema Definitions
@ -14,24 +39,12 @@ roots() -> ["connectors"].
fields(connectors) -> fields("connectors"); fields(connectors) -> fields("connectors");
fields("connectors") -> fields("connectors") ->
[ {mqtt, [ {mqtt,
sc(hoconsc:map(name, mk(hoconsc:map(name,
hoconsc:union([ ref("mqtt_connector") hoconsc:union([ ref(emqx_connector_mqtt_schema, "connector")
])), ])),
#{ desc => "MQTT bridges" #{ desc => "MQTT bridges"
})} })}
]; ].
fields("mqtt_connector") -> schema_mod(Type) ->
emqx_connector_mqtt_schema:fields("connector"); list_to_atom(lists:concat(["emqx_connector_", Type])).
fields("mqtt_connector_info") ->
[{id, sc(binary(), #{desc => "The connector Id"})}]
++ fields("mqtt_connector");
fields("mqtt_connector_test_info") ->
[{bridge_type, sc(mqtt, #{desc => "The Bridge Type"})}]
++ fields("mqtt_connector").
sc(Type, Meta) -> hoconsc:mk(Type, Meta).
ref(Field) -> hoconsc:ref(?MODULE, Field).

View File

@ -8,7 +8,7 @@
%% http://www.apache.org/licenses/LICENSE-2.0 %% http://www.apache.org/licenses/LICENSE-2.0
%% %%
%% Unless required by applicable law or agreed to in writing, software %% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS, %% cluster_shareload under the License is cluster_shareload on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and %% See the License for the specific language governing permissions and
%% limitations under the License. %% limitations under the License.
@ -38,7 +38,24 @@ fields("config") ->
topic_mappings(); topic_mappings();
fields("connector") -> fields("connector") ->
[ {server, [ {mode,
sc(hoconsc:enum([cluster_singleton, cluster_shareload]),
#{ default => cluster_shareload
, desc => """
The mode of the MQTT Bridge. Can be one of 'cluster_singleton' or 'cluster_shareload'<br>
- cluster_singleton: create an unique MQTT connection within the emqx cluster.<br>
In 'cluster_singleton' node, all messages toward the remote broker go through the same
MQTT connection.<br>
- cluster_shareload: create an MQTT connection on each node in the emqx cluster.<br>
In 'cluster_shareload' mode, the incomming load from the remote broker is shared by
using shared subscription.<br>
Note that the 'clientid' is suffixed by the node name, this is to avoid
clientid conflicts between different nodes. And we can only use shared subscription
topic filters for 'from_remote_topic'.
"""
})}
, {server,
sc(emqx_schema:ip_port(), sc(emqx_schema:ip_port(),
#{ default => "127.0.0.1:1883" #{ default => "127.0.0.1:1883"
, desc => "The host and port of the remote MQTT broker" , desc => "The host and port of the remote MQTT broker"
@ -49,11 +66,6 @@ fields("connector") ->
#{ default => v4 #{ default => v4
, desc => "The MQTT protocol version" , desc => "The MQTT protocol version"
})} })}
, {bridge_mode,
sc(boolean(),
#{ default => true
, desc => "The bridge mode of the MQTT protocol"
})}
, {username, , {username,
sc(binary(), sc(binary(),
#{ default => "emqx" #{ default => "emqx"
@ -66,8 +78,7 @@ fields("connector") ->
})} })}
, {clientid, , {clientid,
sc(binary(), sc(binary(),
#{ default => "emqx_${nodename}" #{ desc => "The clientid of the MQTT protocol"
, desc => "The clientid of the MQTT protocol"
})} })}
, {clean_start, , {clean_start,
sc(boolean(), sc(boolean(),

View File

@ -24,7 +24,11 @@
-define(CONF_DEFAULT, <<"connectors: {}">>). -define(CONF_DEFAULT, <<"connectors: {}">>).
-define(BRIDGE_CONF_DEFAULT, <<"bridges: {}">>). -define(BRIDGE_CONF_DEFAULT, <<"bridges: {}">>).
-define(CONNECTR_TYPE, <<"mqtt">>).
-define(CONNECTR_NAME, <<"test_connector">>).
-define(CONNECTR_ID, <<"mqtt:test_connector">>). -define(CONNECTR_ID, <<"mqtt:test_connector">>).
-define(BRIDGE_NAME_INGRESS, <<"ingress_test_bridge">>).
-define(BRIDGE_NAME_EGRESS, <<"egress_test_bridge">>).
-define(BRIDGE_ID_INGRESS, <<"mqtt:ingress_test_bridge">>). -define(BRIDGE_ID_INGRESS, <<"mqtt:ingress_test_bridge">>).
-define(BRIDGE_ID_EGRESS, <<"mqtt:egress_test_bridge">>). -define(BRIDGE_ID_EGRESS, <<"mqtt:egress_test_bridge">>).
-define(MQTT_CONNECOTR(Username), -define(MQTT_CONNECOTR(Username),
@ -63,8 +67,8 @@
-define(metrics(MATCH, SUCC, FAILED, SPEED, SPEED5M, SPEEDMAX), -define(metrics(MATCH, SUCC, FAILED, SPEED, SPEED5M, SPEEDMAX),
#{<<"matched">> := MATCH, <<"success">> := SUCC, #{<<"matched">> := MATCH, <<"success">> := SUCC,
<<"failed">> := FAILED, <<"speed">> := SPEED, <<"failed">> := FAILED, <<"rate">> := SPEED,
<<"speed_last5m">> := SPEED5M, <<"speed_max">> := SPEEDMAX}). <<"rate_last5m">> := SPEED5M, <<"rate_max">> := SPEEDMAX}).
all() -> all() ->
emqx_common_test_helpers:all(?MODULE). emqx_common_test_helpers:all(?MODULE).
@ -115,7 +119,9 @@ t_mqtt_crud_apis(_) ->
%% POST /connectors/ will create a connector %% POST /connectors/ will create a connector
User1 = <<"user1">>, User1 = <<"user1">>,
{ok, 201, Connector} = request(post, uri(["connectors"]), {ok, 201, Connector} = request(post, uri(["connectors"]),
?MQTT_CONNECOTR(User1)#{<<"id">> => ?CONNECTR_ID}), ?MQTT_CONNECOTR(User1)#{ <<"type">> => ?CONNECTR_TYPE
, <<"name">> => ?CONNECTR_NAME
}),
%ct:pal("---connector: ~p", [Connector]), %ct:pal("---connector: ~p", [Connector]),
?assertMatch(#{ <<"id">> := ?CONNECTR_ID ?assertMatch(#{ <<"id">> := ?CONNECTR_ID
@ -128,7 +134,9 @@ t_mqtt_crud_apis(_) ->
%% create a again returns an error %% create a again returns an error
{ok, 400, RetMsg} = request(post, uri(["connectors"]), {ok, 400, RetMsg} = request(post, uri(["connectors"]),
?MQTT_CONNECOTR(User1)#{<<"id">> => ?CONNECTR_ID}), ?MQTT_CONNECOTR(User1)#{ <<"type">> => ?CONNECTR_TYPE
, <<"name">> => ?CONNECTR_NAME
}),
?assertMatch( ?assertMatch(
#{ <<"code">> := _ #{ <<"code">> := _
, <<"message">> := <<"connector already exists">> , <<"message">> := <<"connector already exists">>
@ -187,7 +195,9 @@ t_mqtt_conn_bridge_ingress(_) ->
%% then we add a mqtt connector, using POST %% then we add a mqtt connector, using POST
User1 = <<"user1">>, User1 = <<"user1">>,
{ok, 201, Connector} = request(post, uri(["connectors"]), {ok, 201, Connector} = request(post, uri(["connectors"]),
?MQTT_CONNECOTR(User1)#{<<"id">> => ?CONNECTR_ID}), ?MQTT_CONNECOTR(User1)#{ <<"type">> => ?CONNECTR_TYPE
, <<"name">> => ?CONNECTR_NAME
}),
%ct:pal("---connector: ~p", [Connector]), %ct:pal("---connector: ~p", [Connector]),
?assertMatch(#{ <<"id">> := ?CONNECTR_ID ?assertMatch(#{ <<"id">> := ?CONNECTR_ID
@ -201,11 +211,14 @@ t_mqtt_conn_bridge_ingress(_) ->
%% ... and a MQTT bridge, using POST %% ... and a MQTT bridge, using POST
%% we bind this bridge to the connector created just now %% we bind this bridge to the connector created just now
{ok, 201, Bridge} = request(post, uri(["bridges"]), {ok, 201, Bridge} = request(post, uri(["bridges"]),
?MQTT_BRIDGE_INGRESS(?CONNECTR_ID)#{<<"id">> => ?BRIDGE_ID_INGRESS}), ?MQTT_BRIDGE_INGRESS(?CONNECTR_ID)#{
<<"type">> => ?CONNECTR_TYPE,
<<"name">> => ?BRIDGE_NAME_INGRESS
}),
%ct:pal("---bridge: ~p", [Bridge]), %ct:pal("---bridge: ~p", [Bridge]),
?assertMatch(#{ <<"id">> := ?BRIDGE_ID_INGRESS ?assertMatch(#{ <<"id">> := ?BRIDGE_ID_INGRESS
, <<"bridge_type">> := <<"mqtt">> , <<"type">> := <<"mqtt">>
, <<"status">> := <<"connected">> , <<"status">> := <<"connected">>
, <<"connector">> := ?CONNECTR_ID , <<"connector">> := ?CONNECTR_ID
}, jsx:decode(Bridge)), }, jsx:decode(Bridge)),
@ -250,7 +263,9 @@ t_mqtt_conn_bridge_egress(_) ->
%% then we add a mqtt connector, using POST %% then we add a mqtt connector, using POST
User1 = <<"user1">>, User1 = <<"user1">>,
{ok, 201, Connector} = request(post, uri(["connectors"]), {ok, 201, Connector} = request(post, uri(["connectors"]),
?MQTT_CONNECOTR(User1)#{<<"id">> => ?CONNECTR_ID}), ?MQTT_CONNECOTR(User1)#{ <<"type">> => ?CONNECTR_TYPE
, <<"name">> => ?CONNECTR_NAME
}),
%ct:pal("---connector: ~p", [Connector]), %ct:pal("---connector: ~p", [Connector]),
?assertMatch(#{ <<"id">> := ?CONNECTR_ID ?assertMatch(#{ <<"id">> := ?CONNECTR_ID
@ -264,11 +279,15 @@ t_mqtt_conn_bridge_egress(_) ->
%% ... and a MQTT bridge, using POST %% ... and a MQTT bridge, using POST
%% we bind this bridge to the connector created just now %% we bind this bridge to the connector created just now
{ok, 201, Bridge} = request(post, uri(["bridges"]), {ok, 201, Bridge} = request(post, uri(["bridges"]),
?MQTT_BRIDGE_EGRESS(?CONNECTR_ID)#{<<"id">> => ?BRIDGE_ID_EGRESS}), ?MQTT_BRIDGE_EGRESS(?CONNECTR_ID)#{
<<"type">> => ?CONNECTR_TYPE,
<<"name">> => ?BRIDGE_NAME_EGRESS
}),
%ct:pal("---bridge: ~p", [Bridge]), %ct:pal("---bridge: ~p", [Bridge]),
?assertMatch(#{ <<"id">> := ?BRIDGE_ID_EGRESS ?assertMatch(#{ <<"id">> := ?BRIDGE_ID_EGRESS
, <<"bridge_type">> := <<"mqtt">> , <<"type">> := ?CONNECTR_TYPE
, <<"name">> := ?BRIDGE_NAME_EGRESS
, <<"status">> := <<"connected">> , <<"status">> := <<"connected">>
, <<"connector">> := ?CONNECTR_ID , <<"connector">> := ?CONNECTR_ID
}, jsx:decode(Bridge)), }, jsx:decode(Bridge)),
@ -322,7 +341,10 @@ t_mqtt_conn_update(_) ->
%% then we add a mqtt connector, using POST %% then we add a mqtt connector, using POST
{ok, 201, Connector} = request(post, uri(["connectors"]), {ok, 201, Connector} = request(post, uri(["connectors"]),
?MQTT_CONNECOTR2(<<"127.0.0.1:1883">>)#{<<"id">> => ?CONNECTR_ID}), ?MQTT_CONNECOTR2(<<"127.0.0.1:1883">>)
#{ <<"type">> => ?CONNECTR_TYPE
, <<"name">> => ?CONNECTR_NAME
}),
%ct:pal("---connector: ~p", [Connector]), %ct:pal("---connector: ~p", [Connector]),
?assertMatch(#{ <<"id">> := ?CONNECTR_ID ?assertMatch(#{ <<"id">> := ?CONNECTR_ID
@ -332,9 +354,13 @@ t_mqtt_conn_update(_) ->
%% ... and a MQTT bridge, using POST %% ... and a MQTT bridge, using POST
%% we bind this bridge to the connector created just now %% we bind this bridge to the connector created just now
{ok, 201, Bridge} = request(post, uri(["bridges"]), {ok, 201, Bridge} = request(post, uri(["bridges"]),
?MQTT_BRIDGE_EGRESS(?CONNECTR_ID)#{<<"id">> => ?BRIDGE_ID_EGRESS}), ?MQTT_BRIDGE_EGRESS(?CONNECTR_ID)#{
<<"type">> => ?CONNECTR_TYPE,
<<"name">> => ?BRIDGE_NAME_EGRESS
}),
?assertMatch(#{ <<"id">> := ?BRIDGE_ID_EGRESS ?assertMatch(#{ <<"id">> := ?BRIDGE_ID_EGRESS
, <<"bridge_type">> := <<"mqtt">> , <<"type">> := <<"mqtt">>
, <<"name">> := ?BRIDGE_NAME_EGRESS
, <<"status">> := <<"connected">> , <<"status">> := <<"connected">>
, <<"connector">> := ?CONNECTR_ID , <<"connector">> := ?CONNECTR_ID
}, jsx:decode(Bridge)), }, jsx:decode(Bridge)),
@ -358,9 +384,15 @@ t_mqtt_conn_testing(_) ->
%% APIs for testing the connectivity %% APIs for testing the connectivity
%% then we add a mqtt connector, using POST %% then we add a mqtt connector, using POST
{ok, 200, <<>>} = request(post, uri(["connectors_test"]), {ok, 200, <<>>} = request(post, uri(["connectors_test"]),
?MQTT_CONNECOTR2(<<"127.0.0.1:1883">>)#{<<"bridge_type">> => <<"mqtt">>}), ?MQTT_CONNECOTR2(<<"127.0.0.1:1883">>)#{
<<"type">> => ?CONNECTR_TYPE,
<<"name">> => ?BRIDGE_NAME_EGRESS
}),
{ok, 400, _} = request(post, uri(["connectors_test"]), {ok, 400, _} = request(post, uri(["connectors_test"]),
?MQTT_CONNECOTR2(<<"127.0.0.1:2883">>)#{<<"bridge_type">> => <<"mqtt">>}). ?MQTT_CONNECOTR2(<<"127.0.0.1:2883">>)#{
<<"type">> => ?CONNECTR_TYPE,
<<"name">> => ?BRIDGE_NAME_EGRESS
}).
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
%% HTTP Request %% HTTP Request

View File

@ -27,7 +27,7 @@
-export([ inc/3 -export([ inc/3
, inc/4 , inc/4
, get/3 , get/3
, get_speed/2 , get_rate/2
, create_metrics/2 , create_metrics/2
, clear_metrics/2 , clear_metrics/2
]). ]).
@ -54,7 +54,7 @@
-define(SECS_5M, 300). -define(SECS_5M, 300).
-define(SAMPLING, 10). -define(SAMPLING, 10).
-else. -else.
%% Use 5 secs average speed instead of 5 mins in case of testing %% Use 5 secs average rate instead of 5 mins in case of testing
-define(SECS_5M, 5). -define(SECS_5M, 5).
-define(SAMPLING, 1). -define(SAMPLING, 1).
-endif. -endif.
@ -65,9 +65,9 @@
matched => integer(), matched => integer(),
success => integer(), success => integer(),
failed => integer(), failed => integer(),
speed => float(), rate => float(),
speed_max => float(), rate_max => float(),
speed_last5m => float() rate_last5m => float()
}. }.
-type handler_name() :: atom(). -type handler_name() :: atom().
-type metric_id() :: binary(). -type metric_id() :: binary().
@ -75,22 +75,22 @@
-define(CntrRef(Name), {?MODULE, Name}). -define(CntrRef(Name), {?MODULE, Name}).
-define(SAMPCOUNT_5M, (?SECS_5M div ?SAMPLING)). -define(SAMPCOUNT_5M, (?SECS_5M div ?SAMPLING)).
%% the speed of 'matched' %% the rate of 'matched'
-record(speed, { -record(rate, {
max = 0 :: number(), max = 0 :: number(),
current = 0 :: number(), current = 0 :: number(),
last5m = 0 :: number(), last5m = 0 :: number(),
%% metadata for calculating the avg speed %% metadata for calculating the avg rate
tick = 1 :: number(), tick = 1 :: number(),
last_v = 0 :: number(), last_v = 0 :: number(),
%% metadata for calculating the 5min avg speed %% metadata for calculating the 5min avg rate
last5m_acc = 0 :: number(), last5m_acc = 0 :: number(),
last5m_smpl = [] :: list() last5m_smpl = [] :: list()
}). }).
-record(state, { -record(state, {
metric_ids = sets:new(), metric_ids = sets:new(),
speeds :: undefined | #{metric_id() => #speed{}} rates :: undefined | #{metric_id() => #rate{}}
}). }).
%%------------------------------------------------------------------------------ %%------------------------------------------------------------------------------
@ -122,19 +122,19 @@ get(Name, Id, Metric) ->
Ref -> counters:get(Ref, metrics_idx(Metric)) Ref -> counters:get(Ref, metrics_idx(Metric))
end. end.
-spec(get_speed(handler_name(), metric_id()) -> map()). -spec(get_rate(handler_name(), metric_id()) -> map()).
get_speed(Name, Id) -> get_rate(Name, Id) ->
gen_server:call(Name, {get_speed, Id}). gen_server:call(Name, {get_rate, Id}).
-spec(get_metrics(handler_name(), metric_id()) -> metrics()). -spec(get_metrics(handler_name(), metric_id()) -> metrics()).
get_metrics(Name, Id) -> get_metrics(Name, Id) ->
#{max := Max, current := Current, last5m := Last5M} = get_speed(Name, Id), #{max := Max, current := Current, last5m := Last5M} = get_rate(Name, Id),
#{matched => get_matched(Name, Id), #{matched => get_matched(Name, Id),
success => get_success(Name, Id), success => get_success(Name, Id),
failed => get_failed(Name, Id), failed => get_failed(Name, Id),
speed => Current, rate => Current,
speed_max => Max, rate_max => Max,
speed_last5m => Last5M rate_last5m => Last5M
}. }.
-spec inc(handler_name(), metric_id(), atom()) -> ok. -spec inc(handler_name(), metric_id(), atom()) -> ok.
@ -176,35 +176,35 @@ start_link(Name) ->
init(Name) -> init(Name) ->
erlang:process_flag(trap_exit, true), erlang:process_flag(trap_exit, true),
%% the speed metrics %% the rate metrics
erlang:send_after(timer:seconds(?SAMPLING), self(), ticking), erlang:send_after(timer:seconds(?SAMPLING), self(), ticking),
persistent_term:put(?CntrRef(Name), #{}), persistent_term:put(?CntrRef(Name), #{}),
{ok, #state{}}. {ok, #state{}}.
handle_call({get_speed, _Id}, _From, State = #state{speeds = undefined}) -> handle_call({get_rate, _Id}, _From, State = #state{rates = undefined}) ->
{reply, format_speed(#speed{}), State}; {reply, format_rate(#rate{}), State};
handle_call({get_speed, Id}, _From, State = #state{speeds = Speeds}) -> handle_call({get_rate, Id}, _From, State = #state{rates = Rates}) ->
{reply, case maps:get(Id, Speeds, undefined) of {reply, case maps:get(Id, Rates, undefined) of
undefined -> format_speed(#speed{}); undefined -> format_rate(#rate{});
Speed -> format_speed(Speed) Rate -> format_rate(Rate)
end, State}; end, State};
handle_call({create_metrics, Id}, _From, handle_call({create_metrics, Id}, _From,
State = #state{metric_ids = MIDs, speeds = Speeds}) -> State = #state{metric_ids = MIDs, rates = Rates}) ->
{reply, create_counters(get_self_name(), Id), {reply, create_counters(get_self_name(), Id),
State#state{metric_ids = sets:add_element(Id, MIDs), State#state{metric_ids = sets:add_element(Id, MIDs),
speeds = case Speeds of rates = case Rates of
undefined -> #{Id => #speed{}}; undefined -> #{Id => #rate{}};
_ -> Speeds#{Id => #speed{}} _ -> Rates#{Id => #rate{}}
end}}; end}};
handle_call({delete_metrics, Id}, _From, handle_call({delete_metrics, Id}, _From,
State = #state{metric_ids = MIDs, speeds = Speeds}) -> State = #state{metric_ids = MIDs, rates = Rates}) ->
{reply, delete_counters(get_self_name(), Id), {reply, delete_counters(get_self_name(), Id),
State#state{metric_ids = sets:del_element(Id, MIDs), State#state{metric_ids = sets:del_element(Id, MIDs),
speeds = case Speeds of rates = case Rates of
undefined -> undefined; undefined -> undefined;
_ -> maps:remove(Id, Speeds) _ -> maps:remove(Id, Rates)
end}}; end}};
handle_call(_Request, _From, State) -> handle_call(_Request, _From, State) ->
@ -213,17 +213,17 @@ handle_call(_Request, _From, State) ->
handle_cast(_Msg, State) -> handle_cast(_Msg, State) ->
{noreply, State}. {noreply, State}.
handle_info(ticking, State = #state{speeds = undefined}) -> handle_info(ticking, State = #state{rates = undefined}) ->
erlang:send_after(timer:seconds(?SAMPLING), self(), ticking), erlang:send_after(timer:seconds(?SAMPLING), self(), ticking),
{noreply, State}; {noreply, State};
handle_info(ticking, State = #state{speeds = Speeds0}) -> handle_info(ticking, State = #state{rates = Rates0}) ->
Speeds = maps:map( Rates = maps:map(
fun(Id, Speed) -> fun(Id, Rate) ->
calculate_speed(get_matched(get_self_name(), Id), Speed) calculate_rate(get_matched(get_self_name(), Id), Rate)
end, Speeds0), end, Rates0),
erlang:send_after(timer:seconds(?SAMPLING), self(), ticking), erlang:send_after(timer:seconds(?SAMPLING), self(), ticking),
{noreply, State#state{speeds = Speeds}}; {noreply, State#state{rates = Rates}};
handle_info(_Info, State) -> handle_info(_Info, State) ->
{noreply, State}. {noreply, State}.
@ -261,38 +261,38 @@ get_couters_ref(Name, Id) ->
get_all_counters(Name) -> get_all_counters(Name) ->
persistent_term:get(?CntrRef(Name), #{}). persistent_term:get(?CntrRef(Name), #{}).
calculate_speed(_CurrVal, undefined) -> calculate_rate(_CurrVal, undefined) ->
undefined; undefined;
calculate_speed(CurrVal, #speed{max = MaxSpeed0, last_v = LastVal, calculate_rate(CurrVal, #rate{max = MaxRate0, last_v = LastVal,
tick = Tick, last5m_acc = AccSpeed5Min0, tick = Tick, last5m_acc = AccRate5Min0,
last5m_smpl = Last5MinSamples0}) -> last5m_smpl = Last5MinSamples0}) ->
%% calculate the current speed based on the last value of the counter %% calculate the current rate based on the last value of the counter
CurrSpeed = (CurrVal - LastVal) / ?SAMPLING, CurrRate = (CurrVal - LastVal) / ?SAMPLING,
%% calculate the max speed since the emqx startup %% calculate the max rate since the emqx startup
MaxSpeed = MaxRate =
if MaxSpeed0 >= CurrSpeed -> MaxSpeed0; if MaxRate0 >= CurrRate -> MaxRate0;
true -> CurrSpeed true -> CurrRate
end, end,
%% calculate the average speed in last 5 mins %% calculate the average rate in last 5 mins
{Last5MinSamples, Acc5Min, Last5Min} = {Last5MinSamples, Acc5Min, Last5Min} =
if Tick =< ?SAMPCOUNT_5M -> if Tick =< ?SAMPCOUNT_5M ->
Acc = AccSpeed5Min0 + CurrSpeed, Acc = AccRate5Min0 + CurrRate,
{lists:reverse([CurrSpeed | lists:reverse(Last5MinSamples0)]), {lists:reverse([CurrRate | lists:reverse(Last5MinSamples0)]),
Acc, Acc / Tick}; Acc, Acc / Tick};
true -> true ->
[FirstSpeed | Speeds] = Last5MinSamples0, [FirstRate | Rates] = Last5MinSamples0,
Acc = AccSpeed5Min0 + CurrSpeed - FirstSpeed, Acc = AccRate5Min0 + CurrRate - FirstRate,
{lists:reverse([CurrSpeed | lists:reverse(Speeds)]), {lists:reverse([CurrRate | lists:reverse(Rates)]),
Acc, Acc / ?SAMPCOUNT_5M} Acc, Acc / ?SAMPCOUNT_5M}
end, end,
#speed{max = MaxSpeed, current = CurrSpeed, last5m = Last5Min, #rate{max = MaxRate, current = CurrRate, last5m = Last5Min,
last_v = CurrVal, last5m_acc = Acc5Min, last_v = CurrVal, last5m_acc = Acc5Min,
last5m_smpl = Last5MinSamples, tick = Tick + 1}. last5m_smpl = Last5MinSamples, tick = Tick + 1}.
format_speed(#speed{max = Max, current = Current, last5m = Last5Min}) -> format_rate(#rate{max = Max, current = Current, last5m = Last5Min}) ->
#{max => Max, current => precision(Current, 2), last5m => precision(Last5Min, 2)}. #{max => Max, current => precision(Current, 2), last5m => precision(Last5Min, 2)}.
precision(Float, N) -> precision(Float, N) ->

View File

@ -24,7 +24,7 @@
all() -> all() ->
[ {group, metrics} [ {group, metrics}
, {group, speed} ]. , {group, rate} ].
suite() -> suite() ->
[{ct_hooks, [cth_surefire]}, {timetrap, {seconds, 30}}]. [{ct_hooks, [cth_surefire]}, {timetrap, {seconds, 30}}].
@ -34,8 +34,8 @@ groups() ->
[ t_rule [ t_rule
, t_no_creation_1 , t_no_creation_1
]}, ]},
{speed, [sequence], {rate, [sequence],
[ rule_speed [ rule_rate
]} ]}
]. ].
@ -74,7 +74,7 @@ t_rule(_) ->
ok = emqx_plugin_libs_metrics:clear_metrics(?NAME, <<"rule1">>), ok = emqx_plugin_libs_metrics:clear_metrics(?NAME, <<"rule1">>),
ok = emqx_plugin_libs_metrics:clear_metrics(?NAME, <<"rule2">>). ok = emqx_plugin_libs_metrics:clear_metrics(?NAME, <<"rule2">>).
rule_speed(_) -> rule_rate(_) ->
ok = emqx_plugin_libs_metrics:create_metrics(?NAME, <<"rule1">>), ok = emqx_plugin_libs_metrics:create_metrics(?NAME, <<"rule1">>),
ok = emqx_plugin_libs_metrics:create_metrics(?NAME, <<"rule:2">>), ok = emqx_plugin_libs_metrics:create_metrics(?NAME, <<"rule:2">>),
ok = emqx_plugin_libs_metrics:inc(?NAME, <<"rule1">>, 'rules.matched'), ok = emqx_plugin_libs_metrics:inc(?NAME, <<"rule1">>, 'rules.matched'),
@ -83,11 +83,11 @@ rule_speed(_) ->
?assertEqual(2, emqx_plugin_libs_metrics:get(?NAME, <<"rule1">>, 'rules.matched')), ?assertEqual(2, emqx_plugin_libs_metrics:get(?NAME, <<"rule1">>, 'rules.matched')),
ct:sleep(1000), ct:sleep(1000),
?LET(#{max := Max, current := Current}, ?LET(#{max := Max, current := Current},
emqx_plugin_libs_metrics:get_speed(?NAME, <<"rule1">>), emqx_plugin_libs_metrics:get_rate(?NAME, <<"rule1">>),
{?assert(Max =< 2), {?assert(Max =< 2),
?assert(Current =< 2)}), ?assert(Current =< 2)}),
ct:sleep(2100), ct:sleep(2100),
?LET(#{max := Max, current := Current, last5m := Last5Min}, emqx_plugin_libs_metrics:get_speed(?NAME, <<"rule1">>), ?LET(#{max := Max, current := Current, last5m := Last5Min}, emqx_plugin_libs_metrics:get_rate(?NAME, <<"rule1">>),
{?assert(Max =< 2), {?assert(Max =< 2),
?assert(Current == 0), ?assert(Current == 0),
?assert(Last5Min =< 0.67)}), ?assert(Last5Min =< 0.67)}),

View File

@ -32,13 +32,40 @@ check_params(Params, Tag) ->
roots() -> roots() ->
[ {"rule_creation", sc(ref("rule_creation"), #{desc => "Schema for creating rules"})} [ {"rule_creation", sc(ref("rule_creation"), #{desc => "Schema for creating rules"})}
, {"rule_info", sc(ref("rule_info"), #{desc => "Schema for rule info"})}
, {"rule_events", sc(ref("rule_events"), #{desc => "Schema for rule events"})}
, {"rule_test", sc(ref("rule_test"), #{desc => "Schema for testing rules"})} , {"rule_test", sc(ref("rule_test"), #{desc => "Schema for testing rules"})}
]. ].
fields("rule_creation") -> fields("rule_creation") ->
[ {"id", sc(binary(), #{desc => "The Id of the rule", nullable => false})} [ {"id", sc(binary(),
#{ desc => "The Id of the rule", nullable => false
, example => "my_rule_id"
})}
] ++ emqx_rule_engine_schema:fields("rules"); ] ++ emqx_rule_engine_schema:fields("rules");
fields("rule_info") ->
[ {"metrics", sc(ref("metrics"), #{desc => "The metrics of the rule"})}
, {"node_metrics", sc(ref("node_metrics"), #{desc => "The metrics of the rule"})}
, {"from", sc(hoconsc:array(binary()),
#{desc => "The topics of the rule", example => "t/#"})}
, {"created_at", sc(binary(),
#{ desc => "The created time of the rule"
, example => "2021-12-01T15:00:43.153+08:00"
})}
] ++ fields("rule_creation");
%% TODO: we can delete this API if the Dashboard not denpends on it
fields("rule_events") ->
ETopics = [emqx_rule_events:event_topic(E) || E <- emqx_rule_events:event_names()],
[ {"event", sc(hoconsc:enum(ETopics), #{desc => "The event topics", nullable => false})}
, {"title", sc(binary(), #{desc => "The title", example => "some title"})}
, {"description", sc(binary(), #{desc => "The description", example => "some desc"})}
, {"columns", sc(map(), #{desc => "The columns"})}
, {"test_columns", sc(map(), #{desc => "The test columns"})}
, {"sql_example", sc(binary(), #{desc => "The sql_example"})}
];
fields("rule_test") -> fields("rule_test") ->
[ {"context", sc(hoconsc:union([ ref("ctx_pub") [ {"context", sc(hoconsc:union([ ref("ctx_pub")
, ref("ctx_sub") , ref("ctx_sub")
@ -53,6 +80,18 @@ fields("rule_test") ->
, {"sql", sc(binary(), #{desc => "The SQL of the rule for testing", nullable => false})} , {"sql", sc(binary(), #{desc => "The SQL of the rule for testing", nullable => false})}
]; ];
fields("metrics") ->
[ {"matched", sc(integer(), #{desc => "How much times this rule is matched"})}
, {"rate", sc(float(), #{desc => "The rate of matched, times/second"})}
, {"rate_max", sc(float(), #{desc => "The max rate of matched, times/second"})}
, {"rate_last5m", sc(float(),
#{desc => "The average rate of matched in last 5 mins, times/second"})}
];
fields("node_metrics") ->
[ {"node", sc(binary(), #{desc => "The node name", example => "emqx@127.0.0.1"})}
] ++ fields("metrics");
fields("ctx_pub") -> fields("ctx_pub") ->
[ {"event_type", sc(message_publish, #{desc => "Event Type", nullable => false})} [ {"event_type", sc(message_publish, #{desc => "Event Type", nullable => false})}
, {"id", sc(binary(), #{desc => "Message ID"})} , {"id", sc(binary(), #{desc => "Message ID"})}

View File

@ -18,16 +18,17 @@
-include("rule_engine.hrl"). -include("rule_engine.hrl").
-include_lib("emqx/include/logger.hrl"). -include_lib("emqx/include/logger.hrl").
-include_lib("typerefl/include/types.hrl").
-behaviour(minirest_api). -behaviour(minirest_api).
-export([api_spec/0]). -import(hoconsc, [mk/2, ref/2, array/1]).
-export([ crud_rules/2 %% Swagger specs from hocon schema
, list_events/2 -export([api_spec/0, paths/0, schema/1, namespace/0]).
, crud_rules_by_id/2
, rule_test/2 %% API callbacks
]). -export(['/rule_events'/2, '/rule_test'/2, '/rules'/2, '/rules/:id'/2]).
-define(ERR_NO_RULE(ID), list_to_binary(io_lib:format("Rule ~ts Not Found", [(ID)]))). -define(ERR_NO_RULE(ID), list_to_binary(io_lib:format("Rule ~ts Not Found", [(ID)]))).
-define(ERR_BADARGS(REASON), -define(ERR_BADARGS(REASON),
@ -43,210 +44,130 @@
{400, #{code => 'BAD_ARGS', message => ?ERR_BADARGS(REASON)}} {400, #{code => 'BAD_ARGS', message => ?ERR_BADARGS(REASON)}}
end). end).
namespace() -> "rule".
api_spec() -> api_spec() ->
{ emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false}).
[ api_rules_list_create()
, api_rules_crud()
, api_rule_test()
, api_events_list()
],
[]
}.
api_rules_list_create() -> paths() -> ["/rule_events", "/rule_test", "/rules", "/rules/:id"].
Metadata = #{
error_schema(Code, Message) ->
[ {code, mk(string(), #{example => Code})}
, {message, mk(string(), #{example => Message})}
].
rule_creation_schema() ->
ref(emqx_rule_api_schema, "rule_creation").
rule_update_schema() ->
ref(emqx_rule_engine_schema, "rules").
rule_test_schema() ->
ref(emqx_rule_api_schema, "rule_test").
rule_info_schema() ->
ref(emqx_rule_api_schema, "rule_info").
schema("/rules") ->
#{
operationId => '/rules',
get => #{ get => #{
tags => [<<"rules">>],
description => <<"List all rules">>, description => <<"List all rules">>,
summary => <<"List Rules">>,
responses => #{ responses => #{
<<"200">> => 200 => mk(array(rule_info_schema()), #{desc => "List of rules"})
emqx_mgmt_util:array_schema(resp_schema(), <<"List rules successfully">>)}}, }},
post => #{ post => #{
description => <<"Create a new rule using given Id to all nodes in the cluster">>, tags => [<<"rules">>],
'requestBody' => emqx_mgmt_util:schema(post_req_schema(), <<"Rule parameters">>), description => <<"Create a new rule using given Id">>,
summary => <<"Create a Rule">>,
requestBody => rule_creation_schema(),
responses => #{ responses => #{
<<"400">> => 400 => error_schema('BAD_ARGS', "Invalid Parameters"),
emqx_mgmt_util:error_schema(<<"Invalid Parameters">>, ['BAD_ARGS']), 201 => rule_info_schema()
<<"201">> => }}
emqx_mgmt_util:schema(resp_schema(), <<"Create rule successfully">>)}} };
},
{"/rules", Metadata, crud_rules}.
api_events_list() -> schema("/rule_events") ->
Metadata = #{ #{
operationId => '/rule_events',
get => #{ get => #{
tags => [<<"rules">>],
description => <<"List all events can be used in rules">>, description => <<"List all events can be used in rules">>,
summary => <<"List Events">>,
responses => #{ responses => #{
<<"200">> => 200 => mk(ref(emqx_rule_api_schema, "rule_events"), #{})
emqx_mgmt_util:array_schema(resp_schema(), <<"List events successfully">>)}} }
}, }
{"/rule_events", Metadata, list_events}. };
api_rules_crud() -> schema("/rules/:id") ->
Metadata = #{ #{
operationId => '/rules/:id',
get => #{ get => #{
tags => [<<"rules">>],
description => <<"Get a rule by given Id">>, description => <<"Get a rule by given Id">>,
parameters => [param_path_id()], summary => <<"Get a Rule">>,
parameters => param_path_id(),
responses => #{ responses => #{
<<"404">> => 404 => error_schema('NOT_FOUND', "Rule not found"),
emqx_mgmt_util:error_schema(<<"Rule not found">>, ['NOT_FOUND']), 200 => rule_info_schema()
<<"200">> => }
emqx_mgmt_util:schema(resp_schema(), <<"Get rule successfully">>)}}, },
put => #{ put => #{
description => <<"Create or update a rule by given Id to all nodes in the cluster">>, tags => [<<"rules">>],
parameters => [param_path_id()], description => <<"Update a rule by given Id to all nodes in the cluster">>,
'requestBody' => emqx_mgmt_util:schema(put_req_schema(), <<"Rule parameters">>), summary => <<"Update a Rule">>,
parameters => param_path_id(),
requestBody => rule_update_schema(),
responses => #{ responses => #{
<<"400">> => 400 => error_schema('BAD_ARGS', "Invalid Parameters"),
emqx_mgmt_util:error_schema(<<"Invalid Parameters">>, ['BAD_ARGS']), 200 => rule_info_schema()
<<"200">> => }
emqx_mgmt_util:schema(resp_schema(), },
<<"Create or update rule successfully">>)}},
delete => #{ delete => #{
tags => [<<"rules">>],
description => <<"Delete a rule by given Id from all nodes in the cluster">>, description => <<"Delete a rule by given Id from all nodes in the cluster">>,
parameters => [param_path_id()], summary => <<"Delete a Rule">>,
parameters => param_path_id(),
responses => #{ responses => #{
<<"204">> => 204 => <<"Delete rule successfully">>
emqx_mgmt_util:schema(<<"Delete rule successfully">>)}} }
}, }
{"/rules/:id", Metadata, crud_rules_by_id}. };
api_rule_test() -> schema("/rule_test") ->
Metadata = #{ #{
operationId => '/rule_test',
post => #{ post => #{
tags => [<<"rules">>],
description => <<"Test a rule">>, description => <<"Test a rule">>,
'requestBody' => emqx_mgmt_util:schema(rule_test_req_schema(), <<"Rule parameters">>), summary => <<"Test a Rule">>,
requestBody => rule_test_schema(),
responses => #{ responses => #{
<<"400">> => 400 => error_schema('BAD_ARGS', "Invalid Parameters"),
emqx_mgmt_util:error_schema(<<"Invalid Parameters">>, ['BAD_ARGS']), 412 => error_schema('NOT_MATCH', "SQL Not Match"),
<<"412">> => 200 => <<"Rule Test Pass">>
emqx_mgmt_util:error_schema(<<"SQL Not Match">>, ['NOT_MATCH']),
<<"200">> =>
emqx_mgmt_util:schema(rule_test_resp_schema(), <<"Rule Test Pass">>)}}
},
{"/rule_test", Metadata, rule_test}.
put_req_schema() ->
#{type => object,
properties => #{
sql => #{
description => <<"The SQL">>,
type => string,
example => <<"SELECT * from \"t/1\"">>
},
enable => #{
description => <<"Enable or disable the rule">>,
type => boolean,
example => true
},
outputs => #{
description => <<"The outputs of the rule">>,
type => array,
items => #{
'oneOf' => [
#{
type => string,
example => <<"channel_id_of_my_bridge">>,
description => <<"The channel id of an emqx bridge">>
},
#{
type => object,
properties => #{
function => #{
type => string,
example => <<"console">>
}
}
}
]
} }
},
description => #{
description => <<"The description for the rule">>,
type => string,
example => <<"A simple rule that handles MQTT messages from topic \"t/1\"">>
} }
}
}. }.
post_req_schema() ->
Req = #{properties := Prop} = put_req_schema(),
Req#{properties => Prop#{
id => #{
description => <<"The Id for the rule">>,
example => <<"my_rule">>,
type => string
}
}}.
resp_schema() ->
Req = #{properties := Prop} = put_req_schema(),
Req#{properties => Prop#{
id => #{
description => <<"The Id for the rule">>,
type => string
},
created_at => #{
description => <<"The time that this rule was created, in rfc3339 format">>,
type => string,
example => <<"2021-09-18T13:57:29+08:00">>
}
}}.
rule_test_req_schema() ->
#{type => object, properties => #{
sql => #{
description => <<"The SQL">>,
type => string,
example => <<"SELECT * from \"t/1\"">>
},
context => #{
type => object,
properties => #{
event_type => #{
description => <<"Event Type">>,
type => string,
enum => [<<"message_publish">>, <<"message_acked">>, <<"message_delivered">>,
<<"message_dropped">>, <<"session_subscribed">>, <<"session_unsubscribed">>,
<<"client_connected">>, <<"client_disconnected">>],
example => <<"message_publish">>
},
clientid => #{
description => <<"The Client ID">>,
type => string,
example => <<"\"c_emqx\"">>
},
topic => #{
description => <<"The Topic">>,
type => string,
example => <<"t/1">>
}
}
}
}}.
rule_test_resp_schema() ->
#{type => object}.
param_path_id() -> param_path_id() ->
#{ [{id, mk(binary(), #{in => path, example => <<"my_rule_id">>})}].
name => id,
in => path,
schema => #{type => string},
required => true
}.
%%------------------------------------------------------------------------------ %%------------------------------------------------------------------------------
%% Rules API %% Rules API
%%------------------------------------------------------------------------------ %%------------------------------------------------------------------------------
list_events(#{}, _Params) -> '/rule_events'(get, _Params) ->
{200, emqx_rule_events:event_info()}. {200, emqx_rule_events:event_info()}.
crud_rules(get, _Params) -> '/rules'(get, _Params) ->
Records = emqx_rule_engine:get_rules_ordered_by_ts(), Records = emqx_rule_engine:get_rules_ordered_by_ts(),
{200, format_rule_resp(Records)}; {200, format_rule_resp(Records)};
crud_rules(post, #{body := #{<<"id">> := Id} = Params}) -> '/rules'(post, #{body := #{<<"id">> := Id} = Params}) ->
ConfPath = emqx_rule_engine:config_key_path() ++ [Id], ConfPath = emqx_rule_engine:config_key_path() ++ [Id],
case emqx_rule_engine:get_rule(Id) of case emqx_rule_engine:get_rule(Id) of
{ok, _Rule} -> {ok, _Rule} ->
@ -263,13 +184,13 @@ crud_rules(post, #{body := #{<<"id">> := Id} = Params}) ->
end end
end. end.
rule_test(post, #{body := Params}) -> '/rule_test'(post, #{body := Params}) ->
?CHECK_PARAMS(Params, rule_test, case emqx_rule_sqltester:test(CheckedParams) of ?CHECK_PARAMS(Params, rule_test, case emqx_rule_sqltester:test(CheckedParams) of
{ok, Result} -> {200, Result}; {ok, Result} -> {200, Result};
{error, nomatch} -> {412, #{code => 'NOT_MATCH', message => <<"SQL Not Match">>}} {error, nomatch} -> {412, #{code => 'NOT_MATCH', message => <<"SQL Not Match">>}}
end). end).
crud_rules_by_id(get, #{bindings := #{id := Id}}) -> '/rules/:id'(get, #{bindings := #{id := Id}}) ->
case emqx_rule_engine:get_rule(Id) of case emqx_rule_engine:get_rule(Id) of
{ok, Rule} -> {ok, Rule} ->
{200, format_rule_resp(Rule)}; {200, format_rule_resp(Rule)};
@ -277,7 +198,7 @@ crud_rules_by_id(get, #{bindings := #{id := Id}}) ->
{404, #{code => 'NOT_FOUND', message => <<"Rule Id Not Found">>}} {404, #{code => 'NOT_FOUND', message => <<"Rule Id Not Found">>}}
end; end;
crud_rules_by_id(put, #{bindings := #{id := Id}, body := Params}) -> '/rules/:id'(put, #{bindings := #{id := Id}, body := Params}) ->
ConfPath = emqx_rule_engine:config_key_path() ++ [Id], ConfPath = emqx_rule_engine:config_key_path() ++ [Id],
case emqx:update_config(ConfPath, maps:remove(<<"id">>, Params), #{}) of case emqx:update_config(ConfPath, maps:remove(<<"id">>, Params), #{}) of
{ok, #{post_config_update := #{emqx_rule_engine := AllRules}}} -> {ok, #{post_config_update := #{emqx_rule_engine := AllRules}}} ->
@ -289,7 +210,7 @@ crud_rules_by_id(put, #{bindings := #{id := Id}, body := Params}) ->
{400, #{code => 'BAD_ARGS', message => ?ERR_BADARGS(Reason)}} {400, #{code => 'BAD_ARGS', message => ?ERR_BADARGS(Reason)}}
end; end;
crud_rules_by_id(delete, #{bindings := #{id := Id}}) -> '/rules/:id'(delete, #{bindings := #{id := Id}}) ->
ConfPath = emqx_rule_engine:config_key_path() ++ [Id], ConfPath = emqx_rule_engine:config_key_path() ++ [Id],
case emqx:remove_config(ConfPath, #{}) of case emqx:remove_config(ConfPath, #{}) of
{ok, _} -> {204}; {ok, _} -> {204};
@ -315,11 +236,13 @@ format_rule_resp(#{ id := Id, created_at := CreatedAt,
sql := SQL, sql := SQL,
enabled := Enabled, enabled := Enabled,
description := Descr}) -> description := Descr}) ->
NodeMetrics = get_rule_metrics(Id),
#{id => Id, #{id => Id,
from => Topics, from => Topics,
outputs => format_output(Output), outputs => format_output(Output),
sql => SQL, sql => SQL,
metrics => get_rule_metrics(Id), metrics => aggregate_metrics(NodeMetrics),
node_metrics => NodeMetrics,
enabled => Enabled, enabled => Enabled,
created_at => format_datetime(CreatedAt, millisecond), created_at => format_datetime(CreatedAt, millisecond),
description => Descr description => Descr
@ -339,19 +262,28 @@ do_format_output(BridgeChannelId) when is_binary(BridgeChannelId) ->
get_rule_metrics(Id) -> get_rule_metrics(Id) ->
Format = fun (Node, #{matched := Matched, Format = fun (Node, #{matched := Matched,
speed := Current, rate := Current,
speed_max := Max, rate_max := Max,
speed_last5m := Last5M rate_last5m := Last5M
}) -> }) ->
#{ matched => Matched #{ matched => Matched
, speed => Current , rate => Current
, speed_max => Max , rate_max => Max
, speed_last5m => Last5M , rate_last5m => Last5M
, node => Node , node => Node
} }
end, end,
[Format(Node, rpc:call(Node, emqx_plugin_libs_metrics, get_metrics, [rule_metrics, Id])) [Format(Node, rpc:call(Node, emqx_plugin_libs_metrics, get_metrics, [rule_metrics, Id]))
|| Node <- mria_mnesia:running_nodes()]. || Node <- mria_mnesia:running_nodes()].
aggregate_metrics(AllMetrics) ->
InitMetrics = #{matched => 0, rate => 0, rate_max => 0, rate_last5m => 0},
lists:foldl(fun
(#{matched := Match1, rate := Rate1, rate_max := RateMax1, rate_last5m := Rate5m1},
#{matched := Match0, rate := Rate0, rate_max := RateMax0, rate_last5m := Rate5m0}) ->
#{matched => Match1 + Match0, rate => Rate1 + Rate0,
rate_max => RateMax1 + RateMax0, rate_last5m => Rate5m1 + Rate5m0}
end, InitMetrics, AllMetrics).
get_one_rule(AllRules, Id) -> get_one_rule(AllRules, Id) ->
[R || R = #{id := Id0} <- AllRules, Id0 == Id]. [R || R = #{id := Id0} <- AllRules, Id0 == Id].

View File

@ -44,19 +44,17 @@ fields("rules") ->
SQL query to transform the messages.<br> SQL query to transform the messages.<br>
Example: <code>SELECT * FROM \"test/topic\" WHERE payload.x = 1</code><br> Example: <code>SELECT * FROM \"test/topic\" WHERE payload.x = 1</code><br>
""" """
, example => "SELECT * FROM \"test/topic\" WHERE payload.x = 1"
, nullable => false , nullable => false
, validator => fun ?MODULE:validate_sql/1})} , validator => fun ?MODULE:validate_sql/1
, {"outputs", sc(hoconsc:array(hoconsc:union( })}
[ binary() , {"outputs", sc(hoconsc:array(hoconsc:union(outputs())),
, ref("builtin_output_republish")
, ref("builtin_output_console")
])),
#{ desc => """ #{ desc => """
A list of outputs of the rule.<br> A list of outputs of the rule.<br>
An output can be a string that refers to the channel Id of a emqx bridge, or a object An output can be a string that refers to the channel Id of a emqx bridge, or a object
that refers to a function.<br> that refers to a function.<br>
There a some built-in functions like \"republish\" and \"console\", and we also support user There a some built-in functions like \"republish\" and \"console\", and we also support user
provided functions like \"ModuleName:FunctionName\".<br> provided functions in the format: \"{module}:{function}\".<br>
The outputs in the list is executed one by one in order. The outputs in the list is executed one by one in order.
This means that if one of the output is executing slowly, all of the outputs comes after it will not This means that if one of the output is executing slowly, all of the outputs comes after it will not
be executed until it returns.<br> be executed until it returns.<br>
@ -66,9 +64,19 @@ If there's any error when running an output, there will be an error message, and
counter of the function output or the bridge channel will increase. counter of the function output or the bridge channel will increase.
""" """
, default => [] , default => []
, example => [
<<"http:my_http_bridge">>,
#{function => republish, args => #{
topic => <<"t/1">>, payload => <<"${payload}">>}},
#{function => console}
]
})} })}
, {"enable", sc(boolean(), #{desc => "Enable or disable the rule", default => true})} , {"enable", sc(boolean(), #{desc => "Enable or disable the rule", default => true})}
, {"description", sc(binary(), #{desc => "The description of the rule", default => <<>>})} , {"description", sc(binary(),
#{ desc => "The description of the rule"
, example => "Some description"
, default => <<>>
})}
]; ];
fields("builtin_output_republish") -> fields("builtin_output_republish") ->
@ -106,6 +114,27 @@ fields("builtin_output_console") ->
% default => #{}})} % default => #{}})}
]; ];
fields("user_provided_function") ->
[ {function, sc(binary(),
#{ desc => """
The user provided function. Should be in the format: '{module}:{function}'.<br>
Where the <module> is the erlang callback module and the {function} is the erlang function.<br>
To write your own function, checkout the function <code>console</code> and
<code>republish</code> in the source file:
<code>apps/emqx_rule_engine/src/emqx_rule_outputs.erl</code> as an example.
"""
, example => "module:function"
})}
, {args, sc(map(),
#{ desc => """
The args will be passed as the 3rd argument to module:function/3,
checkout the function <code>console</code> and <code>republish</code> in the source file:
<code>apps/emqx_rule_engine/src/emqx_rule_outputs.erl</code> as an example.
"""
, default => #{}
})}
];
fields("republish_args") -> fields("republish_args") ->
[ {topic, sc(binary(), [ {topic, sc(binary(),
#{ desc =>""" #{ desc =>"""
@ -113,8 +142,9 @@ The target topic of message to be re-published.<br>
Template with variables is allowed, see description of the 'republish_args'. Template with variables is allowed, see description of the 'republish_args'.
""" """
, nullable => false , nullable => false
, example => <<"a/1">>
})} })}
, {qos, sc(binary(), , {qos, sc(qos(),
#{ desc => """ #{ desc => """
The qos of the message to be re-published. The qos of the message to be re-published.
Template with with variables is allowed, see description of the 'republish_args.<br> Template with with variables is allowed, see description of the 'republish_args.<br>
@ -122,8 +152,9 @@ Defaults to ${qos}. If variable ${qos} is not found from the selected result of
0 is used. 0 is used.
""" """
, default => <<"${qos}">> , default => <<"${qos}">>
, example => <<"${qos}">>
})} })}
, {retain, sc(binary(), , {retain, sc(hoconsc:union([binary(), boolean()]),
#{ desc => """ #{ desc => """
The retain flag of the message to be re-published. The retain flag of the message to be re-published.
Template with with variables is allowed, see description of the 'republish_args.<br> Template with with variables is allowed, see description of the 'republish_args.<br>
@ -131,6 +162,7 @@ Defaults to ${retain}. If variable ${retain} is not found from the selected resu
of the rule, false is used. of the rule, false is used.
""" """
, default => <<"${retain}">> , default => <<"${retain}">>
, example => <<"${retain}">>
})} })}
, {payload, sc(binary(), , {payload, sc(binary(),
#{ desc => """ #{ desc => """
@ -140,9 +172,20 @@ Defaults to ${payload}. If variable ${payload} is not found from the selected re
of the rule, then the string \"undefined\" is used. of the rule, then the string \"undefined\" is used.
""" """
, default => <<"${payload}">> , default => <<"${payload}">>
, example => <<"${payload}">>
})} })}
]. ].
outputs() ->
[ binary()
, ref("builtin_output_republish")
, ref("builtin_output_console")
, ref("user_provided_function")
].
qos() ->
hoconsc:union([typerefl:integer(0), typerefl:integer(1), typerefl:integer(2), binary()]).
validate_sql(Sql) -> validate_sql(Sql) ->
case emqx_rule_sqlparser:parse(Sql) of case emqx_rule_sqlparser:parse(Sql) of
{ok, _Result} -> ok; {ok, _Result} -> ok;

View File

@ -25,7 +25,9 @@
, load/1 , load/1
, unload/0 , unload/0
, unload/1 , unload/1
, event_names/0
, event_name/1 , event_name/1
, event_topic/1
, eventmsg_publish/1 , eventmsg_publish/1
]). ]).
@ -45,17 +47,6 @@
, columns_with_exam/1 , columns_with_exam/1
]). ]).
-define(SUPPORTED_HOOK,
[ 'client.connected'
, 'client.disconnected'
, 'session.subscribed'
, 'session.unsubscribed'
, 'message.publish'
, 'message.delivered'
, 'message.acked'
, 'message.dropped'
]).
-ifdef(TEST). -ifdef(TEST).
-export([ reason/1 -export([ reason/1
, hook_fun/1 , hook_fun/1
@ -63,6 +54,17 @@
]). ]).
-endif. -endif.
event_names() ->
[ 'client.connected'
, 'client.disconnected'
, 'session.subscribed'
, 'session.unsubscribed'
, 'message.publish'
, 'message.delivered'
, 'message.acked'
, 'message.dropped'
].
reload() -> reload() ->
lists:foreach(fun(Rule) -> lists:foreach(fun(Rule) ->
ok = emqx_rule_engine:load_hooks_for_rule(Rule) ok = emqx_rule_engine:load_hooks_for_rule(Rule)
@ -78,7 +80,7 @@ load(Topic) ->
unload() -> unload() ->
lists:foreach(fun(HookPoint) -> lists:foreach(fun(HookPoint) ->
emqx_hooks:del(HookPoint, {?MODULE, hook_fun(HookPoint)}) emqx_hooks:del(HookPoint, {?MODULE, hook_fun(HookPoint)})
end, ?SUPPORTED_HOOK). end, event_names()).
unload(Topic) -> unload(Topic) ->
HookPoint = event_name(Topic), HookPoint = event_name(Topic),

View File

@ -247,9 +247,9 @@ handle_output(OutId, Selected, Envs) ->
}) })
end. end.
do_handle_output(ChannelId, Selected, _Envs) when is_binary(ChannelId) -> do_handle_output(BridgeId, Selected, _Envs) when is_binary(BridgeId) ->
?SLOG(debug, #{msg => "output to bridge", channel_id => ChannelId}), ?SLOG(debug, #{msg => "output to bridge", bridge_id => BridgeId}),
emqx_bridge:send_message(ChannelId, Selected); emqx_bridge:send_message(BridgeId, Selected);
do_handle_output(#{mod := Mod, func := Func, args := Args}, Selected, Envs) -> do_handle_output(#{mod := Mod, func := Func, args := Args}, Selected, Envs) ->
Mod:Func(Selected, Envs, Args). Mod:Func(Selected, Envs, Args).

View File

@ -36,34 +36,34 @@ t_crud_rule_api(_Config) ->
<<"outputs">> => [#{<<"function">> => <<"console">>}], <<"outputs">> => [#{<<"function">> => <<"console">>}],
<<"sql">> => <<"SELECT * from \"t/1\"">> <<"sql">> => <<"SELECT * from \"t/1\"">>
}, },
{201, Rule} = emqx_rule_engine_api:crud_rules(post, #{body => Params0}), {201, Rule} = emqx_rule_engine_api:'/rules'(post, #{body => Params0}),
%% if we post again with the same params, it return with 400 "rule id already exists" %% if we post again with the same params, it return with 400 "rule id already exists"
?assertMatch({400, #{code := _, message := _Message}}, ?assertMatch({400, #{code := _, message := _Message}},
emqx_rule_engine_api:crud_rules(post, #{body => Params0})), emqx_rule_engine_api:'/rules'(post, #{body => Params0})),
?assertEqual(RuleID, maps:get(id, Rule)), ?assertEqual(RuleID, maps:get(id, Rule)),
{200, Rules} = emqx_rule_engine_api:crud_rules(get, #{}), {200, Rules} = emqx_rule_engine_api:'/rules'(get, #{}),
ct:pal("RList : ~p", [Rules]), ct:pal("RList : ~p", [Rules]),
?assert(length(Rules) > 0), ?assert(length(Rules) > 0),
{200, Rule1} = emqx_rule_engine_api:crud_rules_by_id(get, #{bindings => #{id => RuleID}}), {200, Rule1} = emqx_rule_engine_api:'/rules/:id'(get, #{bindings => #{id => RuleID}}),
ct:pal("RShow : ~p", [Rule1]), ct:pal("RShow : ~p", [Rule1]),
?assertEqual(Rule, Rule1), ?assertEqual(Rule, Rule1),
{200, Rule2} = emqx_rule_engine_api:crud_rules_by_id(put, #{ {200, Rule2} = emqx_rule_engine_api:'/rules/:id'(put, #{
bindings => #{id => RuleID}, bindings => #{id => RuleID},
body => Params0#{<<"sql">> => <<"select * from \"t/b\"">>} body => Params0#{<<"sql">> => <<"select * from \"t/b\"">>}
}), }),
{200, Rule3} = emqx_rule_engine_api:crud_rules_by_id(get, #{bindings => #{id => RuleID}}), {200, Rule3} = emqx_rule_engine_api:'/rules/:id'(get, #{bindings => #{id => RuleID}}),
%ct:pal("RShow : ~p", [Rule3]), %ct:pal("RShow : ~p", [Rule3]),
?assertEqual(Rule3, Rule2), ?assertEqual(Rule3, Rule2),
?assertEqual(<<"select * from \"t/b\"">>, maps:get(sql, Rule3)), ?assertEqual(<<"select * from \"t/b\"">>, maps:get(sql, Rule3)),
?assertMatch({204}, emqx_rule_engine_api:crud_rules_by_id(delete, ?assertMatch({204}, emqx_rule_engine_api:'/rules/:id'(delete,
#{bindings => #{id => RuleID}})), #{bindings => #{id => RuleID}})),
%ct:pal("Show After Deleted: ~p", [NotFound]), %ct:pal("Show After Deleted: ~p", [NotFound]),
?assertMatch({404, #{code := _, message := _Message}}, ?assertMatch({404, #{code := _, message := _Message}},
emqx_rule_engine_api:crud_rules_by_id(get, #{bindings => #{id => RuleID}})), emqx_rule_engine_api:'/rules/:id'(get, #{bindings => #{id => RuleID}})),
ok. ok.