Skip to content

Commit 9cbf4c6

Browse files
fix: aggregation individual are now more diversified + test on :object
Signed-off-by: Gabriele Ghio <gabriele.ghio@secomind.com>
1 parent 29224e7 commit 9cbf4c6

File tree

3 files changed

+214
-50
lines changed

3 files changed

+214
-50
lines changed

lib/astarte/core/generators/interface.ex

Lines changed: 110 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
#
21
# This file is part of Astarte.
32
#
43
# Copyright 2025 SECO Mind Srl
@@ -27,11 +26,14 @@ defmodule Astarte.Core.Generators.Interface do
2726

2827
alias Astarte.Core.CQLUtils
2928
alias Astarte.Core.Interface
29+
alias Astarte.Core.Mapping
3030

3131
alias Astarte.Core.Generators.Mapping, as: MappingGenerator
3232

3333
alias Astarte.Utilities.Map, as: MapUtilities
3434

35+
@interface_max_mappings 10
36+
3537
@doc """
3638
Generates a valid Astarte Interface.
3739
@@ -46,8 +48,7 @@ defmodule Astarte.Core.Generators.Interface do
4648
type <- type(),
4749
ownership <- ownership(),
4850
aggregation <- aggregation(type),
49-
prefix <- prefix(),
50-
mappings <- mappings(type, name, major_version, prefix),
51+
mappings <- mappings(aggregation, type, name, major_version),
5152
description <- description(),
5253
doc <- doc(),
5354
params: params do
@@ -59,7 +60,6 @@ defmodule Astarte.Core.Generators.Interface do
5960
minor_version: minor_version,
6061
type: type,
6162
ownership: ownership,
62-
prefix: prefix,
6363
aggregation: aggregation,
6464
mappings: mappings,
6565
description: description,
@@ -182,40 +182,118 @@ defmodule Astarte.Core.Generators.Interface do
182182

183183
defp ownership, do: member_of([:device, :server])
184184

185-
defp prefix, do: MappingGenerator.endpoint()
185+
defp interface_mapping(params, endpoint),
186+
do: MappingGenerator.mapping(params ++ [endpoint: endpoint])
186187

187-
defp mappings(interface_type, interface_name, interface_major, prefix) do
188-
gen all retention <- MappingGenerator.retention(interface_type),
189-
reliability <- MappingGenerator.reliability(interface_type),
190-
expiry <- MappingGenerator.expiry(interface_type),
191-
allow_unset <- MappingGenerator.allow_unset(interface_type),
192-
explicit_timestamp <- MappingGenerator.explicit_timestamp(interface_type),
193-
mappings <-
194-
MappingGenerator.endpoint_segment()
195-
|> list_of(min_length: 1, max_length: 10)
196-
|> map(&Enum.uniq_by(&1, fn endpoint -> String.downcase(endpoint) end))
197-
|> bind(fn list ->
198-
list
199-
|> Enum.map(fn postfix ->
200-
MappingGenerator.mapping(
201-
interface_type: interface_type,
202-
interface_name: interface_name,
203-
interface_major: interface_major,
204-
retention: retention,
205-
reliability: reliability,
206-
expiry: expiry,
207-
allow_unset: allow_unset,
208-
explicit_timestamp: explicit_timestamp,
209-
endpoint: prefix <> "/" <> postfix
210-
)
211-
end)
212-
|> fixed_list()
213-
end) do
188+
defp interface_mappings(:individual, params) do
189+
MappingGenerator.endpoint()
190+
|> bind(&interface_mapping(params, &1))
191+
|> list_of(min_length: 1, max_length: @interface_max_mappings)
192+
|> map(&uniq_endpoints/1)
193+
end
194+
195+
defp interface_mappings(:object, params) do
196+
MappingGenerator.endpoint()
197+
|> bind(fn endpoint ->
198+
MappingGenerator.endpoint_segment()
199+
|> bind(&interface_mapping(params, endpoint <> &1))
200+
|> list_of(min_length: 1, max_length: @interface_max_mappings)
201+
|> map(&uniq_endpoints/1)
202+
end)
203+
end
204+
205+
defp mappings(aggregation, interface_type, interface_name, interface_major) do
206+
common =
207+
gen all retention <- MappingGenerator.retention(interface_type),
208+
reliability <- MappingGenerator.reliability(interface_type),
209+
expiry <- MappingGenerator.expiry(interface_type),
210+
allow_unset <- MappingGenerator.allow_unset(interface_type),
211+
explicit_timestamp <- MappingGenerator.explicit_timestamp(interface_type) do
212+
[
213+
retention: retention,
214+
reliability: reliability,
215+
expiry: expiry,
216+
allow_unset: allow_unset,
217+
explicit_timestamp: explicit_timestamp
218+
]
219+
end
220+
221+
gen all common_params <- common,
222+
other_params = [
223+
interface_type: interface_type,
224+
interface_name: interface_name,
225+
interface_major: interface_major
226+
],
227+
params = common_params ++ other_params,
228+
mappings <- interface_mappings(aggregation, params) do
214229
mappings
215230
end
216231
end
217232

218233
defp description, do: one_of([nil, string(:ascii, min_length: 1, max_length: 1000)])
219234

220235
defp doc, do: one_of([nil, string(:ascii, min_length: 1, max_length: 100_000)])
236+
237+
# Utilities
238+
239+
@uniform_param_token "%{}"
240+
241+
@doc """
242+
Filter the mappings based on the endpoint in case of aggregation: individual
243+
"""
244+
@spec uniq_endpoints(list(Mapping.t())) :: list(Mapping.t())
245+
def uniq_endpoints([%Mapping{} | _] = mappings),
246+
do:
247+
mappings
248+
|> Enum.sort_by(&endpoint_sorter/1)
249+
|> uniq_endpoints(MapSet.new(), MapSet.new())
250+
251+
defp uniq_endpoints([], _endpoints, acc), do: MapSet.to_list(acc)
252+
253+
defp uniq_endpoints([%Mapping{endpoint: endpoint} = mapping | rest], prefixes, acc) do
254+
prefix = endpoint |> uniform_param() |> tokenize_endpoint() |> make_prefix()
255+
exists? = prefix_exists?(prefixes, prefix)
256+
257+
case {prefix, exists?} do
258+
{[@uniform_param_token], _} ->
259+
[mapping]
260+
261+
{_, true} ->
262+
uniq_endpoints(rest, prefixes, acc)
263+
264+
{_, false} ->
265+
uniq_endpoints(
266+
rest,
267+
MapSet.put(prefixes, prefix),
268+
MapSet.put(acc, mapping)
269+
)
270+
end
271+
end
272+
273+
defp tokenize_endpoint(endpoint), do: String.split(endpoint, "/", trim: true)
274+
275+
defp make_prefix([], acc), do: Enum.reverse(acc)
276+
277+
defp make_prefix([@uniform_param_token | _], acc),
278+
do: make_prefix([], [@uniform_param_token | acc])
279+
280+
defp make_prefix([token | rest], acc), do: make_prefix(rest, [token | acc])
281+
defp make_prefix(tokenized) when is_list(tokenized), do: make_prefix(tokenized, [])
282+
283+
defp prefix?([], _), do: true
284+
defp prefix?([h | ta], [h | tb]), do: prefix?(ta, tb)
285+
defp prefix?(_, _), do: false
286+
287+
defp prefix_exists?(prefixes, test), do: Enum.any?(prefixes, &prefix?(&1, test))
288+
289+
defp endpoint_dept(endpoint) when is_list(endpoint),
290+
do: endpoint |> Enum.count()
291+
292+
defp endpoint_sorter(%Mapping{endpoint: endpoint}),
293+
do: endpoint |> tokenize_endpoint() |> endpoint_dept()
294+
295+
@uniform_param_regex ~r/%{\w+}/i
296+
297+
defp uniform_param(endpoint),
298+
do: Regex.replace(@uniform_param_regex, endpoint, @uniform_param_token)
221299
end

lib/astarte/core/generators/mapping.ex

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -101,34 +101,35 @@ defmodule Astarte.Core.Generators.Mapping do
101101
"""
102102
@spec endpoint() :: StreamData.t(String.t())
103103
def endpoint do
104-
gen all prefix <- endpoint_segment(),
105-
segments <-
104+
gen all segments <-
106105
frequency([
107106
{3, endpoint_segment()},
108107
{1, endpoint_segment_param()}
109108
])
110109
|> list_of(min_length: 1, max_length: 5) do
111-
"/" <> prefix <> "/" <> Enum.join(segments, "/")
110+
Enum.join(segments, "")
112111
end
113112
end
114113

