001/* 002 * Copyright 2002-2020 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.beans.factory.config; 018 019import java.io.IOException; 020import java.io.Reader; 021import java.util.Arrays; 022import java.util.Collection; 023import java.util.Collections; 024import java.util.LinkedHashMap; 025import java.util.List; 026import java.util.Map; 027import java.util.Properties; 028import java.util.Set; 029import java.util.stream.Collectors; 030 031import org.apache.commons.logging.Log; 032import org.apache.commons.logging.LogFactory; 033import org.yaml.snakeyaml.DumperOptions; 034import org.yaml.snakeyaml.LoaderOptions; 035import org.yaml.snakeyaml.Yaml; 036import org.yaml.snakeyaml.constructor.Constructor; 037import org.yaml.snakeyaml.reader.UnicodeReader; 038import org.yaml.snakeyaml.representer.Representer; 039 040import org.springframework.core.CollectionFactory; 041import org.springframework.core.io.Resource; 042import org.springframework.lang.Nullable; 043import org.springframework.util.Assert; 044import org.springframework.util.ObjectUtils; 045import org.springframework.util.StringUtils; 046 047/** 048 * Base class for YAML factories. 049 * 050 * <p>Requires SnakeYAML 1.18 or higher, as of Spring Framework 5.0.6. 051 * 052 * @author Dave Syer 053 * @author Juergen Hoeller 054 * @author Sam Brannen 055 * @since 4.1 056 */ 057public abstract class YamlProcessor { 058 059 private final Log logger = LogFactory.getLog(getClass()); 060 061 private ResolutionMethod resolutionMethod = ResolutionMethod.OVERRIDE; 062 063 private Resource[] resources = new Resource[0]; 064 065 private List<DocumentMatcher> documentMatchers = Collections.emptyList(); 066 067 private boolean matchDefault = true; 068 069 private Set<String> supportedTypes = Collections.emptySet(); 070 071 072 /** 073 * A map of document matchers allowing callers to selectively use only 074 * some of the documents in a YAML resource. In YAML documents are 075 * separated by {@code ---} lines, and each document is converted 076 * to properties before the match is made. E.g. 077 * <pre class="code"> 078 * environment: dev 079 * url: https://dev.bar.com 080 * name: Developer Setup 081 * --- 082 * environment: prod 083 * url:https://foo.bar.com 084 * name: My Cool App 085 * </pre> 086 * when mapped with 087 * <pre class="code"> 088 * setDocumentMatchers(properties -> 089 * ("prod".equals(properties.getProperty("environment")) ? MatchStatus.FOUND : MatchStatus.NOT_FOUND)); 090 * </pre> 091 * would end up as 092 * <pre class="code"> 093 * environment=prod 094 * url=https://foo.bar.com 095 * name=My Cool App 096 * </pre> 097 */ 098 public void setDocumentMatchers(DocumentMatcher... matchers) { 099 this.documentMatchers = Arrays.asList(matchers); 100 } 101 102 /** 103 * Flag indicating that a document for which all the 104 * {@link #setDocumentMatchers(DocumentMatcher...) document matchers} abstain will 105 * nevertheless match. Default is {@code true}. 106 */ 107 public void setMatchDefault(boolean matchDefault) { 108 this.matchDefault = matchDefault; 109 } 110 111 /** 112 * Method to use for resolving resources. Each resource will be converted to a Map, 113 * so this property is used to decide which map entries to keep in the final output 114 * from this factory. Default is {@link ResolutionMethod#OVERRIDE}. 115 */ 116 public void setResolutionMethod(ResolutionMethod resolutionMethod) { 117 Assert.notNull(resolutionMethod, "ResolutionMethod must not be null"); 118 this.resolutionMethod = resolutionMethod; 119 } 120 121 /** 122 * Set locations of YAML {@link Resource resources} to be loaded. 123 * @see ResolutionMethod 124 */ 125 public void setResources(Resource... resources) { 126 this.resources = resources; 127 } 128 129 /** 130 * Set the supported types that can be loaded from YAML documents. 131 * <p>If no supported types are configured, all types encountered in YAML 132 * documents will be supported. If an unsupported type is encountered, an 133 * {@link IllegalStateException} will be thrown when the corresponding YAML 134 * node is processed. 135 * @param supportedTypes the supported types, or an empty array to clear the 136 * supported types 137 * @since 5.1.16 138 * @see #createYaml() 139 */ 140 public void setSupportedTypes(Class<?>... supportedTypes) { 141 if (ObjectUtils.isEmpty(supportedTypes)) { 142 this.supportedTypes = Collections.emptySet(); 143 } 144 else { 145 Assert.noNullElements(supportedTypes, "'supportedTypes' must not contain null elements"); 146 this.supportedTypes = Arrays.stream(supportedTypes).map(Class::getName) 147 .collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet)); 148 } 149 } 150 151 /** 152 * Provide an opportunity for subclasses to process the Yaml parsed from the supplied 153 * resources. Each resource is parsed in turn and the documents inside checked against 154 * the {@link #setDocumentMatchers(DocumentMatcher...) matchers}. If a document 155 * matches it is passed into the callback, along with its representation as Properties. 156 * Depending on the {@link #setResolutionMethod(ResolutionMethod)} not all of the 157 * documents will be parsed. 158 * @param callback a callback to delegate to once matching documents are found 159 * @see #createYaml() 160 */ 161 protected void process(MatchCallback callback) { 162 Yaml yaml = createYaml(); 163 for (Resource resource : this.resources) { 164 boolean found = process(callback, yaml, resource); 165 if (this.resolutionMethod == ResolutionMethod.FIRST_FOUND && found) { 166 return; 167 } 168 } 169 } 170 171 /** 172 * Create the {@link Yaml} instance to use. 173 * <p>The default implementation sets the "allowDuplicateKeys" flag to {@code false}, 174 * enabling built-in duplicate key handling in SnakeYAML 1.18+. 175 * <p>As of Spring Framework 5.1.16, if custom {@linkplain #setSupportedTypes 176 * supported types} have been configured, the default implementation creates 177 * a {@code Yaml} instance that filters out unsupported types encountered in 178 * YAML documents. If an unsupported type is encountered, an 179 * {@link IllegalStateException} will be thrown when the node is processed. 180 * @see LoaderOptions#setAllowDuplicateKeys(boolean) 181 */ 182 protected Yaml createYaml() { 183 LoaderOptions loaderOptions = new LoaderOptions(); 184 loaderOptions.setAllowDuplicateKeys(false); 185 186 if (!this.supportedTypes.isEmpty()) { 187 return new Yaml(new FilteringConstructor(loaderOptions), new Representer(), 188 new DumperOptions(), loaderOptions); 189 } 190 return new Yaml(loaderOptions); 191 } 192 193 private boolean process(MatchCallback callback, Yaml yaml, Resource resource) { 194 int count = 0; 195 try { 196 if (logger.isDebugEnabled()) { 197 logger.debug("Loading from YAML: " + resource); 198 } 199 try (Reader reader = new UnicodeReader(resource.getInputStream())) { 200 for (Object object : yaml.loadAll(reader)) { 201 if (object != null && process(asMap(object), callback)) { 202 count++; 203 if (this.resolutionMethod == ResolutionMethod.FIRST_FOUND) { 204 break; 205 } 206 } 207 } 208 if (logger.isDebugEnabled()) { 209 logger.debug("Loaded " + count + " document" + (count > 1 ? "s" : "") + 210 " from YAML resource: " + resource); 211 } 212 } 213 } 214 catch (IOException ex) { 215 handleProcessError(resource, ex); 216 } 217 return (count > 0); 218 } 219 220 private void handleProcessError(Resource resource, IOException ex) { 221 if (this.resolutionMethod != ResolutionMethod.FIRST_FOUND && 222 this.resolutionMethod != ResolutionMethod.OVERRIDE_AND_IGNORE) { 223 throw new IllegalStateException(ex); 224 } 225 if (logger.isWarnEnabled()) { 226 logger.warn("Could not load map from " + resource + ": " + ex.getMessage()); 227 } 228 } 229 230 @SuppressWarnings("unchecked") 231 private Map<String, Object> asMap(Object object) { 232 // YAML can have numbers as keys 233 Map<String, Object> result = new LinkedHashMap<>(); 234 if (!(object instanceof Map)) { 235 // A document can be a text literal 236 result.put("document", object); 237 return result; 238 } 239 240 Map<Object, Object> map = (Map<Object, Object>) object; 241 map.forEach((key, value) -> { 242 if (value instanceof Map) { 243 value = asMap(value); 244 } 245 if (key instanceof CharSequence) { 246 result.put(key.toString(), value); 247 } 248 else { 249 // It has to be a map key in this case 250 result.put("[" + key.toString() + "]", value); 251 } 252 }); 253 return result; 254 } 255 256 private boolean process(Map<String, Object> map, MatchCallback callback) { 257 Properties properties = CollectionFactory.createStringAdaptingProperties(); 258 properties.putAll(getFlattenedMap(map)); 259 260 if (this.documentMatchers.isEmpty()) { 261 if (logger.isDebugEnabled()) { 262 logger.debug("Merging document (no matchers set): " + map); 263 } 264 callback.process(properties, map); 265 return true; 266 } 267 268 MatchStatus result = MatchStatus.ABSTAIN; 269 for (DocumentMatcher matcher : this.documentMatchers) { 270 MatchStatus match = matcher.matches(properties); 271 result = MatchStatus.getMostSpecific(match, result); 272 if (match == MatchStatus.FOUND) { 273 if (logger.isDebugEnabled()) { 274 logger.debug("Matched document with document matcher: " + properties); 275 } 276 callback.process(properties, map); 277 return true; 278 } 279 } 280 281 if (result == MatchStatus.ABSTAIN && this.matchDefault) { 282 if (logger.isDebugEnabled()) { 283 logger.debug("Matched document with default matcher: " + map); 284 } 285 callback.process(properties, map); 286 return true; 287 } 288 289 if (logger.isDebugEnabled()) { 290 logger.debug("Unmatched document: " + map); 291 } 292 return false; 293 } 294 295 /** 296 * Return a flattened version of the given map, recursively following any nested Map 297 * or Collection values. Entries from the resulting map retain the same order as the 298 * source. When called with the Map from a {@link MatchCallback} the result will 299 * contain the same values as the {@link MatchCallback} Properties. 300 * @param source the source map 301 * @return a flattened map 302 * @since 4.1.3 303 */ 304 protected final Map<String, Object> getFlattenedMap(Map<String, Object> source) { 305 Map<String, Object> result = new LinkedHashMap<>(); 306 buildFlattenedMap(result, source, null); 307 return result; 308 } 309 310 private void buildFlattenedMap(Map<String, Object> result, Map<String, Object> source, @Nullable String path) { 311 source.forEach((key, value) -> { 312 if (StringUtils.hasText(path)) { 313 if (key.startsWith("[")) { 314 key = path + key; 315 } 316 else { 317 key = path + '.' + key; 318 } 319 } 320 if (value instanceof String) { 321 result.put(key, value); 322 } 323 else if (value instanceof Map) { 324 // Need a compound key 325 @SuppressWarnings("unchecked") 326 Map<String, Object> map = (Map<String, Object>) value; 327 buildFlattenedMap(result, map, key); 328 } 329 else if (value instanceof Collection) { 330 // Need a compound key 331 @SuppressWarnings("unchecked") 332 Collection<Object> collection = (Collection<Object>) value; 333 if (collection.isEmpty()) { 334 result.put(key, ""); 335 } 336 else { 337 int count = 0; 338 for (Object object : collection) { 339 buildFlattenedMap(result, Collections.singletonMap( 340 "[" + (count++) + "]", object), key); 341 } 342 } 343 } 344 else { 345 result.put(key, (value != null ? value : "")); 346 } 347 }); 348 } 349 350 351 /** 352 * Callback interface used to process the YAML parsing results. 353 */ 354 public interface MatchCallback { 355 356 /** 357 * Process the given representation of the parsing results. 358 * @param properties the properties to process (as a flattened 359 * representation with indexed keys in case of a collection or map) 360 * @param map the result map (preserving the original value structure 361 * in the YAML document) 362 */ 363 void process(Properties properties, Map<String, Object> map); 364 } 365 366 367 /** 368 * Strategy interface used to test if properties match. 369 */ 370 public interface DocumentMatcher { 371 372 /** 373 * Test if the given properties match. 374 * @param properties the properties to test 375 * @return the status of the match 376 */ 377 MatchStatus matches(Properties properties); 378 } 379 380 381 /** 382 * Status returned from {@link DocumentMatcher#matches(java.util.Properties)}. 383 */ 384 public enum MatchStatus { 385 386 /** 387 * A match was found. 388 */ 389 FOUND, 390 391 /** 392 * No match was found. 393 */ 394 NOT_FOUND, 395 396 /** 397 * The matcher should not be considered. 398 */ 399 ABSTAIN; 400 401 /** 402 * Compare two {@link MatchStatus} items, returning the most specific status. 403 */ 404 public static MatchStatus getMostSpecific(MatchStatus a, MatchStatus b) { 405 return (a.ordinal() < b.ordinal() ? a : b); 406 } 407 } 408 409 410 /** 411 * Method to use for resolving resources. 412 */ 413 public enum ResolutionMethod { 414 415 /** 416 * Replace values from earlier in the list. 417 */ 418 OVERRIDE, 419 420 /** 421 * Replace values from earlier in the list, ignoring any failures. 422 */ 423 OVERRIDE_AND_IGNORE, 424 425 /** 426 * Take the first resource in the list that exists and use just that. 427 */ 428 FIRST_FOUND 429 } 430 431 432 /** 433 * {@link Constructor} that supports filtering of unsupported types. 434 * <p>If an unsupported type is encountered in a YAML document, an 435 * {@link IllegalStateException} will be thrown from {@link #getClassForName}. 436 */ 437 private class FilteringConstructor extends Constructor { 438 439 FilteringConstructor(LoaderOptions loaderOptions) { 440 super(loaderOptions); 441 } 442 443 @Override 444 protected Class<?> getClassForName(String name) throws ClassNotFoundException { 445 Assert.state(YamlProcessor.this.supportedTypes.contains(name), 446 () -> "Unsupported type encountered in YAML document: " + name); 447 return super.getClassForName(name); 448 } 449 } 450 451}