Skip to content

Commit aaa8c70

Browse files
committed
Sitemap YAML serialization and parsing
This PR creates a new YAML format for sitemaps. It partially replaces openhab#4945 (covering YAML). It also makes further steps towards openhab#5007 Signed-off-by: Laurent Garnier <lg.hc@free.fr>
1 parent 1e33058 commit aaa8c70

26 files changed

Lines changed: 5751 additions & 40 deletions

bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ public class FileFormatResource implements RESTResource {
206206
param: my param value
207207
""";
208208

209-
private static final String YAML_ITEMS_AND_THINGS_EXAMPLE = """
209+
private static final String YAML_FULL_EXAMPLE = """
210210
version: 1
211211
things:
212212
binding:typeBridge:idBridge:
@@ -251,6 +251,15 @@ public class FileFormatResource implements RESTResource {
251251
value: my value
252252
config:
253253
param: my param value
254+
sitemaps:
255+
MySitemap:
256+
label: My Sitemap
257+
widgets:
258+
- type: Frame
259+
widgets:
260+
- type: Input
261+
item: MyItem
262+
label: My Input
254263
""";
255264

256265
private static final String DSL_SITEMAPS_EXAMPLE = """
@@ -261,6 +270,19 @@ public class FileFormatResource implements RESTResource {
261270
}
262271
""";
263272

273+
private static final String YAML_SITEMAPS_EXAMPLE = """
274+
version: 1
275+
sitemaps:
276+
MySitemap:
277+
label: My Sitemap
278+
widgets:
279+
- type: Frame
280+
widgets:
281+
- type: Input
282+
item: MyItem
283+
label: My Input
284+
""";
285+
264286
private static final String GEN_ID_PATTERN = "gen_file_format_%d";
265287

266288
private final Logger logger = LoggerFactory.getLogger(FileFormatResource.class);
@@ -463,11 +485,12 @@ public Response createFileFormatForThings(final @Context HttpHeaders httpHeaders
463485
@RolesAllowed({ Role.ADMIN })
464486
@Path("/sitemaps")
465487
@Consumes(MediaType.APPLICATION_JSON)
466-
@Produces({ "text/vnd.openhab.dsl.sitemap" })
488+
@Produces({ "text/vnd.openhab.dsl.sitemap", "application/yaml" })
467489
@Operation(operationId = "createFileFormatForSitemaps", summary = "Create file format for a list of sitemaps in registry.", security = {
468490
@SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = {
469491
@ApiResponse(responseCode = "200", description = "OK", content = {
470-
@Content(mediaType = "text/vnd.openhab.dsl.sitemap", schema = @Schema(example = DSL_SITEMAPS_EXAMPLE)) }),
492+
@Content(mediaType = "text/vnd.openhab.dsl.sitemap", schema = @Schema(example = DSL_SITEMAPS_EXAMPLE)),
493+
@Content(mediaType = "application/yaml", schema = @Schema(example = YAML_SITEMAPS_EXAMPLE)) }),
471494
@ApiResponse(responseCode = "400", description = "Payload invalid."),
472495
@ApiResponse(responseCode = "404", description = "One or more sitemaps not found in registry."),
473496
@ApiResponse(responseCode = "415", description = "Unsupported media type.") })
@@ -523,7 +546,7 @@ public Response createFileFormatForSitemaps(final @Context HttpHeaders httpHeade
523546
@Content(mediaType = "text/vnd.openhab.dsl.thing", schema = @Schema(example = DSL_THINGS_EXAMPLE)),
524547
@Content(mediaType = "text/vnd.openhab.dsl.item", schema = @Schema(example = DSL_ITEMS_EXAMPLE)),
525548
@Content(mediaType = "text/vnd.openhab.dsl.sitemap", schema = @Schema(example = DSL_SITEMAPS_EXAMPLE)),
526-
@Content(mediaType = "application/yaml", schema = @Schema(example = YAML_ITEMS_AND_THINGS_EXAMPLE)) }),
549+
@Content(mediaType = "application/yaml", schema = @Schema(example = YAML_FULL_EXAMPLE)) }),
527550
@ApiResponse(responseCode = "400", description = "Invalid JSON data."),
528551
@ApiResponse(responseCode = "415", description = "Unsupported media type.") })
529552
public Response create(final @Context HttpHeaders httpHeaders,
@@ -594,10 +617,15 @@ public Response create(final @Context HttpHeaders httpHeaders,
594617
itemSerializer.setItemsToBeSerialized(genId, items,
595618
hideChannelLinksAndMetadata ? List.of() : metadata, stateFormatters, hideDefaultParameters);
596619
}
620+
if (sitemapSerializer != null) {
621+
sitemapSerializer.setSitemapsToBeSerialized(genId, sitemaps);
622+
}
597623
if (thingSerializer != null) {
598624
thingSerializer.generateFormat(genId, outputStream);
599625
} else if (itemSerializer != null) {
600626
itemSerializer.generateFormat(genId, outputStream);
627+
} else if (sitemapSerializer != null) {
628+
sitemapSerializer.generateFormat(genId, outputStream);
601629
}
602630
break;
603631
default:
@@ -623,7 +651,7 @@ public Response parse(final @Context HttpHeaders httpHeaders,
623651
@Content(mediaType = "text/vnd.openhab.dsl.thing", schema = @Schema(example = DSL_THINGS_EXAMPLE)),
624652
@Content(mediaType = "text/vnd.openhab.dsl.item", schema = @Schema(example = DSL_ITEMS_EXAMPLE)),
625653
@Content(mediaType = "text/vnd.openhab.dsl.sitemap", schema = @Schema(example = DSL_SITEMAPS_EXAMPLE)),
626-
@Content(mediaType = "application/yaml", schema = @Schema(example = YAML_ITEMS_AND_THINGS_EXAMPLE)) }) String input) {
654+
@Content(mediaType = "application/yaml", schema = @Schema(example = YAML_FULL_EXAMPLE)) }) String input) {
627655
String contentTypeHeader = httpHeaders.getHeaderString(HttpHeaders.CONTENT_TYPE);
628656
logger.debug("parse: contentType = {}", contentTypeHeader);
629657

@@ -641,6 +669,7 @@ public Response parse(final @Context HttpHeaders httpHeaders,
641669
SitemapParser sitemapParser = getSitemapParser(contentTypeHeader);
642670
String modelName = null;
643671
String modelName2 = null;
672+
String modelName3 = null;
644673
switch (contentTypeHeader) {
645674
case "text/vnd.openhab.dsl.thing":
646675
if (thingParser == null) {
@@ -684,13 +713,13 @@ public Response parse(final @Context HttpHeaders httpHeaders,
684713
return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE)
685714
.entity("Unsupported content type '" + contentTypeHeader + "'!").build();
686715
}
687-
modelName2 = sitemapParser.startParsingFormat(input, errors, warnings);
688-
if (modelName2 == null) {
716+
modelName3 = sitemapParser.startParsingFormat(input, errors, warnings);
717+
if (modelName3 == null) {
689718
return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors)).build();
690719
}
691-
sitemaps = sitemapParser.getParsedObjects(modelName2);
720+
sitemaps = sitemapParser.getParsedObjects(modelName3);
692721
if (sitemaps.isEmpty()) {
693-
sitemapParser.finishParsingFormat(modelName2);
722+
sitemapParser.finishParsingFormat(modelName3);
694723
return Response.status(Response.Status.BAD_REQUEST).entity("No sitemap loaded from input").build();
695724
}
696725
break;
@@ -717,6 +746,19 @@ public Response parse(final @Context HttpHeaders httpHeaders,
717746
metadata = itemParser.getParsedMetadata(modelNameToUse);
718747
stateFormatters = itemParser.getParsedStateFormatters(modelNameToUse);
719748
}
749+
if (sitemapParser != null) {
750+
// Avoid parsing the input a second time
751+
if (modelName == null && modelName2 == null) {
752+
modelName3 = sitemapParser.startParsingFormat(input, errors, warnings);
753+
if (modelName3 == null) {
754+
return Response.status(Response.Status.BAD_REQUEST).entity(String.join("\n", errors))
755+
.build();
756+
}
757+
}
758+
String modelNameToUse = modelName != null ? modelName
759+
: (modelName2 != null ? modelName2 : Objects.requireNonNull(modelName3));
760+
sitemaps = sitemapParser.getParsedObjects(modelNameToUse);
761+
}
720762
break;
721763
default:
722764
return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE)
@@ -730,8 +772,8 @@ public Response parse(final @Context HttpHeaders httpHeaders,
730772
if (modelName2 != null && itemParser != null) {
731773
itemParser.finishParsingFormat(modelName2);
732774
}
733-
if (modelName2 != null && sitemapParser != null) {
734-
sitemapParser.finishParsingFormat(modelName2);
775+
if (modelName3 != null && sitemapParser != null) {
776+
sitemapParser.finishParsingFormat(modelName3);
735777
}
736778
return Response.ok(result).build();
737779
}
@@ -903,6 +945,7 @@ private Thing simulateThing(DiscoveryResult result, ThingType thingType) {
903945
private @Nullable SitemapSerializer getSitemapSerializer(String mediaType) {
904946
return switch (mediaType) {
905947
case "text/vnd.openhab.dsl.sitemap" -> sitemapSerializers.get("DSL");
948+
case "application/yaml" -> sitemapSerializers.get("YAML");
906949
default -> null;
907950
};
908951
}
@@ -927,6 +970,7 @@ private Thing simulateThing(DiscoveryResult result, ThingType thingType) {
927970
private @Nullable SitemapParser getSitemapParser(String contentType) {
928971
return switch (contentType) {
929972
case "text/vnd.openhab.dsl.sitemap" -> sitemapParsers.get("DSL");
973+
case "application/yaml" -> sitemapParsers.get("YAML");
930974
default -> null;
931975
};
932976
}

bundles/org.openhab.core.model.yaml/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,10 @@
4545
<artifactId>org.openhab.core.automation.module.script.rulesupport</artifactId>
4646
<version>${project.version}</version>
4747
</dependency>
48+
<dependency>
49+
<groupId>org.openhab.core.bundles</groupId>
50+
<artifactId>org.openhab.core.sitemap</artifactId>
51+
<version>${project.version}</version>
52+
</dependency>
4853
</dependencies>
4954
</project>

bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/YamlModelRepositoryImpl.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import org.openhab.core.model.yaml.internal.rules.YamlRuleDTO;
4949
import org.openhab.core.model.yaml.internal.rules.YamlRuleTemplateDTO;
5050
import org.openhab.core.model.yaml.internal.semantics.YamlSemanticTagDTO;
51+
import org.openhab.core.model.yaml.internal.sitemaps.YamlSitemapDTO;
5152
import org.openhab.core.model.yaml.internal.things.YamlThingDTO;
5253
import org.openhab.core.service.WatchService;
5354
import org.openhab.core.service.WatchService.Kind;
@@ -97,14 +98,15 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
9798
getElementName(YamlRuleTemplateDTO.class), // "ruleTemplates"
9899
getElementName(YamlSemanticTagDTO.class), // "tags"
99100
getElementName(YamlThingDTO.class), // "things"
100-
getElementName(YamlItemDTO.class) // "items"
101+
getElementName(YamlItemDTO.class), // "items"
102+
getElementName(YamlSitemapDTO.class) // "sitemaps"
101103
);
102104

103105
private static final String UNWANTED_EXCEPTION_TEXT = "at [Source: UNKNOWN; byte offset: #UNKNOWN] ";
104106
private static final String UNWANTED_EXCEPTION_TEXT2 = "\\n \\(through reference chain: .*";
105107

106-
private static final List<Path> WATCHED_PATHS = Stream.of("things", "items", "tags", "rules", "yaml").map(Path::of)
107-
.toList();
108+
private static final List<Path> WATCHED_PATHS = Stream.of("things", "items", "tags", "sitemaps", "rules", "yaml")
109+
.map(Path::of).toList();
108110

109111
private final Logger logger = LoggerFactory.getLogger(YamlModelRepositoryImpl.class);
110112

bundles/org.openhab.core.model.yaml/src/main/java/org/openhab/core/model/yaml/internal/items/YamlItemDTO.java

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,11 @@ public boolean isValid(@Nullable List<@NonNull String> errors, @Nullable List<@N
133133
"item \"%s\": \"dimension\" field ignored as type is not Number".formatted(name, dimension));
134134
}
135135
}
136-
if (icon != null) {
137-
subErrors.clear();
138-
ok &= isValidIcon(icon, subErrors);
139-
subErrors.forEach(error -> {
140-
addToList(errors, "invalid item \"%s\": %s".formatted(name, error));
141-
});
136+
if (icon != null && !YamlElementUtils.isValidIcon(icon)) {
137+
addToList(errors,
138+
"invalid item \"%s\": invalid value \"%s\" for \"icon\" field; it must contain a maximum of 3 segments separated by a colon, each segment matching pattern [a-zA-Z0-9_][a-zA-Z0-9_-]*"
139+
.formatted(name, icon));
140+
ok = false;
142141
}
143142
if (groups != null) {
144143
for (String gr : groups) {
@@ -206,26 +205,6 @@ public boolean isValid(@Nullable List<@NonNull String> errors, @Nullable List<@N
206205
return ok;
207206
}
208207

209-
private boolean isValidIcon(String icon, List<@NonNull String> errors) {
210-
boolean ok = true;
211-
String[] segments = icon.split(AbstractUID.SEPARATOR);
212-
int nb = segments.length;
213-
if (nb > 3) {
214-
errors.add("too many segments in value \"%s\" for \"icon\" field; maximum 3 is expected".formatted(icon));
215-
ok = false;
216-
nb = 3;
217-
}
218-
for (int i = 0; i < nb; i++) {
219-
String segment = segments[i];
220-
if (!ICON_SEGMENT_PATTERN.matcher(segment).matches()) {
221-
errors.add("segment \"%s\" in \"icon\" field not matching the expected syntax %s".formatted(segment,
222-
ICON_SEGMENT_PATTERN.pattern()));
223-
ok = false;
224-
}
225-
}
226-
return ok;
227-
}
228-
229208
private boolean isValidChannel(String channelUID, @Nullable Map<@NonNull String, @NonNull Object> configuration,
230209
List<@NonNull String> errors) {
231210
boolean ok = true;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.core.model.yaml.internal.sitemaps;
14+
15+
import java.util.List;
16+
import java.util.Objects;
17+
import java.util.Set;
18+
19+
import org.eclipse.jdt.annotation.NonNull;
20+
import org.eclipse.jdt.annotation.Nullable;
21+
import org.openhab.core.items.ItemUtil;
22+
23+
/**
24+
* This is a data transfer object that is used to serialize a sitemap rule condition.
25+
*
26+
* @author Laurent Garnier - Initial contribution
27+
*/
28+
public class YamlConditionDTO {
29+
30+
private static final Set<String> ALLOWED_CONDITIONS = Set.of("==", "!=", "<", ">", "<=", ">=");
31+
32+
public String item;
33+
public String operator;
34+
public String argument;
35+
36+
public YamlConditionDTO() {
37+
}
38+
39+
public boolean isValid(@NonNull List<@NonNull String> errors, @NonNull List<@NonNull String> warnings) {
40+
boolean ok = true;
41+
if (item != null && !ItemUtil.isValidItemName(item)) {
42+
addToList(errors,
43+
"invalid value \"%s\" for \"item\" field in condition; it must begin with a letter or underscore followed by alphanumeric characters and underscores, and must not contain any other symbols"
44+
.formatted(item));
45+
ok = false;
46+
}
47+
if (operator != null && !ALLOWED_CONDITIONS.contains(operator)) {
48+
addToList(errors, "invalid value \"%s\" for \"operator\" field in condition".formatted(operator));
49+
ok = false;
50+
}
51+
if ((item != null || operator != null) && argument == null) {
52+
addToList(errors, "\"argument\" field missing while mandatory in condition");
53+
ok = false;
54+
}
55+
return ok;
56+
}
57+
58+
private void addToList(@Nullable List<@NonNull String> list, String value) {
59+
if (list != null) {
60+
list.add(value);
61+
}
62+
}
63+
64+
@Override
65+
public int hashCode() {
66+
return Objects.hash(item, operator, argument);
67+
}
68+
69+
@Override
70+
public boolean equals(@Nullable Object obj) {
71+
if (this == obj) {
72+
return true;
73+
} else if (obj == null || getClass() != obj.getClass()) {
74+
return false;
75+
}
76+
YamlConditionDTO other = (YamlConditionDTO) obj;
77+
return Objects.equals(item, other.item) && Objects.equals(operator, other.operator)
78+
&& Objects.equals(argument, other.argument);
79+
}
80+
}

0 commit comments

Comments
 (0)