115114
@doc """
116115
Generates a generic endpoint segment.
117116
"""
118117
@spec endpoint_segment() :: StreamData.t(StreamData.t(String.t()))
119-
def endpoint_segment do
120-
gen all prefix <- string(@unix_prefix_path_chars, length: 1),
121-
rest <- string(@unix_path_chars, max_length: 19) do
122-
prefix <> rest
123-
end
124-
end
118+
def endpoint_segment, do: endpoint_segment_content() |> map(fn content -> "/" <> content end)
125119

126120
@doc """
127121
Generates a parametrized endpoint segment.
128122
"""
129123
@spec endpoint_segment_param() :: StreamData.t(StreamData.t(String.t()))
130124
def endpoint_segment_param,
131-
do: endpoint_segment() |> map(fn segment -> "%{" <> segment <> "}" end)
125+
do: endpoint_segment_content() |> map(fn content -> "/%{" <> content <> "}" end)
126+
127+
defp endpoint_segment_content do
128+
gen all prefix <- string(@unix_prefix_path_chars, length: 1),
129+
rest <- string(@unix_path_chars, max_length: 19) do
130+
prefix <> rest
131+
end
132+
end
132133

133134
defp endpoint_id(interface_name, interface_major, endpoint),
134135
do: constant(CQLUtils.endpoint_id(interface_name, interface_major, endpoint))
@@ -154,12 +155,12 @@ defmodule Astarte.Core.Generators.Mapping do
154155

