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