Skip to content

Commit 704d716

Browse files
committed
Yaml config enhancements to support variable substitutions, includes and packages
Signed-off-by: Jimmy Tanagra <[email protected]>
1 parent e9700f8 commit 704d716

37 files changed

+927
-4
lines changed

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.openhab.core.model.yaml.YamlModelRepository;
4444
import org.openhab.core.model.yaml.internal.semantics.YamlSemanticTagDTO;
4545
import org.openhab.core.model.yaml.internal.things.YamlThingDTO;
46+
import org.openhab.core.model.yaml.internal.util.preprocessor.YamlPreprocessor;
4647
import org.openhab.core.service.WatchService;
4748
import org.openhab.core.service.WatchService.Kind;
4849
import org.osgi.service.component.annotations.Activate;
@@ -79,6 +80,7 @@
7980
* @author Laurent Garnier - Added basic version management
8081
* @author Laurent Garnier - Added method generateSyntaxFromElements + new parameters
8182
* for method isValid
83+
* @author Jimmy Tanagra - Added Yaml preprocessor to support !include, !secret, variable substitutions, and packages
8284
*/
8385
@NonNullByDefault
8486
@Component(immediate = true)
@@ -87,6 +89,8 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
8789
private static final String VERSION = "version";
8890
private static final String READ_ONLY = "readOnly";
8991
private static final Set<String> KNOWN_ELEMENTS = Set.of( //
92+
// "version", "readOnly" are reserved keys
93+
// "variables" and "packages" are reserved elements for YamlPreprocessor
9094
getElementName(YamlSemanticTagDTO.class), // "tags"
9195
getElementName(YamlThingDTO.class) // "things"
9296
);
@@ -110,8 +114,8 @@ public YamlModelRepositoryImpl(@Reference(target = WatchService.CONFIG_WATCHER_F
110114
.disable(YAMLGenerator.Feature.SPLIT_LINES) // do not split long lines
111115
.enable(YAMLGenerator.Feature.INDENT_ARRAYS_WITH_INDICATOR) // indent arrays
112116
.enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) // use quotes only where necessary
113-
.enable(YAMLParser.Feature.PARSE_BOOLEAN_LIKE_WORDS_AS_STRINGS).build(); // do not parse ON/OFF/... as
114-
// booleans
117+
.enable(YAMLParser.Feature.PARSE_BOOLEAN_LIKE_WORDS_AS_STRINGS) // do not parse ON/OFF/... as booleans
118+
.build();
115119
this.objectMapper = new ObjectMapper(yamlFactory);
116120
objectMapper.findAndRegisterModules();
117121
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
@@ -158,7 +162,7 @@ public void deactivate() {
158162
public synchronized void processWatchEvent(Kind kind, Path fullPath) {
159163
Path relativePath = watchPath.relativize(fullPath);
160164
String modelName = relativePath.toString();
161-
if (relativePath.startsWith("automation") || !modelName.endsWith(".yaml")) {
165+
if (relativePath.startsWith("automation") || !modelName.endsWith(".yaml") || modelName.endsWith(".inc.yaml")) {
162166
logger.trace("Ignored {}", fullPath);
163167
return;
164168
}
@@ -167,7 +171,7 @@ public synchronized void processWatchEvent(Kind kind, Path fullPath) {
167171
if (kind == WatchService.Kind.DELETE) {
168172
removeModel(modelName);
169173
} else if (!Files.isHidden(fullPath) && Files.isReadable(fullPath) && !Files.isDirectory(fullPath)) {
170-
JsonNode fileContent = objectMapper.readTree(fullPath.toFile());
174+
JsonNode fileContent = objectMapper.valueToTree(YamlPreprocessor.load(fullPath));
171175

172176
// check version
173177
JsonNode versionNode = fileContent.get(VERSION);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright (c) 2010-2025 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.util.preprocessor;
14+
15+
import java.util.Map;
16+
17+
/**
18+
* The {@link IncludeObject} represents an object constructed from an <code>!include</code> node
19+
* to be processed by the {@link YamlPreprocessor}.
20+
*
21+
* @author Jimmy Tanagra - Initial contribution
22+
*/
23+
record IncludeObject(String fileName, Map<String, String> vars) {
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/*
2+
* Copyright (c) 2010-2025 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.util.preprocessor;
14+
15+
import java.util.Map;
16+
import java.util.regex.Matcher;
17+
import java.util.regex.Pattern;
18+
19+
import org.eclipse.jdt.annotation.Nullable;
20+
import org.slf4j.Logger;
21+
import org.slf4j.LoggerFactory;
22+
import org.yaml.snakeyaml.DumperOptions;
23+
import org.yaml.snakeyaml.LoaderOptions;
24+
import org.yaml.snakeyaml.constructor.AbstractConstruct;
25+
import org.yaml.snakeyaml.constructor.Construct;
26+
import org.yaml.snakeyaml.constructor.Constructor;
27+
import org.yaml.snakeyaml.error.YAMLException;
28+
import org.yaml.snakeyaml.nodes.MappingNode;
29+
import org.yaml.snakeyaml.nodes.Node;
30+
import org.yaml.snakeyaml.nodes.NodeId;
31+
import org.yaml.snakeyaml.nodes.ScalarNode;
32+
import org.yaml.snakeyaml.nodes.Tag;
33+
34+
/**
35+
* The {@link ModelConstructor} adds extended functionality to the
36+
* {@link Constructor} class to support:
37+
*
38+
* - Nested variable interpolation
39+
* - <code>!include</code> tag for including other YAML files
40+
*
41+
* @author Jimmy Tanagra - Initial contribution
42+
*/
43+
class ModelConstructor extends Constructor {
44+
45+
private static final Tag INCLUDE_TAG = new Tag("!include");
46+
private static final int MAX_VAR_NESTING_DEPTH = 10;
47+
48+
// The valid syntax is a subset of bash variable substitution syntax:
49+
// ${var} - if var is not set, return empty string
50+
// ${var-default} - if var is set but empty, return empty
51+
// ${var:-default} - if var is set but empty, return default
52+
private static final Pattern VARIABLE_PATTERN = Pattern
53+
.compile("\\$\\{\\s*((?<name>\\w+)((?<separator>:?-)(?<default>.*)?)?)\\s*\\}");
54+
55+
private final Logger logger = LoggerFactory.getLogger(ModelConstructor.class);
56+
57+
private final Map<String, String> variables;
58+
59+
public ModelConstructor(Map<String, String> variables) {
60+
super(new LoaderOptions());
61+
62+
this.variables = variables;
63+
64+
this.yamlConstructors.put(INCLUDE_TAG, new ConstructInclude());
65+
this.yamlConstructors.put(Tag.STR, new ConstructInterpolation());
66+
this.yamlConstructors.put(Tag.NULL, new ConstructNull());
67+
logger.trace("ModelConstructor created with vars: {}", variables);
68+
}
69+
70+
public class ConstructInterpolation extends AbstractConstruct {
71+
72+
public Object construct(Node node) {
73+
ScalarNode scalarNode = (ScalarNode) node;
74+
75+
String value = (String) constructScalar(scalarNode);
76+
77+
// don't interpolate single quoted strings
78+
if (scalarNode.getScalarStyle() == DumperOptions.ScalarStyle.SINGLE_QUOTED) {
79+
return value;
80+
}
81+
82+
Matcher matcher = VARIABLE_PATTERN.matcher(value);
83+
if (!matcher.find()) {
84+
return value;
85+
}
86+
87+
String interpolated = value;
88+
int nestedLevel = 0;
89+
90+
do {
91+
interpolated = matcher.replaceAll(match -> {
92+
String variableName = match.group("name");
93+
String defaultValue = match.group("default");
94+
String separator = match.group("separator");
95+
String variableValue = variables.get(variableName);
96+
try {
97+
String resolved = resolveVariable(variableName, separator, defaultValue);
98+
logger.debug("Interpolating variable {} => {}", variableName, resolved);
99+
return resolved;
100+
} catch (MissingVariableException e) {
101+
logger.warn("{}", e.getMessage());
102+
}
103+
return "";
104+
});
105+
if (nestedLevel++ > MAX_VAR_NESTING_DEPTH) {
106+
throw new YAMLException("Variable nesting is too deep in " + value);
107+
}
108+
matcher = VARIABLE_PATTERN.matcher(interpolated);
109+
} while (matcher.find());
110+
111+
// resolve the interpolated node because the type might change e.g.
112+
// ${var1} => 1: originally a STR, it now becomes !!int 1
113+
ModelResolver resolver = new ModelResolver();
114+
Tag newTag = resolver.resolve(NodeId.scalar, interpolated, true);
115+
ScalarNode replacedNode = new ScalarNode(newTag, interpolated, scalarNode.getStartMark(),
116+
scalarNode.getEndMark(), scalarNode.getScalarStyle());
117+
// now find the correct constructor for the new node
118+
Construct constructor = yamlConstructors.get(newTag);
119+
if (constructor == null) {
120+
throw new YAMLException("No constructor found for substituted value '%s' => '%s' with tag %s"
121+
.formatted(value, interpolated, newTag.toString()));
122+
}
123+
// finally, construct the new node
124+
return constructor.construct(replacedNode);
125+
}
126+
127+
/**
128+
* Implement the logic for missing and unset variables
129+
*
130+
* @param name - variable name in the template
131+
* @param separator - separator in the template, can be :-, -
132+
* @param defaultValue - default value or the error in the template
133+
* @return the value to resolve in the template
134+
*/
135+
private String resolveVariable(String name, @Nullable String separator, @Nullable String defaultValue) {
136+
String value = variables.get(name);
137+
if (value != null && !value.isEmpty()) {
138+
return value.toString();
139+
}
140+
// variable is either unset or empty
141+
if (separator != null) {
142+
if (separator.startsWith(":")) {
143+
if (value == null || value.isEmpty()) {
144+
return defaultValue;
145+
}
146+
} else {
147+
if (value == null) {
148+
return defaultValue;
149+
}
150+
}
151+
}
152+
return "";
153+
}
154+
155+
public class MissingVariableException extends YAMLException {
156+
private static final long serialVersionUID = 1L;
157+
158+
public MissingVariableException(String message) {
159+
super(message);
160+
}
161+
}
162+
}
163+
164+
private class ConstructNull extends AbstractConstruct {
165+
166+
// Return an empty string for null values so that the keys are not removed from the map
167+
// This matches the behavior of Jackson's parser, otherwise some tests will fail
168+
@Override
169+
public Object construct(Node node) {
170+
if (node != null) {
171+
constructScalar((ScalarNode) node);
172+
}
173+
return "";
174+
}
175+
}
176+
177+
private class ConstructInclude extends AbstractConstruct {
178+
179+
@Override
180+
public Object construct(Node node) {
181+
logger.debug("Constructing !include node: {}", node);
182+
if (node instanceof ScalarNode scalarNode) {
183+
String value = constructScalar(scalarNode).trim();
184+
return new IncludeObject(value, Map.of());
185+
} else if (node instanceof MappingNode mappingNode) {
186+
Map<Object, Object> includeOptions = constructMapping(mappingNode);
187+
188+
String fileName = (String) includeOptions.get("file");
189+
if (fileName == null) {
190+
logger.warn("Missing 'file' key in !include: {}", includeOptions);
191+
return Map.of();
192+
}
193+
194+
Map<String, String> vars = new java.util.HashMap<>(variables);
195+
Object varsObj = includeOptions.get("vars");
196+
if (varsObj instanceof Map<?, ?> varsMap) {
197+
varsMap.forEach((key, val) -> {
198+
if (key instanceof String k && val != null) {
199+
vars.put(k, val.toString());
200+
}
201+
});
202+
} else if (varsObj != null) {
203+
logger.warn("Invalid 'vars' in !include: {}. Expected a map.", varsObj);
204+
}
205+
206+
return new IncludeObject(fileName, vars);
207+
} else {
208+
logger.warn("Invalid !include argument type: {}", node.getClass().getName());
209+
}
210+
return Map.of();
211+
}
212+
}
213+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (c) 2010-2025 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.util.preprocessor;
14+
15+
import java.util.regex.Pattern;
16+
17+
import org.eclipse.jdt.annotation.Nullable;
18+
import org.yaml.snakeyaml.nodes.Tag;
19+
import org.yaml.snakeyaml.resolver.Resolver;
20+
21+
/**
22+
* The {@link ModelResolver} class is a custom resolver for YAML
23+
* that follows the openHAB model syntax.
24+
*
25+
* Resolves only "true" and "false" (case insensitive) to boolean values.
26+
* This matches the behavior of Jackson's PARSE_BOOLEAN_LIKE_WORDS_AS_STRINGS
27+
* https://github.com/FasterXML/jackson-dataformats-text/blob/58de8215e55fab161ec8a287bd6b7099926926dd/yaml/src/main/java/com/fasterxml/jackson/dataformat/yaml/YAMLParser.java#L689-L691
28+
*
29+
* The default behavior of SnakeYAML is to resolve "True", "False", "yes",
30+
* "no", "on", "off", etc. to boolean values.
31+
*
32+
* @author Jimmy Tanagra - Initial contribution
33+
*/
34+
class ModelResolver extends Resolver {
35+
public static final Pattern BOOL = Pattern.compile("^(?:true|false)$", Pattern.CASE_INSENSITIVE);
36+
37+
@Override
38+
public void addImplicitResolver(@Nullable Tag tag, @Nullable Pattern regexp, @Nullable String first, int limit) {
39+
if (tag == Tag.BOOL) {
40+
regexp = BOOL;
41+
first = "tTfF";
42+
}
43+
super.addImplicitResolver(tag, regexp, first, limit);
44+
}
45+
}

0 commit comments

Comments
 (0)