155156
@doc false
156157
@spec reliability(:datastream | :properties) ::
157-
StreamData.t(:unreliable | :guaranteed | :unique)
158+
StreamData.t(:unreliable | :guaranteed | :unique | nil)
158159
def reliability(:datastream), do: member_of([:unreliable, :guaranteed, :unique])
159-
def reliability(_), do: constant(nil)
160+
def reliability(_), do: nil
160161

161162
@doc false
162-
@spec explicit_timestamp(:datastream | :properties) :: StreamData.t(nil | boolean())
163+
@spec explicit_timestamp(:datastream | :properties) :: StreamData.t(boolean())
163164
def explicit_timestamp(:datastream), do: boolean()
164165
def explicit_timestamp(_), do: constant(false)
165166

@@ -174,15 +175,16 @@ defmodule Astarte.Core.Generators.Mapping do
174175
def expiry(_), do: constant(0)
175176

176177
@doc false
177-
@spec database_retention_policy(:datastream | :properties) :: StreamData.t(:no_ttl | :use_ttl)
178+
@spec database_retention_policy(:datastream | :properties) ::
179+
StreamData.t(:no_ttl | :use_ttl | nil)
178180
def database_retention_policy(:datastream), do: member_of([:no_ttl, :use_ttl])
179-
def database_retention_policy(_), do: constant(nil)
181+
def database_retention_policy(_), do: nil
180182

181183
@doc false
182184
@spec database_retention_ttl(:datastream | :properties, :use_ttl | :no_ttl) ::
183-
StreamData.t(nil | non_neg_integer())
185+
StreamData.t(non_neg_integer() | nil)
184186
def database_retention_ttl(:datastream, :use_ttl), do: integer(60..1_048_576)
185-
def database_retention_ttl(_, _), do: constant(nil)
187+
def database_retention_ttl(_, _), do: nil
186188

187189
@doc false
188190
@spec allow_unset(:datastream | :properties) :: StreamData.t(boolean())

test/astarte/core/generators/interface_test.exs

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,67 @@ defmodule Astarte.Core.Generators.InterfaceTest do
2323
use ExUnit.Case, async: true
2424
use ExUnitProperties
2525

26-
alias Astarte.Core.Generators.Interface, as: InterfaceGenerator
2726
alias Astarte.Core.Interface
27+
alias Astarte.Core.Mapping
28+
29+
alias Astarte.Core.Generators.Interface, as: InterfaceGenerator
2830

