|
| 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 | +} |
0 commit comments