diff --git a/lib/astarte/core/generators/mapping/value.ex b/lib/astarte/core/generators/mapping/value.ex new file mode 100644 index 00000000..ed038bbd --- /dev/null +++ b/lib/astarte/core/generators/mapping/value.ex @@ -0,0 +1,114 @@ +# +# This file is part of Astarte. +# +# Copyright 2025 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. +# + +defmodule Astarte.Core.Generators.Mapping.Value do + @moduledoc """ + This module provides generators for Interface values. + """ + use ExUnitProperties + + alias Astarte.Core.Interface + alias Astarte.Core.Mapping + + alias Astarte.Core.Generators.Mapping.ValueType, as: ValueTypeGenerator + + @doc """ + Generates a valid value based on interface + """ + @spec value(Interface.t()) :: StreamData.t(map()) + def value(%Interface{} = interface) when not is_struct(interface, StreamData) do + interface |> constant() |> value() + end + + @spec value(StreamData.t(Interface.t())) :: StreamData.t(map()) + def value(gen), do: gen |> bind(&build_package/1) + + defp build_package(%Interface{aggregation: :individual} = interface) do + %Interface{mappings: mappings} = interface + + gen all %Mapping{endpoint: endpoint, value_type: value_type} <- member_of(mappings), + path <- endpoint_path(endpoint), + value <- build_value(value_type) do + %{path: path, value: value} + end + end + + defp build_package(%Interface{aggregation: :object} = interface) do + %Interface{mappings: [%Mapping{endpoint: endpoint} | _] = mappings} = interface + + endpoint = endpoint |> String.split("/") |> Enum.drop(-1) |> Enum.join("/") + + gen all path <- endpoint_path(endpoint), + value <- + mappings + |> Map.new(fn %Mapping{endpoint: endpoint, value_type: value_type} -> + {endpoint_postfix(endpoint), build_value(value_type)} + end) + |> optional_map() do + %{path: path, value: value} + end + end + + defp endpoint_path(endpoint) do + endpoint + |> String.split("/") + |> Enum.map(&convert_token/1) + |> fixed_list() + |> map(&Enum.join(&1, "/")) + end + + defp endpoint_postfix(endpoint), do: Regex.replace(~r/.*\//, endpoint, "") + + defp build_value(value_type), do: ValueTypeGenerator.value_from_type(value_type) + + # Utilities + defp convert_token(token) do + case(Mapping.is_placeholder?(token)) do + true -> string(:alphanumeric, min_length: 1) + false -> constant(token) + end + end + + @doc """ + Returns true if `path` matches `endpoint` according to the given `aggregation`. + """ + @spec path_matches_endpoint?(:individual | :object, String.t(), String.t()) :: boolean() + def path_matches_endpoint?(:individual, endpoint, path), + do: + path_matches_endpoint?( + endpoint |> Mapping.normalize_endpoint() |> String.split("/"), + path |> String.split("/") + ) + + def path_matches_endpoint?(:object, endpoint, path), + do: + path_matches_endpoint?( + endpoint |> Mapping.normalize_endpoint() |> String.split("/") |> Enum.drop(-1), + path |> String.split("/") + ) + + defp path_matches_endpoint?([], []), do: true + + defp path_matches_endpoint?(["" | endpoints], [_ | paths]), + do: path_matches_endpoint?(endpoints, paths) + + defp path_matches_endpoint?([same | endpoints], [same | paths]), + do: path_matches_endpoint?(endpoints, paths) + + defp path_matches_endpoint?(_, _), do: false +end diff --git a/lib/astarte/core/generators/mapping/value_type.ex b/lib/astarte/core/generators/mapping/value_type.ex new file mode 100644 index 00000000..14078894 --- /dev/null +++ b/lib/astarte/core/generators/mapping/value_type.ex @@ -0,0 +1,88 @@ +# +# This file is part of Astarte. +# +# Copyright 2025 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. +# + +defmodule Astarte.Core.Generators.Mapping.ValueType do + @moduledoc """ + This module provides generators for any ValueType. + """ + use ExUnitProperties + + alias Astarte.Core.Mapping.ValueType + + alias Astarte.Common.Generators.DateTime, as: DateTimeGenerator + + @valid_atoms [ + :double, + :integer, + :boolean, + :longinteger, + :string, + :binaryblob, + :datetime, + :doublearray, + :integerarray, + :booleanarray, + :longintegerarray, + :stringarray, + :binaryblobarray, + :datetimearray + ] + + @type valid_t :: + unquote( + @valid_atoms + |> Enum.map_join(" | ", &inspect/1) + |> Code.string_to_quoted!() + ) + + @doc """ + List of all astarte's ValueType atoms + """ + @spec valid_atoms() :: list(atom()) + def valid_atoms, do: @valid_atoms + + @doc """ + Generates a valid ValueType + """ + @spec value_type() :: StreamData.t(ValueType.t()) + def value_type, do: member_of(valid_atoms()) + + @doc """ + Generates a valid value from ValueType + """ + @spec value_from_type(type :: valid_t()) :: StreamData.t(any()) + def value_from_type(:double), do: float() + def value_from_type(:integer), do: integer(-0x7FFFFFFF..0x7FFFFFFF) + def value_from_type(:boolean), do: boolean() + def value_from_type(:longinteger), do: integer(-0x7FFFFFFFFFFFFFFF..0x7FFFFFFFFFFFFFFF) + def value_from_type(:string), do: string(:utf8, max_length: 65_535) + def value_from_type(:binaryblob), do: binary(max_length: 65_535) + + def value_from_type(:datetime), do: DateTimeGenerator.date_time() + + def value_from_type(array) when is_atom(array), + do: type_array(array) |> value_from_type() |> list_of(max_length: 1023) + + defp type_array(:doublearray), do: :double + defp type_array(:integerarray), do: :integer + defp type_array(:longintegerarray), do: :longinteger + defp type_array(:booleanarray), do: :boolean + defp type_array(:stringarray), do: :string + defp type_array(:binaryblobarray), do: :binaryblob + defp type_array(:datetimearray), do: :datetime +end diff --git a/test/astarte/core/generators/mapping/value_test.exs b/test/astarte/core/generators/mapping/value_test.exs new file mode 100644 index 00000000..52f1fe5e --- /dev/null +++ b/test/astarte/core/generators/mapping/value_test.exs @@ -0,0 +1,142 @@ +# +# This file is part of Astarte. +# +# Copyright 2025 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. +# + +defmodule Astarte.Core.Generators.Mapping.ValueTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Astarte.Core.Interface + alias Astarte.Core.Mapping + alias Astarte.Core.Mapping.ValueType + + alias Astarte.Core.Generators.Interface, as: InterfaceGenerator + alias Astarte.Core.Generators.Mapping.Value, as: ValueGenerator + + @moduletag :mapping + @moduletag :value + + @endpoints_path [ + {:individual, "/%{param}", "/abc", true}, + {:object, "/%{param}/a", "/dce", true}, + {:individual, "/Alpha/Beta/Gamma", "/Alpha/Beta/Gamma", true}, + {:individual, "/Alpha/Beta/Gamma", "/Alpha/Beta", false}, + {:individual, "/Shop/Prd45/Details", "/shop/Prd45/Details", false}, + {:individual, "/api/v1/users/%{UID}/orders/%{OID}/lines/LID", + "/api/v1/users/U9/orders/O77/lines/LID", true}, + {:individual, "/api/v1/users/%{UID}/orders/%{OID}/lines/LID", + "/api/v1/users/U9/orders/O77/lines", false}, + {:object, "/Catalog/%{Section}/Items", "/Catalog/Electronics", true}, + {:object, "/Catalog/%{Section}/Items", "/Catalog/Electronics/Items", false}, + {:object, "/Foo/%{Id}/Bar", "/Foo/A1", true}, + {:object, "/Foo/%{Id}/Bar", "/Foo/A1/B2", false}, + {:individual, "/User_Profile/%{UID123}/Orders/%{YEAR}/Summary", + "/User_Profile/A7/Orders/2025/Summary", true}, + {:individual, "/User_Profile/%{UID123}/Orders/%{YEAR}/Summary", + "/User_Profile/A7/Orders/2025", false}, + {:individual, "/Srv/%{Env}/Cfg/%{Key}/Apply", "/Srv/Prod/Cfg/DB1/Apply", true}, + {:individual, "/Srv/%{Env}/Cfg/%{Key}/Apply", "/Srv/Prod/Cfg/DB1/Apply/Now", false}, + {:individual, "/Root/%{A}/%{B}/%{C}/Leaf", "/Root/x1/y2/z3/Leaf", true}, + {:object, "/Root/%{A}/%{B}/%{C}/Leaf", "/Root/x1/y2/z3", true}, + {:object, "/Root/%{A}/%{B}/%{C}/Leaf", "/Root/x1/y2", false}, + {:individual, "/Alpha/%{X}/Beta/%{Y}/Gamma", "/Alpha/aa1/Beta/bb2/Gamma", true}, + {:individual, "/Alpha/%{X}/Beta/%{Y}/Gamma", "/Alpha/aa1/Beta/Gamma", false}, + {:individual, "/A_/B_/C_/D_/E_", "/A_/B_/C_/D_/E_", true}, + {:individual, "/A_/B_/C_/D_/E_", "/A_/B_/C_/D_", false}, + {:individual, "/calc/%{Mode}/run/Now", "/calc/Fast/run/Now", true}, + {:individual, "/calc/%{Mode}/run/Now", "/calc/Fast/run", false}, + {:object, "/calc/%{Mode}/run/Now", "/calc/Fast/run", true}, + {:individual, "/Auth/Login/Step/N", "/Auth/Login/Step/N", true}, + {:individual, "/Auth/Login/Step/N", "/Auth/Login/Step2", false}, + {:individual, "/node/%{N1}/leaf/%{L2}/x/K", "/node/N/leaf/L/x/K", true}, + {:object, "/node/%{N1}/leaf/%{L2}/x/K", "/node/N/leaf/L/x", true}, + {:individual, "/Lib/%{Pkg}/v/%{Major}/_/_Minor", "/Lib/core/v/1/_/_Minor", true}, + {:individual, "/Lib/%{Pkg}/v/%{Major}/_/_Minor", "/Lib/core/v/1", false}, + {:individual, "/xY/%{Za}/Q1/%{Rb}/Zz", "/xY/za1/Q1/rb2/Zz", true}, + {:individual, "/xY/%{Za}/Q1/%{Rb}/Zz", "/xY/za1/Q1/rb2/ZZ", false}, + {:object, "/alpha/%{A1}/beta/%{B2}/gamma", "/alpha/x1/beta/y2", true}, + {:object, "/alpha/%{A1}/beta/%{B2}/gamma", "/alpha/x1/beta", false}, + {:individual, "/srv/%{EnvX}/cfg/%{KeyX}/edit/User", "/srv/Dev/cfg/DB2/edit/User", true}, + {:individual, "/srv/%{EnvX}/cfg/%{KeyX}/edit/User", "/srv/Dev/cfg/DB2/edit", false}, + {:individual, "/AA/BB/CC/%{DD}/EE/FF", "/AA/BB/CC/d1/EE/FF", true}, + {:object, "/AA/BB/CC/%{DD}/EE/FF", "/AA/BB/CC/d1/EE", true}, + {:individual, "/Path/%{A}/To/%{B}/Res/%{C}/View", "/Path/A1/To/B2/Res/C3/View", true}, + {:individual, "/Path/%{A}/To/%{B}/Res/%{C}/View", "/Path/A1/To/B2/Res/C3/View/", false}, + {:individual, "/ROOT/%{X}/MID/%{Y}/TAIL/Z", "/ROOT/r1/MID/m2/TAIL/Z", true}, + {:object, "/ROOT/%{X}/MID/%{Y}/TAIL/Z", "/ROOT/r1/MID/m2/TAIL", true}, + {:individual, "/Ping/Pong/%{Who}/Score/Final", "/Ping/Pong/Alice/Score/Final", true}, + {:individual, "/Ping/Pong/%{Who}/Score/Final", "/Ping/Pong/Alice/ScoreFinal", false} + ] + + defp value_to_check(:individual, value), do: value + defp value_to_check(:object, value) when is_map(value), do: value |> Map.values() |> Enum.at(0) + + @doc false + describe "test utilities" do + test "path_matches_endpoint?/3" do + for {aggregation, endpoint, path, expected} <- @endpoints_path do + assert expected == ValueGenerator.path_matches_endpoint?(aggregation, endpoint, path) + end + end + end + + @doc false + describe "value generator" do + @describetag :success + @describetag :ut + + property "generates value based on interface (gen)" do + gen = InterfaceGenerator.interface() |> ValueGenerator.value() + + check all value <- gen do + assert %{path: _path, value: _value} = value + end + end + + property "generates value based on interface (struct)" do + check all interface <- InterfaceGenerator.interface(), + value <- ValueGenerator.value(interface) do + assert %{path: _path, value: _value} = value + end + end + + property "generates value must have mapping path matches endpoint" do + check all %Interface{mappings: mappings, aggregation: aggregation} = interface <- + InterfaceGenerator.interface(), + %{path: path, value: _value} <- ValueGenerator.value(interface) do + assert Enum.any?(mappings, fn %Mapping{endpoint: endpoint} -> + ValueGenerator.path_matches_endpoint?(aggregation, endpoint, path) + end) + end + end + + property "generates value must be valid type" do + check all %Interface{mappings: mappings, aggregation: aggregation} = interface <- + InterfaceGenerator.interface(), + %{path: path, value: value} <- ValueGenerator.value(interface) do + value = value_to_check(aggregation, value) + + %Mapping{value_type: value_type} = + Enum.find(mappings, fn %Mapping{endpoint: endpoint} -> + ValueGenerator.path_matches_endpoint?(aggregation, endpoint, path) + end) + + assert ValueType.validate_value(value_type, value) + end + end + end +end diff --git a/test/astarte/core/generators/mapping/value_type_test.exs b/test/astarte/core/generators/mapping/value_type_test.exs new file mode 100644 index 00000000..cb115bbd --- /dev/null +++ b/test/astarte/core/generators/mapping/value_type_test.exs @@ -0,0 +1,63 @@ +# +# This file is part of Astarte. +# +# Copyright 2025 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. +# + +defmodule Astarte.Core.Generators.Mapping.ValueTypeTest do + @moduledoc """ + Tests for Astarte Mapping ValueTypeTest + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias Astarte.Core.Mapping.ValueType + + alias Astarte.Core.Generators.Mapping.ValueType, as: ValueTypeGenerator + + @moduletag :core + @moduletag :mapping + @moduletag :value_type + + defp valid_cast(type) when is_atom(type) do + case ValueType.cast(type) do + {:ok, _} -> true + :error -> false + end + end + + @doc false + describe "value_type generator" do + @describetag :success + @describetag :ut + + test "validate all valid_atoms in value_type generator" do + assert ValueTypeGenerator.valid_atoms() |> Enum.all?(&valid_cast/1) + end + + property "validate generated value_type using validate_value/2" do + check all value_type <- ValueTypeGenerator.value_type() do + assert valid_cast(value_type) + end + end + + property "validate generated value_type using value_from_type/1" do + check all value_type <- ValueTypeGenerator.value_type(), + value <- ValueTypeGenerator.value_from_type(value_type) do + assert ValueType.validate_value(value_type, value) + end + end + end +end