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}