diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b2f5737f..e276b7034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Add support for device tags ([#191](https://github.com/edgehog-device-manager/edgehog/pull/191)) +- Add support for device custom attributes + ([#205](https://github.com/edgehog-device-manager/edgehog/pull/205)) ## [0.5.1] - 2022-06-01 ### Added diff --git a/backend/lib/ecto/json_variant.ex b/backend/lib/ecto/json_variant.ex new file mode 100644 index 000000000..77d4af0c7 --- /dev/null +++ b/backend/lib/ecto/json_variant.ex @@ -0,0 +1,193 @@ +# +# This file is part of Edgehog. +# +# Copyright 2022 SECO Mind Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule Ecto.JSONVariant do + use Ecto.Type + + alias __MODULE__ + + # TODO: add support for array values + @supported_types [ + :double, + :integer, + :boolean, + :longinteger, + :string, + :binaryblob, + :datetime + ] + + defstruct [:type, :value] + + @impl true + def type, do: :map + + @impl true + def cast(%{"type" => type, "value" => value}) when is_atom(type) and type in @supported_types do + do_cast(type, value) + end + + def cast(%{"type" => type, "value" => value}) when is_binary(type) do + with {:ok, type} <- type_string_to_atom(type) do + do_cast(type, value) + end + end + + def cast(%{type: type, value: value}) when is_atom(type) and type in @supported_types do + do_cast(type, value) + end + + def cast(%{type: type, value: value}) when is_binary(type) do + with {:ok, type} <- type_string_to_atom(type) do + do_cast(type, value) + end + end + + def cast(_) do + :error + end + + defp do_cast(type, value) do + with {:ok, value} <- cast_fun(type).(value) do + {:ok, struct!(__MODULE__, type: type, value: value)} + end + end + + defp cast_fun(:double), do: &Ecto.Type.cast(:float, &1) + defp cast_fun(:integer), do: &cast_integer/1 + defp cast_fun(:boolean), do: &Ecto.Type.cast(:boolean, &1) + defp cast_fun(:longinteger), do: &cast_longinteger/1 + defp cast_fun(:string), do: &cast_string/1 + defp cast_fun(:binaryblob), do: &cast_binaryblob/1 + defp cast_fun(:datetime), do: &Ecto.Type.cast(:utc_datetime_usec, &1) + + defp cast_integer(term) when is_binary(term) do + case Integer.parse(term) do + {integer, ""} when abs(integer) <= 0x7FFF_FFFF -> {:ok, integer} + _ -> :error + end + end + + defp cast_integer(term) when is_integer(term) and abs(term) <= 0x7FFF_FFFF, do: {:ok, term} + defp cast_integer(term) when is_integer(term), do: {:error, message: "is out of range"} + defp cast_integer(_), do: :error + + defp cast_longinteger(term) when is_binary(term) do + case Integer.parse(term) do + {integer, ""} when abs(integer) <= 0x7FFF_FFFF_FFFF_FFFF -> {:ok, integer} + _ -> :error + end + end + + defp cast_longinteger(term) when is_integer(term) and abs(term) <= 0x7FFF_FFFF_FFFF_FFFF do + {:ok, term} + end + + defp cast_longinteger(_), do: :error + + defp cast_string(term) when is_binary(term) do + if String.valid?(term) do + {:ok, term} + else + :error + end + end + + defp cast_string(_), do: :error + + defp cast_binaryblob(term) when is_binary(term) do + case Base.decode64(term) do + {:ok, value} -> {:ok, value} + _ -> :error + end + end + + defp cast_binaryblob(_), do: :error + + defp type_string_to_atom("double"), do: {:ok, :double} + defp type_string_to_atom("integer"), do: {:ok, :integer} + defp type_string_to_atom("boolean"), do: {:ok, :boolean} + defp type_string_to_atom("longinteger"), do: {:ok, :longinteger} + defp type_string_to_atom("string"), do: {:ok, :string} + defp type_string_to_atom("binaryblob"), do: {:ok, :binaryblob} + defp type_string_to_atom("datetime"), do: {:ok, :datetime} + defp type_string_to_atom(_), do: :error + + @impl true + def dump(%JSONVariant{type: type, value: value}) when type in @supported_types do + with {:ok, value} <- dump_fun(type).(value) do + {:ok, %{t: Atom.to_string(type), v: value}} + end + end + + def dump(_), do: :error + + defp dump_fun(:double), do: &Ecto.Type.dump(:float, &1) + defp dump_fun(:integer), do: &Ecto.Type.dump(:integer, &1) + defp dump_fun(:boolean), do: &Ecto.Type.dump(:boolean, &1) + defp dump_fun(:longinteger), do: &Ecto.Type.dump(:integer, &1) + defp dump_fun(:string), do: &Ecto.Type.dump(:string, &1) + defp dump_fun(:binaryblob), do: &dump_binaryblob/1 + defp dump_fun(:datetime), do: &dump_datetime/1 + + defp dump_binaryblob(value) do + with {:ok, binary} <- Ecto.Type.dump(:binary, value) do + {:ok, Base.encode64(binary)} + end + end + + defp dump_datetime(value) do + with {:ok, datetime} <- Ecto.Type.dump(:utc_datetime_usec, value) do + {:ok, DateTime.to_iso8601(datetime)} + end + end + + @impl true + def load(%{"t" => type_string, "v" => value}) do + with {:ok, type} <- type_string_to_atom(type_string), + {:ok, value} <- load_fun(type).(value) do + {:ok, struct!(__MODULE__, type: type, value: value)} + end + end + + def load(_), do: :error + + defp load_fun(:double), do: &Ecto.Type.load(:float, &1) + defp load_fun(:integer), do: &Ecto.Type.load(:integer, &1) + defp load_fun(:boolean), do: &Ecto.Type.load(:boolean, &1) + defp load_fun(:longinteger), do: &Ecto.Type.load(:integer, &1) + defp load_fun(:string), do: &Ecto.Type.load(:string, &1) + defp load_fun(:binaryblob), do: &load_binaryblob/1 + defp load_fun(:datetime), do: &load_datetime/1 + + defp load_binaryblob(value) do + case Base.decode64(value) do + {:ok, binary} -> Ecto.Type.load(:binary, binary) + _ -> :error + end + end + + defp load_datetime(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, 0} -> Ecto.Type.load(:utc_datetime_usec, datetime) + _ -> :error + end + end +end diff --git a/backend/lib/edgehog/astarte.ex b/backend/lib/edgehog/astarte.ex index 347ebc86d..8502af200 100644 --- a/backend/lib/edgehog/astarte.ex +++ b/backend/lib/edgehog/astarte.ex @@ -378,7 +378,7 @@ defmodule Edgehog.Astarte do filters |> Enum.reduce(Device, &filter_with/2) |> Repo.all() - |> Repo.preload(:tags) + |> Repo.preload([:tags, :custom_attributes]) end defp filter_with(filter, query) do @@ -487,7 +487,7 @@ defmodule Edgehog.Astarte do """ def get_device!(id) do Repo.get!(Device, id) - |> Repo.preload(:tags) + |> Repo.preload([:tags, :custom_attributes]) end @doc """ @@ -508,7 +508,7 @@ defmodule Edgehog.Astarte do |> Device.changeset(attrs) with {:ok, device} <- Repo.insert(changeset) do - {:ok, Repo.preload(device, :tags)} + {:ok, Repo.preload(device, [:tags, :custom_attributes])} end end @@ -555,7 +555,7 @@ defmodule Edgehog.Astarte do |> Repo.transaction() |> case do {:ok, %{update_device: device}} -> - {:ok, Repo.preload(device, :tags)} + {:ok, Repo.preload(device, [:tags, :custom_attributes])} {:error, _failed_operation, failed_value, _progress_so_far} -> {:error, failed_value} @@ -605,7 +605,7 @@ defmodule Edgehog.Astarte do """ def fetch_realm_device(%Realm{id: realm_id}, device_id) do case Repo.get_by(Device, realm_id: realm_id, device_id: device_id) do - %Device{} = device -> {:ok, Repo.preload(device, :tags)} + %Device{} = device -> {:ok, Repo.preload(device, [:tags, :custom_attributes])} nil -> {:error, :device_not_found} end end diff --git a/backend/lib/edgehog/astarte/device.ex b/backend/lib/edgehog/astarte/device.ex index 71ab0b9af..1e51d55d5 100644 --- a/backend/lib/edgehog/astarte/device.ex +++ b/backend/lib/edgehog/astarte/device.ex @@ -43,6 +43,10 @@ defmodule Edgehog.Astarte.Device do has_one :system_model, through: [:system_model_part_number, :system_model] many_to_many :tags, Devices.Tag, join_through: Devices.DeviceTag, on_replace: :delete + has_many :custom_attributes, Devices.Attribute, + where: [namespace: "custom"], + on_replace: :delete + timestamps() end @@ -74,5 +78,6 @@ defmodule Edgehog.Astarte.Device do :part_number ]) |> validate_required([:name]) + |> cast_assoc(:custom_attributes, with: &Devices.Attribute.custom_attribute_changeset/2) end end diff --git a/backend/lib/edgehog/devices/attribute.ex b/backend/lib/edgehog/devices/attribute.ex new file mode 100644 index 000000000..5fffe8f36 --- /dev/null +++ b/backend/lib/edgehog/devices/attribute.ex @@ -0,0 +1,52 @@ +# +# This file is part of Edgehog. +# +# Copyright 2022 SECO Mind Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule Edgehog.Devices.Attribute do + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + schema "device_attributes" do + field :tenant_id, :integer, + autogenerate: {Edgehog.Repo, :get_tenant_id, []}, + primary_key: true + + field :device_id, :id, primary_key: true + field :namespace, Ecto.Enum, values: [:custom], primary_key: true + field :key, :string, primary_key: true + field :typed_value, Ecto.JSONVariant + + timestamps() + end + + @doc false + def changeset(attributes, attrs) do + attributes + |> cast(attrs, [:namespace, :key, :typed_value]) + |> validate_required([:namespace, :key, :typed_value]) + |> validate_format(:key, ~r/[a-z0-9-_]+/) + end + + @doc false + def custom_attribute_changeset(attributes, attrs) do + changeset(attributes, attrs) + |> validate_inclusion(:namespace, [:custom]) + end +end diff --git a/backend/lib/edgehog_web/resolvers/astarte.ex b/backend/lib/edgehog_web/resolvers/astarte.ex index 49fb19f34..75f8e491d 100644 --- a/backend/lib/edgehog_web/resolvers/astarte.ex +++ b/backend/lib/edgehog_web/resolvers/astarte.ex @@ -51,6 +51,7 @@ defmodule EdgehogWeb.Resolvers.Astarte do def update_device(%{device_id: id} = attrs, %{context: context}) do device = Astarte.get_device!(id) + attrs = maybe_wrap_typed_values(attrs) with {:ok, device} <- Astarte.update_device(device, attrs) do device = preload_system_model_for_device(device, context) @@ -247,4 +248,28 @@ defmodule EdgehogWeb.Resolvers.Astarte do _ -> {:error, "Unknown led behavior"} end end + + defp maybe_wrap_typed_values(%{custom_attributes: custom_attributes} = attrs) + when is_list(custom_attributes) do + wrapped_attributes = + Enum.map(custom_attributes, fn attr -> + %{ + namespace: namespace, + key: key, + type: type, + value: value + } = attr + + # Wrap type and value under the :typed_value key, as expected by the Ecto schema + %{ + namespace: namespace, + key: key, + typed_value: %{type: type, value: value} + } + end) + + %{attrs | custom_attributes: wrapped_attributes} + end + + defp maybe_wrap_typed_values(attrs), do: attrs end diff --git a/backend/lib/edgehog_web/resolvers/devices.ex b/backend/lib/edgehog_web/resolvers/devices.ex index c870790c1..199a28e3f 100644 --- a/backend/lib/edgehog_web/resolvers/devices.ex +++ b/backend/lib/edgehog_web/resolvers/devices.ex @@ -21,8 +21,10 @@ defmodule EdgehogWeb.Resolvers.Devices do alias Edgehog.Astarte alias Edgehog.Devices + alias Edgehog.Devices.Attribute alias Edgehog.Devices.HardwareType alias Edgehog.Devices.SystemModel + alias EdgehogWeb.Schema.VariantTypes def find_hardware_type(%{id: id}, _resolution) do Devices.fetch_hardware_type(id) @@ -174,4 +176,13 @@ defmodule EdgehogWeb.Resolvers.Devices do tag_names = for t <- tags, do: t.name {:ok, tag_names} end + + def extract_attribute_type(%Attribute{typed_value: typed_value}, _args, _context) do + {:ok, typed_value.type} + end + + def extract_attribute_value(%Attribute{typed_value: typed_value}, _args, _context) do + %Ecto.JSONVariant{type: type, value: value} = typed_value + VariantTypes.encode_variant_value(type, value) + end end diff --git a/backend/lib/edgehog_web/schema.ex b/backend/lib/edgehog_web/schema.ex index 5e1c4b0a9..0bb4a5965 100644 --- a/backend/lib/edgehog_web/schema.ex +++ b/backend/lib/edgehog_web/schema.ex @@ -26,6 +26,7 @@ defmodule EdgehogWeb.Schema do import_types EdgehogWeb.Schema.LocalizationTypes import_types EdgehogWeb.Schema.OSManagementTypes import_types EdgehogWeb.Schema.TenantsTypes + import_types EdgehogWeb.Schema.VariantTypes import_types Absinthe.Plug.Types import_types Absinthe.Type.Custom diff --git a/backend/lib/edgehog_web/schema/astarte_types.ex b/backend/lib/edgehog_web/schema/astarte_types.ex index 388459166..328311984 100644 --- a/backend/lib/edgehog_web/schema/astarte_types.ex +++ b/backend/lib/edgehog_web/schema/astarte_types.ex @@ -91,6 +91,23 @@ defmodule EdgehogWeb.Schema.AstarteTypes do field :tag, :string end + @desc """ + An input object for a device attribute. + """ + input_object :device_attribute_input do + @desc "The namespace of the device attribute." + field :namespace, non_null(:device_attribute_namespace) + + @desc "The key of the device attribute." + field :key, non_null(:string) + + @desc "The type of the device attribute." + field :type, non_null(:variant_type) + + @desc "The value of the device attribute." + field :value, non_null(:variant_value) + end + @desc """ Describes hardware-related info of a device. @@ -389,6 +406,29 @@ defmodule EdgehogWeb.Schema.AstarteTypes do value :wifi end + enum :device_attribute_namespace do + @desc "Custom attributes, user defined" + value :custom + end + + object :device_attribute do + @desc "The namespace of the device attribute." + field :namespace, non_null(:device_attribute_namespace) + + @desc "The key of the device attribute." + field :key, non_null(:string) + + @desc "The type of the device attribute." + field :type, non_null(:variant_type) do + resolve &Resolvers.Devices.extract_attribute_type/3 + end + + @desc "The value of the device attribute." + field :value, non_null(:variant_value) do + resolve &Resolvers.Devices.extract_attribute_value/3 + end + end + @desc """ Denotes a device instance that connects and exchanges data. @@ -421,6 +461,9 @@ defmodule EdgehogWeb.Schema.AstarteTypes do resolve &Resolvers.Devices.extract_device_tags/3 end + @desc "The custom attributes of the device. These attributes are user editable." + field :custom_attributes, non_null(list_of(non_null(:device_attribute))) + @desc "List of capabilities supported by the device." field :capabilities, non_null(list_of(non_null(:device_capability))) do resolve &Resolvers.Astarte.list_device_capabilities/3 @@ -553,6 +596,9 @@ defmodule EdgehogWeb.Schema.AstarteTypes do @desc "The tags of the device. These replace all the current tags." field :tags, list_of(non_null(:string)) + + @desc "The custom attributes of the device. These replace all the current custom attributes." + field :custom_attributes, list_of(non_null(:device_attribute_input)) end output do diff --git a/backend/lib/edgehog_web/schema/variant_types.ex b/backend/lib/edgehog_web/schema/variant_types.ex new file mode 100644 index 000000000..a517143dc --- /dev/null +++ b/backend/lib/edgehog_web/schema/variant_types.ex @@ -0,0 +1,92 @@ +# +# This file is part of Edgehog. +# +# Copyright 2022 SECO Mind Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule EdgehogWeb.Schema.VariantTypes do + use Absinthe.Schema.Notation + + @supported_types [ + :double, + :integer, + :boolean, + :longinteger, + :string, + :binaryblob, + :datetime + ] + + enum :variant_type do + @desc "Double type" + value :double + @desc "32 bit integer type" + value :integer + @desc "Boolean type" + value :boolean + + @desc """ + 64 bit integer type. When this is the type, the value will be a string representing the number. + This is done to avoid representation errors when using JSON Numbers. + """ + value :longinteger + @desc "String type" + value :string + @desc "Binary blob type. When this is the type, the value will be Base64 encoded." + value :binaryblob + @desc "Datetime type. When this is the type, the value will be an ISO8601 timestamp." + value :datetime + end + + @desc """ + A variant value. It can contain any JSON value. The value will be checked together with the + type to verify whether it's valid. + """ + scalar :variant_value, name: "VariantValue" do + # We encode and decode values as-is, proper encoding/decoding and validation will be handled + # one level higher when both the type and the value are available at once. + # See encode/2 for encoding and the Ecto.JSONVariant module for decoding. + serialize &Function.identity/1 + parse &decode_variant_value/1 + end + + # Handle all scalar JSON types and decode them as-is + defp decode_variant_value(%Absinthe.Blueprint.Input.Float{value: value}), do: {:ok, value} + defp decode_variant_value(%Absinthe.Blueprint.Input.Integer{value: value}), do: {:ok, value} + defp decode_variant_value(%Absinthe.Blueprint.Input.String{value: value}), do: {:ok, value} + defp decode_variant_value(%Absinthe.Blueprint.Input.Null{}), do: {:ok, nil} + defp decode_variant_value(_), do: :error + + # Handle encoding with type + value + # :binaryblob gets converted to base64 + def encode_variant_value(:binaryblob, value) when is_binary(value) do + {:ok, Base.encode64(value)} + end + + # :datetime gets converted to ISO8601 + def encode_variant_value(:datetime, %DateTime{} = value) do + {:ok, DateTime.to_iso8601(value)} + end + + # :longinteger gets converted to string to avoid JSON representation problems + def encode_variant_value(:longinteger, value) when is_integer(value), + do: {:ok, to_string(value)} + + # Everything else is encoded as itself + def encode_variant_value(type, value) when type in @supported_types, do: {:ok, value} + def encode_variant_value(_type, _value), do: {:error, :unsupported_type} +end diff --git a/backend/priv/repo/migrations/20220607055759_create_device_attributes.exs b/backend/priv/repo/migrations/20220607055759_create_device_attributes.exs new file mode 100644 index 000000000..4c740a30a --- /dev/null +++ b/backend/priv/repo/migrations/20220607055759_create_device_attributes.exs @@ -0,0 +1,45 @@ +# +# This file is part of Edgehog. +# +# Copyright 2022 SECO Mind Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule Edgehog.Repo.Migrations.CreateDeviceAttributes do + use Ecto.Migration + + def change do + create table(:device_attributes, primary_key: false) do + add :tenant_id, references(:tenants, column: :tenant_id, on_delete: :delete_all), + null: false, + primary_key: true + + add :device_id, + references(:devices, with: [tenant_id: :tenant_id], match: :full, on_delete: :delete_all), + null: false, + primary_key: true + + add :namespace, :string, null: false, primary_key: true + add :key, :string, null: false, primary_key: true + add :typed_value, :map, null: false + + timestamps() + end + + create index(:device_attributes, [:tenant_id]) + create index(:device_attributes, [:device_id, :tenant_id]) + end +end diff --git a/backend/test/ecto/json_variant_test.exs b/backend/test/ecto/json_variant_test.exs new file mode 100644 index 000000000..8f0cb60c5 --- /dev/null +++ b/backend/test/ecto/json_variant_test.exs @@ -0,0 +1,242 @@ +# +# This file is part of Edgehog. +# +# Copyright 2022 SECO Mind Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule Ecto.JSONVariantTest do + use ExUnit.Case + alias Ecto.JSONVariant + + import Ecto.Changeset + + @types %{ + variant: JSONVariant + } + + describe "cast/1 with double type" do + test "correctly handles a double" do + assert {:ok, %{variant: %JSONVariant{type: :double, value: 42.0}}} == + cast_and_apply(%{"variant" => %{"type" => "double", "value" => 42.0}}) + end + + test "correctly handles an integer" do + assert {:ok, %{variant: %JSONVariant{type: :double, value: 42.0}}} === + cast_and_apply(%{"variant" => %{"type" => "double", "value" => 42}}) + end + + test "correctly handles a string" do + assert {:ok, %{variant: %JSONVariant{type: :double, value: 42.0}}} === + cast_and_apply(%{"variant" => %{"type" => "double", "value" => "42"}}) + end + + test "fails with invalid value" do + assert {:error, %Ecto.Changeset{}} = + cast_and_apply(%{"variant" => %{"type" => "double", "value" => "foobar"}}) + end + end + + describe "cast/1 with integer type" do + test "correctly handles an integer" do + assert {:ok, %{variant: %JSONVariant{type: :integer, value: 42}}} == + cast_and_apply(%{"variant" => %{"type" => "integer", "value" => 42}}) + end + + test "correctly handles a string" do + assert {:ok, %{variant: %JSONVariant{type: :integer, value: 42}}} == + cast_and_apply(%{"variant" => %{"type" => "integer", "value" => "42"}}) + end + + test "fails with invalid value" do + assert {:error, %Ecto.Changeset{}} = + cast_and_apply(%{"variant" => %{"type" => "integer", "value" => "foobar"}}) + end + + test "fails with out of range value" do + assert {:error, %Ecto.Changeset{}} = + cast_and_apply(%{"variant" => %{"type" => "integer", "value" => 2_000_000_000_000}}) + end + end + + describe "cast/1 with boolean type" do + test "correctly handles a boolean" do + assert {:ok, %{variant: %JSONVariant{type: :boolean, value: true}}} == + cast_and_apply(%{"variant" => %{"type" => "boolean", "value" => true}}) + end + + test "correctly handles a string" do + assert {:ok, %{variant: %JSONVariant{type: :boolean, value: true}}} == + cast_and_apply(%{"variant" => %{"type" => "boolean", "value" => "true"}}) + end + + test "correctly handles an integer string" do + assert {:ok, %{variant: %JSONVariant{type: :boolean, value: true}}} == + cast_and_apply(%{"variant" => %{"type" => "boolean", "value" => "1"}}) + end + + test "fails with invalid value" do + assert {:error, %Ecto.Changeset{}} = + cast_and_apply(%{"variant" => %{"type" => "boolean", "value" => "foobar"}}) + end + end + + describe "cast/1 with longinteger type" do + test "correctly handles an longinteger" do + assert {:ok, %{variant: %JSONVariant{type: :longinteger, value: 42}}} == + cast_and_apply(%{"variant" => %{"type" => "longinteger", "value" => 42}}) + end + + test "correctly handles a string" do + assert {:ok, %{variant: %JSONVariant{type: :longinteger, value: 42}}} == + cast_and_apply(%{"variant" => %{"type" => "longinteger", "value" => "42"}}) + end + + test "fails with invalid value" do + assert {:error, %Ecto.Changeset{}} = + cast_and_apply(%{"variant" => %{"type" => "longinteger", "value" => "foobar"}}) + end + + test "fails with out of range value" do + assert {:error, %Ecto.Changeset{}} = + cast_and_apply(%{ + "variant" => %{"type" => "longinteger", "value" => 0x1_FFFF_FFFF_FFFF_FFFF} + }) + end + end + + describe "cast/1 with string type" do + test "correctly handles a valid string" do + assert {:ok, %{variant: %JSONVariant{type: :string, value: "hello world"}}} == + cast_and_apply(%{"variant" => %{"type" => "string", "value" => "hello world"}}) + end + + test "fails with invalid string" do + assert {:error, %Ecto.Changeset{}} = + cast_and_apply(%{"variant" => %{"type" => "string", "value" => <<128>>}}) + end + + test "fails with invalid value" do + assert {:error, %Ecto.Changeset{}} = + cast_and_apply(%{"variant" => %{"type" => "string", "value" => 42}}) + end + end + + describe "cast/1 with binaryblob type" do + test "correctly handles base64" do + assert {:ok, %{variant: %JSONVariant{type: :binaryblob, value: <<128>>}}} == + cast_and_apply(%{"variant" => %{"type" => "binaryblob", "value" => "gA=="}}) + end + + test "fails with invalid value" do + assert {:error, %Ecto.Changeset{}} = + cast_and_apply(%{"variant" => %{"type" => "binaryblob", "value" => <<128>>}}) + end + end + + describe "cast/1 with datetime type" do + test "correctly handles an ISO8601 timestamp" do + assert {:ok, %{variant: %JSONVariant{type: :datetime, value: %DateTime{}}}} = + cast_and_apply(%{ + "variant" => %{"type" => "datetime", "value" => "2022-06-08T14:30:33.167352Z"} + }) + end + + test "correctly handles a DateTime" do + assert {:ok, %{variant: %JSONVariant{type: :datetime, value: %DateTime{}}}} = + cast_and_apply(%{ + "variant" => %{"type" => "datetime", "value" => DateTime.utc_now()} + }) + end + + test "fails with invalid value" do + assert {:error, %Ecto.Changeset{}} = + cast_and_apply(%{"variant" => %{"type" => "datetime", "value" => "foobar"}}) + end + end + + describe "dump and load" do + test "roundtrip for double" do + {:ok, %{variant: value}} = + cast_and_apply(%{"variant" => %{"type" => "double", "value" => 42.0}}) + + assert value == dump_load_roundtrip(value) + end + + test "roundtrip for integer" do + {:ok, %{variant: value}} = + cast_and_apply(%{"variant" => %{"type" => "integer", "value" => 42}}) + + assert value == dump_load_roundtrip(value) + end + + test "roundtrip for boolean" do + {:ok, %{variant: value}} = + cast_and_apply(%{"variant" => %{"type" => "boolean", "value" => true}}) + + assert value == dump_load_roundtrip(value) + end + + test "roundtrip for longinteger" do + {:ok, %{variant: value}} = + cast_and_apply(%{"variant" => %{"type" => "longinteger", "value" => 42}}) + + assert value == dump_load_roundtrip(value) + end + + test "roundtrip for string" do + {:ok, %{variant: value}} = + cast_and_apply(%{"variant" => %{"type" => "string", "value" => "hello"}}) + + assert value == dump_load_roundtrip(value) + end + + test "roundtrip for binaryblob" do + {:ok, %{variant: value}} = + cast_and_apply(%{"variant" => %{"type" => "binaryblob", "value" => "ZWRnZWhvZw=="}}) + + assert value == dump_load_roundtrip(value) + end + + test "roundtrip for datetime" do + {:ok, %{variant: value}} = + cast_and_apply(%{"variant" => %{"type" => "datetime", "value" => DateTime.utc_now()}}) + + assert value == dump_load_roundtrip(value) + end + end + + def cast_and_apply(params) do + {%{}, @types} + |> cast(params, Map.keys(@types)) + |> apply_action(:insert) + end + + def dump_load_roundtrip(value) do + {:ok, dumped_value} = JSONVariant.dump(value) + + {:ok, loaded_value} = + dumped_value + |> to_string_keys() + |> JSONVariant.load() + + loaded_value + end + + def to_string_keys(%{t: t, v: v} = _dumped_value) do + %{"t" => t, "v" => v} + end +end diff --git a/backend/test/edgehog/astarte_test.exs b/backend/test/edgehog/astarte_test.exs index d03d9aa6b..1e1c041a6 100644 --- a/backend/test/edgehog/astarte_test.exs +++ b/backend/test/edgehog/astarte_test.exs @@ -23,6 +23,7 @@ defmodule Edgehog.AstarteTest do use Edgehog.AstarteMockCase alias Edgehog.Astarte + alias Edgehog.Devices describe "clusters" do alias Edgehog.Astarte.Cluster @@ -473,11 +474,29 @@ defmodule Edgehog.AstarteTest do test "update_device/2 with valid data updates the device", %{realm: realm} do device = device_fixture(realm) - update_attrs = %{name: "some updated name", tags: ["some", "tags"]} + + update_attrs = %{ + name: "some updated name", + tags: ["some", "tags"], + custom_attributes: [ + %{ + "namespace" => "custom", + "key" => "some-attribute", + "typed_value" => %{"type" => "double", "value" => 42} + } + ] + } assert {:ok, %Device{} = device} = Astarte.update_device(device, update_attrs) assert device.name == "some updated name" assert ["some", "tags"] == Enum.map(device.tags, & &1.name) + assert [custom_attribute] = device.custom_attributes + + assert %Devices.Attribute{ + namespace: :custom, + key: "some-attribute", + typed_value: %Ecto.JSONVariant{type: :double, value: 42.0} + } = custom_attribute end test "update_device/2 normalizes and deduplicates tags", %{realm: realm} do @@ -527,6 +546,131 @@ defmodule Edgehog.AstarteTest do assert device.device_id == initial_device_id end + test "update_device/2 adds custom attributes", %{realm: realm} do + device = device_fixture(realm) + + update_attrs = %{ + custom_attributes: [ + %{ + "namespace" => "custom", + "key" => "some-attribute", + "typed_value" => %{"type" => "double", "value" => 42} + } + ] + } + + assert {:ok, %Device{} = device} = Astarte.update_device(device, update_attrs) + assert [custom_attribute] = device.custom_attributes + + assert %Devices.Attribute{ + namespace: :custom, + key: "some-attribute", + typed_value: %Ecto.JSONVariant{type: :double, value: 42.0} + } = custom_attribute + + update_attrs = %{ + custom_attributes: [ + %{ + "namespace" => "custom", + "key" => "some-attribute", + "typed_value" => %{"type" => "double", "value" => 42} + }, + %{ + "namespace" => "custom", + "key" => "some-other-attribute", + "typed_value" => %{"type" => "string", "value" => "hello"} + } + ] + } + + assert {:ok, %Device{} = device} = Astarte.update_device(device, update_attrs) + assert [^custom_attribute, new_attribute] = device.custom_attributes + + assert %Devices.Attribute{ + namespace: :custom, + key: "some-other-attribute", + typed_value: %Ecto.JSONVariant{type: :string, value: "hello"} + } = new_attribute + end + + test "update_device/2 removes custom attributes", %{realm: realm} do + device = device_fixture(realm) + + update_attrs = %{ + custom_attributes: [ + %{ + "namespace" => "custom", + "key" => "some-attribute", + "typed_value" => %{"type" => "double", "value" => 42} + }, + %{ + "namespace" => "custom", + "key" => "some-other-attribute", + "typed_value" => %{"type" => "string", "value" => "hello"} + } + ] + } + + assert {:ok, %Device{} = device} = Astarte.update_device(device, update_attrs) + assert [attribute_1, _attribute_2] = device.custom_attributes + + update_attrs = %{ + custom_attributes: [ + %{ + "namespace" => "custom", + "key" => "some-attribute", + "typed_value" => %{"type" => "double", "value" => 42} + } + ] + } + + assert {:ok, %Device{} = device} = Astarte.update_device(device, update_attrs) + assert [^attribute_1] = device.custom_attributes + end + + test "update_device/2 updates custom attributes", %{realm: realm} do + device = device_fixture(realm) + + update_attrs = %{ + custom_attributes: [ + %{ + "namespace" => "custom", + "key" => "some-attribute", + "typed_value" => %{"type" => "double", "value" => 42} + } + ] + } + + assert {:ok, %Device{} = device} = Astarte.update_device(device, update_attrs) + assert [custom_attribute] = device.custom_attributes + + assert %Devices.Attribute{ + namespace: :custom, + key: "some-attribute", + typed_value: %Ecto.JSONVariant{type: :double, value: 42.0} + } = custom_attribute + + update_attrs = %{ + custom_attributes: [ + %{ + "namespace" => "custom", + "key" => "some-attribute", + "typed_value" => %{"type" => "string", "value" => "new value"} + } + ] + } + + assert {:ok, %Device{} = device} = Astarte.update_device(device, update_attrs) + + assert [updated_attribute] = device.custom_attributes + + assert %Devices.Attribute{ + namespace: :custom, + key: "some-attribute", + typed_value: %Ecto.JSONVariant{type: :string, value: "new value"} + } = updated_attribute + end + test "update_device/2 with invalid data returns error changeset", %{realm: realm} do device = device_fixture(realm) assert {:error, %Ecto.Changeset{}} = Astarte.update_device(device, @invalid_attrs) diff --git a/backend/test/edgehog_web/schema/mutation/update_device_test.exs b/backend/test/edgehog_web/schema/mutation/update_device_test.exs index 8af7a76b1..83d945b55 100644 --- a/backend/test/edgehog_web/schema/mutation/update_device_test.exs +++ b/backend/test/edgehog_web/schema/mutation/update_device_test.exs @@ -42,6 +42,12 @@ defmodule EdgehogWeb.Schema.Mutation.UpdateDeviceTest do id name tags + customAttributes { + namespace + key + type + value + } } } } @@ -55,7 +61,13 @@ defmodule EdgehogWeb.Schema.Mutation.UpdateDeviceTest do input: %{ device_id: Absinthe.Relay.Node.to_global_id(:device, device.id, EdgehogWeb.Schema), name: "Some new name", - tags: ["foo", "bar", "baz"] + tags: ["foo", "bar", "baz"], + custom_attributes: %{ + namespace: "CUSTOM", + key: "foo", + type: "STRING", + value: "bar" + } } } @@ -66,7 +78,15 @@ defmodule EdgehogWeb.Schema.Mutation.UpdateDeviceTest do "updateDevice" => %{ "device" => %{ "name" => "Some new name", - "tags" => ["foo", "bar", "baz"] + "tags" => ["foo", "bar", "baz"], + "customAttributes" => [ + %{ + "namespace" => "CUSTOM", + "key" => "foo", + "type" => "STRING", + "value" => "bar" + } + ] } } } @@ -92,7 +112,20 @@ defmodule EdgehogWeb.Schema.Mutation.UpdateDeviceTest do api_path: api_path, device: device } do - {:ok, _} = Astarte.update_device(device, %{tags: ["not", "touched"]}) + {:ok, _} = + Astarte.update_device(device, %{ + tags: ["not", "touched"], + custom_attributes: [ + %{ + namespace: :custom, + key: "string", + typed_value: %{ + type: :string, + value: "not touched" + } + } + ] + }) variables = %{ input: %{ @@ -108,7 +141,15 @@ defmodule EdgehogWeb.Schema.Mutation.UpdateDeviceTest do "updateDevice" => %{ "device" => %{ "name" => "Some new name", - "tags" => ["not", "touched"] + "tags" => ["not", "touched"], + "customAttributes" => [ + %{ + "namespace" => "CUSTOM", + "key" => "string", + "type" => "STRING", + "value" => "not touched" + } + ] } } } diff --git a/backend/test/edgehog_web/schema/query/device_test.exs b/backend/test/edgehog_web/schema/query/device_test.exs index b19c7030b..07cecefb0 100644 --- a/backend/test/edgehog_web/schema/query/device_test.exs +++ b/backend/test/edgehog_web/schema/query/device_test.exs @@ -171,5 +171,144 @@ defmodule EdgehogWeb.Schema.Query.DeviceTest do assert device["tags"] == ["foo", "bar"] end + + @custom_attributes_query """ + query ($id: ID!) { + device(id: $id) { + customAttributes { + namespace + key + type + value + } + } + } + """ + + test "returns custom attributes for all types", %{ + conn: conn, + api_path: api_path, + realm: realm + } do + custom_attributes = [ + %{ + namespace: :custom, + key: "double", + typed_value: %{ + type: :double, + value: 42.0 + } + }, + %{ + namespace: :custom, + key: "integer", + typed_value: %{ + type: :integer, + value: 300 + } + }, + %{ + namespace: :custom, + key: "boolean", + typed_value: %{ + type: :boolean, + value: true + } + }, + %{ + namespace: :custom, + key: "longinteger", + typed_value: %{ + type: :longinteger, + value: "1234567890" + } + }, + %{ + namespace: :custom, + key: "string", + typed_value: %{ + type: :string, + value: "foobar" + } + }, + %{ + namespace: :custom, + key: "binaryblob", + typed_value: %{ + type: :binaryblob, + value: "ZWRnZWhvZw==" + } + }, + %{ + namespace: :custom, + key: "datetime", + typed_value: %{ + type: :datetime, + value: "2022-06-10T16:27:41.235243Z" + } + } + ] + + {:ok, %Device{id: id}} = + device_fixture(realm) + |> Astarte.update_device(%{custom_attributes: custom_attributes}) + + variables = %{id: Absinthe.Relay.Node.to_global_id(:device, id, EdgehogWeb.Schema)} + + conn = get(conn, api_path, query: @custom_attributes_query, variables: variables) + + assert %{ + "data" => %{ + "device" => %{ + "customAttributes" => custom_attributes + } + } + } = json_response(conn, 200) + + assert custom_attributes == [ + %{ + "key" => "double", + "namespace" => "CUSTOM", + "type" => "DOUBLE", + "value" => 42.0 + }, + %{ + "key" => "integer", + "namespace" => "CUSTOM", + "type" => "INTEGER", + "value" => 300 + }, + %{ + "key" => "boolean", + "namespace" => "CUSTOM", + "type" => "BOOLEAN", + "value" => true + }, + %{ + "key" => "longinteger", + "namespace" => "CUSTOM", + "type" => "LONGINTEGER", + "value" => "1234567890" + }, + %{ + "key" => "string", + "namespace" => "CUSTOM", + "type" => "STRING", + "value" => "foobar" + }, + %{ + "key" => "binaryblob", + "namespace" => "CUSTOM", + "type" => "BINARYBLOB", + "value" => "ZWRnZWhvZw==" + }, + %{ + "key" => "datetime", + "namespace" => "CUSTOM", + "type" => "DATETIME", + "value" => "2022-06-10T16:27:41.235243Z" + } + ] + end end end