2931
@moduletag :core
3032
@moduletag :interface
3133

34+
@endpoint_cases [
35+
{["/AbCde", "/AbCde/QwEr", "/XyZ"], ["/AbCde", "/XyZ"]},
36+
{["/shop/Prd45", "/shop/Prd45/Details"], ["/shop/Prd45"]},
37+
{["/alpha/Beta", "/alpha/Beta/Gamma", "/alpha/Delta"], ["/alpha/Beta", "/alpha/Delta"]},
38+
{["/%{RootB}/Xy", "/%{RootA}", "/%{RootA}/Xy", "/Abc"], ["/%{RootA}"]},
39+
{["/%{RootB}", "/%{RootB}/Seg1", "/%{RootB}/Seg1/Seg2"], ["/%{RootB}"]},
40+
{["/api/%{CA}", "/api/%{CA}/status", "/api/%{VA}/status"], ["/api/%{CA}"]},
41+
{["/api/%{V_1}", "/api/%{V_1}/ping", "/api/%{V_2}"], ["/api/%{V_1}"]},
42+
{["/Ux9/Za2", "/Ux9/Za2/Rt7"], ["/Ux9/Za2"]},
43+
{["/foo/%{idA}", "/foo/%{idB}/bar"], ["/foo/%{idA}"]},
44+
{["/foo/%{Ab1}", "/foo/%{Ab1}/X", "/bar/Baz"], ["/foo/%{Ab1}", "/bar/Baz"]},
45+
{["/mmNn/ooPP", "/mmNn/ooPP/qqRR"], ["/mmNn/ooPP"]},
46+
{["/docs/%{Slug_1}", "/docs/%{Slug_1}/v1", "/docs/%{Slug_2}"], ["/docs/%{Slug_1}"]},
47+
{["/AA/BB", "/AA/BB/CC", "/DD"], ["/AA/BB", "/DD"]},
48+
{["/AA/BB/CC", "/AA/BB"], ["/AA/BB"]},
49+
{["/xY/Za", "/xY/Za/Q1", "/xY/Zb"], ["/xY/Za", "/xY/Zb"]},
50+
{["/p1q2/RS", "/p1q2/RS/TT/UU"], ["/p1q2/RS"]},
51+
{["/user/%{U_123}", "/user/%{U_123}/orders", "/user/%{U_456}"], ["/user/%{U_123}"]},
52+
{["/calc/%{ModeA}/run", "/calc/%{ModeB}/run"], ["/calc/%{ModeA}/run"]},
53+
{["/calc/%{ModeC}/run/now", "/calc/%{ModeC}/run"], ["/calc/%{ModeC}/run"]},
54+
{["/A1b2C3", "/A1b2C3/D4e5"], ["/A1b2C3"]},
55+
{["/AABB", "/AABBCC"], ["/AABB", "/AABBCC"]},
56+
{["/srv/%{EnvX}/cfg", "/srv/%{EnvX}/cfg/edit"], ["/srv/%{EnvX}/cfg"]},
57+
{["/srv/%{Env_1}/cfg", "/srv/%{Env_2}/cfg"], ["/srv/%{Env_1}/cfg"]},
58+
{["/%{ROOT_X}", "/%{ROOT_Y}", "/%{ROOT_Y}/x"], ["/%{ROOT_X}"]},
59+
{["/root/%{Id1}/x", "/root/%{Id2}/x/y"], ["/root/%{Id1}/x"]},
60+
{["/root/%{Abc}/x/y", "/root/%{Abc}/x"], ["/root/%{Abc}/x"]},
61+
{["/kLm/Nop", "/kLm/Nop/Qrs", "/kLm/Tuv"], ["/kLm/Nop", "/kLm/Tuv"]},
62+
{["/data/%{KeyA}/val", "/data/%{KeyB}/val"], ["/data/%{KeyA}/val"]},
63+
{["/data/%{KeyA}/val", "/data/%{KeyA}/val/x"], ["/data/%{KeyA}/val"]},
64+
{["/zZz/%{Pid}/q", "/zZz/%{Pid}/q/r"], ["/zZz/%{Pid}/q"]},
65+
{["/zZz/%{Pid1}/q", "/zZz/%{Pid2}/w"], ["/zZz/%{Pid1}/q"]},
66+
{["/Mix/Aa", "/Mix/Aa/Bb", "/Mix/Cc"], ["/Mix/Aa", "/Mix/Cc"]},
67+
{["/auth/Login", "/auth/Login/Step2"], ["/auth/Login"]},
68+
{["/auth/%{FlowA}/step/next", "/auth/%{FlowA}/step"], ["/auth/%{FlowA}/step"]},
69+
{["/auth/%{FlowA}/step", "/auth/%{FlowB}"], ["/auth/%{FlowB}"]},
70+
{["/r1/r2/r3", "/r1/r2"], ["/r1/r2"]},
71+
{["/r1/%{VarA}", "/r1/%{VarA}/x", "/r2"], ["/r1/%{VarA}", "/r2"]},
72+
{["/A_/B_", "/A_/B_/C_"], ["/A_/B_"]},
73+
{["/A_/B_/C_", "/A_/B_/C_/D_"], ["/A_/B_/C_"]},
74+
{["/node/%{N1}/leaf/%{L2}", "/node/%{N1}/leaf"], ["/node/%{N1}/leaf"]},
75+
{["/node/%{N1}/leaf/%{L2}", "/node/%{N2}/leaf/%{L3}"], ["/node/%{N1}/leaf/%{L2}"]},
76+
{["/Sx/Tx", "/Sx/Tx/Ux", "/Sx/Vx"], ["/Sx/Tx", "/Sx/Vx"]},
77+
{["/G1/H2", "/G1/H2/I3/J4"], ["/G1/H2"]},
78+
{["/lib/%{Pkg}/v", "/lib/%{Pkg}/v/1"], ["/lib/%{Pkg}/v"]},
79+
{["/lib/%{PkgA}/v", "/lib/%{PkgB}/v"], ["/lib/%{PkgA}/v"]},
80+
{["/cat/%{C1}/dog/%{D2}", "/cat/%{C1}/dog/%{D2}/x"], ["/cat/%{C1}/dog/%{D2}"]},
81+
{["/alpha/%{A1}/beta", "/alpha/%{A1}/beta/gamma"], ["/alpha/%{A1}/beta"]},
82+
{["/alpha/%{A1}/beta", "/alpha/%{A2}"], ["/alpha/%{A2}"]},
83+
{["/xLong/SegName", "/xLong/SegName/Next"], ["/xLong/SegName"]},
84+
{["/Ping", "/Ping/Pong", "/Pang"], ["/Ping", "/Pang"]}
85+
]
86+
3287
@doc false
3388
describe "interface generator" do
3489
@describetag :success
@@ -51,6 +106,35 @@ defmodule Astarte.Core.Generators.InterfaceTest do
51106
end
52107
end
53108

109+
test "validate endpoints in :individual" do
110+
for {endpoints, results} <- @endpoint_cases do
111+
mappings =
112+
for endpoint <- endpoints do
113+
%Mapping{endpoint: endpoint}
114+
end
115+
116+
uniq_mappings = InterfaceGenerator.uniq_endpoints(mappings)
117+
118+
uniq_endpoints =
119+
for %Mapping{endpoint: endpoint} <- uniq_mappings do
120+
endpoint
121+
end
122+
123+
assert MapSet.new(results) == MapSet.new(uniq_endpoints)
124+
end
125+
end
126+
127+
property "validate endpoints in aggregation :object must be the same" do
128+
check all %Interface{mappings: mappings} <-
129+
InterfaceGenerator.interface(aggregation: :object),
130+
endpoints =
131+
mappings
132+
|> Enum.map(fn %Mapping{endpoint: endpoint} -> endpoint end)
133+
|> Enum.map(&Regex.replace(~r"/[^/]+$", &1, "")) do
134+
assert 1 == endpoints |> Enum.uniq() |> length()
135+
end
136+
end
137+
54138
@tag issue: 45
55139
property "custom interface creation" do
56140
gen_interface_changes =

0 commit comments

Comments
 (0)