|
|
@@ -0,0 +1,456 @@
|
|
|
+%%--------------------------------------------------------------------
|
|
|
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
|
|
|
+%%--------------------------------------------------------------------
|
|
|
+
|
|
|
+-module(emqx_gcp_device_api).
|
|
|
+
|
|
|
+-behaviour(minirest_api).
|
|
|
+
|
|
|
+-include_lib("hocon/include/hoconsc.hrl").
|
|
|
+-include_lib("stdlib/include/qlc.hrl").
|
|
|
+-include_lib("stdlib/include/ms_transform.hrl").
|
|
|
+-include_lib("emqx/include/logger.hrl").
|
|
|
+
|
|
|
+-define(TAGS, [<<"GCP Devices">>]).
|
|
|
+-define(TAB, emqx_gcp_device).
|
|
|
+-define(FORMAT_FUN, {emqx_gcp_device, format_device}).
|
|
|
+
|
|
|
+-export([import_devices/1]).
|
|
|
+-export([get_device/1, update_device/1, remove_device/1]).
|
|
|
+
|
|
|
+-export([
|
|
|
+ api_spec/0,
|
|
|
+ paths/0,
|
|
|
+ schema/1,
|
|
|
+ fields/1
|
|
|
+]).
|
|
|
+
|
|
|
+-export([
|
|
|
+ '/gcp_devices'/2,
|
|
|
+ '/gcp_devices/:deviceid'/2
|
|
|
+]).
|
|
|
+
|
|
|
+-type deviceid() :: emqx_gcp_device:deviceid().
|
|
|
+-type formatted_device() :: emqx_gcp_device:formatted_device().
|
|
|
+-type base64_encoded_config() :: emqx_gcp_device:encoded_config().
|
|
|
+-type imported_key() :: #{
|
|
|
+ binary() := binary() | non_neg_integer()
|
|
|
+ % #{
|
|
|
+ % <<"key">> => binary(),
|
|
|
+ % <<"key_type">> => binary(),
|
|
|
+ % <<"expires_at">> => non_neg_integer()
|
|
|
+ % }.
|
|
|
+}.
|
|
|
+-type key_fields() :: key | key_type | expires_at.
|
|
|
+-type imported_device() :: #{
|
|
|
+ binary() := deviceid() | binary() | [imported_key()] | base64_encoded_config() | boolean()
|
|
|
+ % #{
|
|
|
+ % <<"deviceid">> => deviceid(),
|
|
|
+ % <<"project">> => binary(),
|
|
|
+ % <<"location">> => binary(),
|
|
|
+ % <<"registry">> => binary(),
|
|
|
+ % <<"keys">> => [imported_key()],
|
|
|
+ % <<"config">> => base64_encoded_config(),
|
|
|
+ % <<"blocked">> => boolean(),
|
|
|
+ % }.
|
|
|
+}.
|
|
|
+-type device_fields() :: deviceid | project | location | registry | keys | config.
|
|
|
+-type checked_device_fields() :: device_fields() | key_fields().
|
|
|
+-type validated_device() :: #{checked_device_fields() := term()}.
|
|
|
+
|
|
|
+%%-------------------------------------------------------------------------------------------------
|
|
|
+%% `minirest' and `minirest_trails' API
|
|
|
+%%-------------------------------------------------------------------------------------------------
|
|
|
+
|
|
|
+api_spec() ->
|
|
|
+ emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}).
|
|
|
+
|
|
|
+paths() ->
|
|
|
+ [
|
|
|
+ "/gcp_devices",
|
|
|
+ "/gcp_devices/:deviceid"
|
|
|
+ ].
|
|
|
+
|
|
|
+schema("/gcp_devices") ->
|
|
|
+ #{
|
|
|
+ 'operationId' => '/gcp_devices',
|
|
|
+ get => #{
|
|
|
+ description => ?DESC(gcp_devices_get),
|
|
|
+ tags => ?TAGS,
|
|
|
+ parameters => [
|
|
|
+ hoconsc:ref(emqx_dashboard_swagger, page),
|
|
|
+ hoconsc:ref(emqx_dashboard_swagger, limit)
|
|
|
+ ],
|
|
|
+ responses => #{
|
|
|
+ 200 => [
|
|
|
+ {data, hoconsc:mk(hoconsc:array(hoconsc:ref(gcp_device_all_info)), #{})},
|
|
|
+ {meta, hoconsc:mk(hoconsc:ref(emqx_dashboard_swagger, meta), #{})}
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ },
|
|
|
+ post => #{
|
|
|
+ description => ?DESC(gcp_devices_post),
|
|
|
+ tags => ?TAGS,
|
|
|
+ 'requestBody' => hoconsc:mk(hoconsc:array(?R_REF(gcp_exported_device)), #{}),
|
|
|
+ responses =>
|
|
|
+ #{
|
|
|
+ 200 => hoconsc:ref(import_result),
|
|
|
+ 400 => emqx_dashboard_swagger:error_codes(
|
|
|
+ ['BAD_REQUEST'],
|
|
|
+ <<"Bad Request">>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+schema("/gcp_devices/:deviceid") ->
|
|
|
+ #{
|
|
|
+ 'operationId' => '/gcp_devices/:deviceid',
|
|
|
+ get =>
|
|
|
+ #{
|
|
|
+ description => ?DESC(gcp_device_get),
|
|
|
+ tags => ?TAGS,
|
|
|
+ parameters => [deviceid(#{in => path})],
|
|
|
+ responses =>
|
|
|
+ #{
|
|
|
+ 200 => hoconsc:mk(
|
|
|
+ hoconsc:ref(gcp_device_all_info),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(gcp_device_all_info)
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ 404 => emqx_dashboard_swagger:error_codes(
|
|
|
+ ['NOT_FOUND'],
|
|
|
+ ?DESC(gcp_device_response404)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ },
|
|
|
+ put =>
|
|
|
+ #{
|
|
|
+ description => ?DESC(gcp_device_put),
|
|
|
+ tags => ?TAGS,
|
|
|
+ parameters => [deviceid(#{in => path})],
|
|
|
+ 'requestBody' => hoconsc:ref(gcp_device),
|
|
|
+ responses =>
|
|
|
+ #{
|
|
|
+ 200 => hoconsc:mk(
|
|
|
+ hoconsc:ref(gcp_device_info),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(gcp_device_info)
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ 400 => emqx_dashboard_swagger:error_codes(
|
|
|
+ ['BAD_REQUEST'],
|
|
|
+ <<"Bad Request">>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ },
|
|
|
+ delete => #{
|
|
|
+ description => ?DESC(gcp_device_delete),
|
|
|
+ tags => ?TAGS,
|
|
|
+ parameters => [deviceid(#{in => path})],
|
|
|
+ responses => #{
|
|
|
+ 204 => <<"GCP device deleted">>
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }.
|
|
|
+
|
|
|
+fields(gcp_device) ->
|
|
|
+ [
|
|
|
+ {registry,
|
|
|
+ hoconsc:mk(
|
|
|
+ binary(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(registry),
|
|
|
+ default => <<>>,
|
|
|
+ example => <<"my-registry">>
|
|
|
+ }
|
|
|
+ )},
|
|
|
+ {project,
|
|
|
+ hoconsc:mk(
|
|
|
+ binary(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(project),
|
|
|
+ default => <<>>,
|
|
|
+ example => <<"iot-export">>
|
|
|
+ }
|
|
|
+ )},
|
|
|
+ {location,
|
|
|
+ hoconsc:mk(
|
|
|
+ binary(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(location),
|
|
|
+ default => <<>>,
|
|
|
+ example => <<"europe-west1">>
|
|
|
+ }
|
|
|
+ )},
|
|
|
+ {keys,
|
|
|
+ hoconsc:mk(
|
|
|
+ ?ARRAY(hoconsc:ref(key)),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(keys),
|
|
|
+ default => []
|
|
|
+ }
|
|
|
+ )},
|
|
|
+ {config,
|
|
|
+ hoconsc:mk(
|
|
|
+ binary(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(config),
|
|
|
+ required => true,
|
|
|
+ example => <<"bXktY29uZmln">>
|
|
|
+ }
|
|
|
+ )}
|
|
|
+ ];
|
|
|
+fields(gcp_device_info) ->
|
|
|
+ fields(deviceid) ++ fields(gcp_device);
|
|
|
+fields(gcp_device_all_info) ->
|
|
|
+ [
|
|
|
+ {created_at,
|
|
|
+ hoconsc:mk(
|
|
|
+ non_neg_integer(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(created_at),
|
|
|
+ required => true,
|
|
|
+ example => 1690484400
|
|
|
+ }
|
|
|
+ )}
|
|
|
+ ] ++ fields(gcp_device_info);
|
|
|
+fields(gcp_exported_device) ->
|
|
|
+ [
|
|
|
+ {blocked,
|
|
|
+ hoconsc:mk(
|
|
|
+ boolean(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(blocked),
|
|
|
+ required => true,
|
|
|
+ example => false
|
|
|
+ }
|
|
|
+ )}
|
|
|
+ ] ++ fields(deviceid) ++ fields(gcp_device);
|
|
|
+fields(import_result) ->
|
|
|
+ [
|
|
|
+ {errors,
|
|
|
+ hoconsc:mk(
|
|
|
+ non_neg_integer(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(imported_counter_errors),
|
|
|
+ required => true,
|
|
|
+ example => 0
|
|
|
+ }
|
|
|
+ )},
|
|
|
+ {imported,
|
|
|
+ hoconsc:mk(
|
|
|
+ non_neg_integer(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(imported_counter),
|
|
|
+ required => true,
|
|
|
+ example => 14
|
|
|
+ }
|
|
|
+ )}
|
|
|
+ ];
|
|
|
+fields(key) ->
|
|
|
+ [
|
|
|
+ {key,
|
|
|
+ hoconsc:mk(
|
|
|
+ binary(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(key),
|
|
|
+ required => true,
|
|
|
+ example => <<"<DEVICE-PUBLIC-KEY>">>
|
|
|
+ }
|
|
|
+ )},
|
|
|
+ {key_type,
|
|
|
+ hoconsc:mk(
|
|
|
+ binary(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(key_type),
|
|
|
+ required => true,
|
|
|
+ example => <<"ES256_PEM">>
|
|
|
+ }
|
|
|
+ )},
|
|
|
+ {expires_at,
|
|
|
+ hoconsc:mk(
|
|
|
+ non_neg_integer(),
|
|
|
+ #{
|
|
|
+ desc => ?DESC(expires_at),
|
|
|
+ required => true,
|
|
|
+ example => 1706738400
|
|
|
+ }
|
|
|
+ )}
|
|
|
+ ];
|
|
|
+fields(deviceid) ->
|
|
|
+ [
|
|
|
+ deviceid()
|
|
|
+ ].
|
|
|
+
|
|
|
+'/gcp_devices'(get, #{query_string := Params}) ->
|
|
|
+ Response = emqx_mgmt_api:paginate(?TAB, Params, ?FORMAT_FUN),
|
|
|
+ {200, Response};
|
|
|
+'/gcp_devices'(post, #{body := Body}) ->
|
|
|
+ import_devices(Body).
|
|
|
+
|
|
|
+'/gcp_devices/:deviceid'(get, #{bindings := #{deviceid := DeviceId}}) ->
|
|
|
+ get_device(DeviceId);
|
|
|
+'/gcp_devices/:deviceid'(put, #{bindings := #{deviceid := DeviceId}, body := Body}) ->
|
|
|
+ update_device(maps:merge(Body, #{<<"deviceid">> => DeviceId}));
|
|
|
+'/gcp_devices/:deviceid'(delete, #{bindings := #{deviceid := DeviceId}}) ->
|
|
|
+ remove_device(DeviceId).
|
|
|
+
|
|
|
+%%------------------------------------------------------------------------------
|
|
|
+%% Handlers
|
|
|
+%%------------------------------------------------------------------------------
|
|
|
+
|
|
|
+-spec import_devices([imported_device()]) ->
|
|
|
+ {200, #{imported := non_neg_integer(), errors := non_neg_integer()}}
|
|
|
+ | {400, #{message := binary()}}.
|
|
|
+import_devices(Devices) ->
|
|
|
+ case validate_devices(Devices) of
|
|
|
+ {ok, FormattedDevices} ->
|
|
|
+ {NumImported, NumErrors} = emqx_gcp_device:import_devices(FormattedDevices),
|
|
|
+ {200, #{imported => NumImported, errors => NumErrors}};
|
|
|
+ {error, Reason} ->
|
|
|
+ {400, #{message => Reason}}
|
|
|
+ end.
|
|
|
+
|
|
|
+-spec get_device(deviceid()) -> {200, formatted_device()} | {404, 'NOT_FOUND', binary()}.
|
|
|
+get_device(DeviceId) ->
|
|
|
+ case emqx_gcp_device:get_device(DeviceId) of
|
|
|
+ {ok, Device} ->
|
|
|
+ {200, Device};
|
|
|
+ not_found ->
|
|
|
+ Message = list_to_binary(io_lib:format("device not found: ~s", [DeviceId])),
|
|
|
+ {404, 'NOT_FOUND', Message}
|
|
|
+ end.
|
|
|
+
|
|
|
+-spec update_device(imported_device()) -> {200, formatted_device()} | {400, binary()}.
|
|
|
+update_device(Device) ->
|
|
|
+ case validate_device(Device) of
|
|
|
+ {ok, ValidatedDevice} ->
|
|
|
+ ok = emqx_gcp_device:put_device(ValidatedDevice),
|
|
|
+ {200, ValidatedDevice};
|
|
|
+ {error, Reason} ->
|
|
|
+ {400, Reason}
|
|
|
+ end.
|
|
|
+
|
|
|
+-spec remove_device(deviceid()) -> {204}.
|
|
|
+remove_device(DeviceId) ->
|
|
|
+ ok = emqx_gcp_device:remove_device(DeviceId),
|
|
|
+ {204}.
|
|
|
+
|
|
|
+%%------------------------------------------------------------------------------
|
|
|
+%% Internal functions
|
|
|
+%%------------------------------------------------------------------------------
|
|
|
+
|
|
|
+-define(KEY_TYPES, [<<"RSA_PEM">>, <<"RSA_X509_PEM">>, <<"ES256_PEM">>, <<"ES256_X509_PEM">>]).
|
|
|
+
|
|
|
+-spec deviceid() -> tuple().
|
|
|
+deviceid() ->
|
|
|
+ deviceid(#{}).
|
|
|
+
|
|
|
+-spec deviceid(map()) -> tuple().
|
|
|
+deviceid(Override) ->
|
|
|
+ {deviceid,
|
|
|
+ hoconsc:mk(
|
|
|
+ binary(),
|
|
|
+ maps:merge(
|
|
|
+ #{
|
|
|
+ desc => ?DESC(deviceid),
|
|
|
+ required => true,
|
|
|
+ example => <<"c2-ec-x509">>
|
|
|
+ },
|
|
|
+ Override
|
|
|
+ )
|
|
|
+ )}.
|
|
|
+
|
|
|
+-spec validate_devices([imported_device()]) -> {ok, [validated_device()]} | {error, binary()}.
|
|
|
+validate_devices(Devices) ->
|
|
|
+ validate_devices(Devices, []).
|
|
|
+
|
|
|
+-spec validate_devices([imported_device()], [validated_device()]) ->
|
|
|
+ {ok, [validated_device()]} | {error, binary()}.
|
|
|
+validate_devices([], Validated) ->
|
|
|
+ {ok, lists:reverse(Validated)};
|
|
|
+validate_devices([Device | Devices], Validated) ->
|
|
|
+ case validate_device(Device) of
|
|
|
+ {ok, ValidatedDevice} ->
|
|
|
+ validate_devices(Devices, [ValidatedDevice | Validated]);
|
|
|
+ {error, _} = Error ->
|
|
|
+ Error
|
|
|
+ end.
|
|
|
+
|
|
|
+-spec validate_device(imported_device()) -> {ok, validated_device()} | {error, binary()}.
|
|
|
+validate_device(Device) ->
|
|
|
+ validate([deviceid, project, location, registry, keys, config], Device).
|
|
|
+
|
|
|
+-spec validate([checked_device_fields()], imported_device()) ->
|
|
|
+ {ok, validated_device()} | {error, binary()}.
|
|
|
+validate(Fields, Device) ->
|
|
|
+ validate(Fields, Device, #{}).
|
|
|
+
|
|
|
+-spec validate([checked_device_fields()], imported_device(), validated_device()) ->
|
|
|
+ {ok, validated_device()} | {error, binary()}.
|
|
|
+validate([], _Device, Validated) ->
|
|
|
+ {ok, Validated};
|
|
|
+validate([key_type | Fields], #{<<"key_type">> := KeyType} = Device, Validated) ->
|
|
|
+ case lists:member(KeyType, ?KEY_TYPES) of
|
|
|
+ true ->
|
|
|
+ validate(Fields, Device, Validated#{key_type => KeyType});
|
|
|
+ false ->
|
|
|
+ {error, <<"invalid key_type">>}
|
|
|
+ end;
|
|
|
+validate([key | Fields], #{<<"key">> := Key} = Device, Validated) ->
|
|
|
+ validate(Fields, Device, Validated#{key => Key});
|
|
|
+validate([expires_at | Fields], #{<<"expires_at">> := Expire} = Device, Validated) when
|
|
|
+ is_integer(Expire)
|
|
|
+->
|
|
|
+ validate(Fields, Device, Validated#{expires_at => Expire});
|
|
|
+validate([expires_at | _Fields], #{<<"expires_at">> := _}, _Validated) ->
|
|
|
+ {error, <<"invalid expires_at">>};
|
|
|
+validate([expires_at | Fields], Device, Validated) ->
|
|
|
+ validate(Fields, Device, Validated#{expires_at => 0});
|
|
|
+validate([Field | Fields], Device, Validated) when Field =:= deviceid; Field =:= key ->
|
|
|
+ FieldBin = atom_to_binary(Field),
|
|
|
+ case maps:find(FieldBin, Device) of
|
|
|
+ {ok, Value} when is_binary(Value) ->
|
|
|
+ validate(Fields, Device, Validated#{Field => Value});
|
|
|
+ _ ->
|
|
|
+ {error, <<"invalid or missing field: ", FieldBin/binary>>}
|
|
|
+ end;
|
|
|
+validate([Field | Fields], Device, Validated) when
|
|
|
+ Field =:= project; Field =:= location; Field =:= registry; Field =:= config
|
|
|
+->
|
|
|
+ FieldBin = atom_to_binary(Field),
|
|
|
+ case maps:find(FieldBin, Device) of
|
|
|
+ {ok, Value} when is_binary(Value) ->
|
|
|
+ validate(Fields, Device, Validated#{Field => Value});
|
|
|
+ error ->
|
|
|
+ validate(Fields, Device, Validated#{Field => <<>>});
|
|
|
+ _ ->
|
|
|
+ {error, <<"invalid field: ", FieldBin/binary>>}
|
|
|
+ end;
|
|
|
+validate([keys | Fields], #{<<"keys">> := Keys} = Device, Validated) when is_list(Keys) ->
|
|
|
+ case validate_keys(Keys) of
|
|
|
+ {ok, ValidatedKeys} ->
|
|
|
+ validate(Fields, Device, Validated#{keys => ValidatedKeys});
|
|
|
+ {error, _} = Error ->
|
|
|
+ Error
|
|
|
+ end;
|
|
|
+validate([Field | _Fields], _Device, _Validated) ->
|
|
|
+ {error, <<"invalid or missing field: ", (atom_to_binary(Field))/binary>>}.
|
|
|
+
|
|
|
+-spec validate_keys([imported_key()]) ->
|
|
|
+ {ok, [validated_device()]} | {error, binary()}.
|
|
|
+validate_keys(Keys) ->
|
|
|
+ validate_keys(Keys, []).
|
|
|
+
|
|
|
+-spec validate_keys([imported_key()], [validated_device()]) ->
|
|
|
+ {ok, [validated_device()]} | {error, binary()}.
|
|
|
+validate_keys([], Validated) ->
|
|
|
+ {ok, lists:reverse(Validated)};
|
|
|
+validate_keys([Key | Keys], Validated) ->
|
|
|
+ case validate([key, key_type, expires_at], Key) of
|
|
|
+ {ok, ValidatedKey} ->
|
|
|
+ validate_keys(Keys, [ValidatedKey | Validated]);
|
|
|
+ {error, _} = Error ->
|
|
|
+ Error
|
|
|
+ end.
|