emqx/apps/emqx_gcp_device/src/emqx_gcp_device_api.erl

457 lines
15 KiB
Erlang

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