View Javadoc
1   /*
2    * Copyright 2002-2014 the original author or authors.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *      http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  
17  package org.springframework.beans.factory.config;
18  
19  import java.io.IOException;
20  import java.io.InputStream;
21  import java.util.AbstractMap;
22  import java.util.Arrays;
23  import java.util.Collection;
24  import java.util.Collections;
25  import java.util.LinkedHashMap;
26  import java.util.List;
27  import java.util.Map;
28  import java.util.Map.Entry;
29  import java.util.Properties;
30  import java.util.Set;
31  
32  import org.apache.commons.logging.Log;
33  import org.apache.commons.logging.LogFactory;
34  import org.yaml.snakeyaml.Yaml;
35  import org.yaml.snakeyaml.constructor.Constructor;
36  import org.yaml.snakeyaml.nodes.MappingNode;
37  import org.yaml.snakeyaml.parser.ParserException;
38  
39  import org.springframework.core.io.Resource;
40  import org.springframework.util.Assert;
41  import org.springframework.util.StringUtils;
42  
43  /**
44   * Base class for Yaml factories.
45   *
46   * @author Dave Syer
47   * @since 4.1
48   */
49  public abstract class YamlProcessor {
50  
51  	private final Log logger = LogFactory.getLog(getClass());
52  
53  	private ResolutionMethod resolutionMethod = ResolutionMethod.OVERRIDE;
54  
55  	private Resource[] resources = new Resource[0];
56  
57  	private List<DocumentMatcher> documentMatchers = Collections.emptyList();
58  
59  	private boolean matchDefault = true;
60  
61  
62  	/**
63  	 * A map of document matchers allowing callers to selectively use only
64  	 * some of the documents in a YAML resource. In YAML documents are
65  	 * separated by <code>---<code> lines, and each document is converted
66  	 * to properties before the match is made. E.g.
67  	 *
68  	 * <pre class="code">
69  	 * environment: dev
70  	 * url: http://dev.bar.com
71  	 * name: Developer Setup
72  	 * ---
73  	 * environment: prod
74  	 * url:http://foo.bar.com
75  	 * name: My Cool App
76  	 * </pre>
77  	 *
78  	 * when mapped with
79  	 * <code>documentMatchers = YamlProcessor.mapMatcher({"environment": "prod"})</code>
80  	 * would end up as
81  	 *
82  	 * <pre class="code">
83  	 * environment=prod
84  	 * url=http://foo.bar.com
85  	 * name=My Cool App
86  	 * url=http://dev.bar.com
87  	 * </pre>
88  	 * @param matchers a map of keys to value patterns (regular expressions)
89  	 */
90  	public void setDocumentMatchers(DocumentMatcher... matchers) {
91  		this.documentMatchers = Arrays.asList(matchers);
92  	}
93  
94  	/**
95  	 * Flag indicating that a document for which all the
96  	 * {@link #setDocumentMatchers(DocumentMatcher...) document matchers} abstain will
97  	 * nevertheless match.
98  	 * @param matchDefault the flag to set (default true)
99  	 */
100 	public void setMatchDefault(boolean matchDefault) {
101 		this.matchDefault = matchDefault;
102 	}
103 
104 	/**
105 	 * Method to use for resolving resources. Each resource will be converted to a Map, so
106 	 * this property is used to decide which map entries to keep in the final output from
107 	 * this factory.
108 	 * @param resolutionMethod the resolution method to set (defaults to
109 	 * {@link ResolutionMethod#OVERRIDE}).
110 	 */
111 	public void setResolutionMethod(ResolutionMethod resolutionMethod) {
112 		Assert.notNull(resolutionMethod, "ResolutionMethod must not be null");
113 		this.resolutionMethod = resolutionMethod;
114 	}
115 
116 	/**
117 	 * Set locations of YAML {@link Resource resources} to be loaded.
118 	 * @see ResolutionMethod
119 	 */
120 	public void setResources(Resource... resources) {
121 		this.resources = resources;
122 	}
123 
124 
125 	/**
126 	 * Provide an opportunity for subclasses to process the Yaml parsed from the supplied
127 	 * resources. Each resource is parsed in turn and the documents inside checked against
128 	 * the {@link #setDocumentMatchers(DocumentMatcher...) matchers}. If a document
129 	 * matches it is passed into the callback, along with its representation as
130 	 * Properties. Depending on the {@link #setResolutionMethod(ResolutionMethod)} not all
131 	 * of the documents will be parsed.
132 	 * @param callback a callback to delegate to once matching documents are found
133 	 * @see #createYaml()
134 	 */
135 	protected void process(MatchCallback callback) {
136 		Yaml yaml = createYaml();
137 		for (Resource resource : this.resources) {
138 			boolean found = process(callback, yaml, resource);
139 			if (this.resolutionMethod == ResolutionMethod.FIRST_FOUND && found) {
140 				return;
141 			}
142 		}
143 	}
144 
145 	/**
146 	 * Create the {@link Yaml} instance to use.
147 	 */
148 	protected Yaml createYaml() {
149 		return new Yaml(new StrictMapAppenderConstructor());
150 	}
151 
152 	private boolean process(MatchCallback callback, Yaml yaml, Resource resource) {
153 		int count = 0;
154 		try {
155 			if (this.logger.isDebugEnabled()) {
156 				this.logger.debug("Loading from YAML: " + resource);
157 			}
158 			InputStream stream = resource.getInputStream();
159 			try {
160 				for (Object object : yaml.loadAll(stream)) {
161 					if (object != null && process(asMap(object), callback)) {
162 						count++;
163 						if (this.resolutionMethod == ResolutionMethod.FIRST_FOUND) {
164 							break;
165 						}
166 					}
167 				}
168 				if (this.logger.isDebugEnabled()) {
169 					this.logger.debug("Loaded " + count + " document" + (count > 1 ? "s" : "") +
170 							" from YAML resource: " + resource);
171 				}
172 			}
173 			finally {
174 				stream.close();
175 			}
176 		}
177 		catch (IOException ex) {
178 			handleProcessError(resource, ex);
179 		}
180 		return (count > 0);
181 	}
182 
183 	private void handleProcessError(Resource resource, IOException ex) {
184 		if (this.resolutionMethod != ResolutionMethod.FIRST_FOUND &&
185 				this.resolutionMethod != ResolutionMethod.OVERRIDE_AND_IGNORE) {
186 			throw new IllegalStateException(ex);
187 		}
188 		if (this.logger.isWarnEnabled()) {
189 			this.logger.warn("Could not load map from " + resource + ": " + ex.getMessage());
190 		}
191 	}
192 
193 	@SuppressWarnings("unchecked")
194 	private Map<String, Object> asMap(Object object) {
195 		// YAML can have numbers as keys
196 		Map<String, Object> result = new LinkedHashMap<String, Object>();
197 		if (!(object instanceof Map)) {
198 			// A document can be a text literal
199 			result.put("document", object);
200 			return result;
201 		}
202 
203 		Map<Object, Object> map = (Map<Object, Object>) object;
204 		for (Entry<Object, Object> entry : map.entrySet()) {
205 			Object value = entry.getValue();
206 			if (value instanceof Map) {
207 				value = asMap(value);
208 			}
209 			Object key = entry.getKey();
210 			if (key instanceof CharSequence) {
211 				result.put(key.toString(), value);
212 			}
213 			else {
214 				// It has to be a map key in this case
215 				result.put("[" + key.toString() + "]", value);
216 			}
217 		}
218 		return result;
219 	}
220 
221 	private boolean process(Map<String, Object> map, MatchCallback callback) {
222 		Properties properties = new Properties();
223 		properties.putAll(getFlattenedMap(map));
224 
225 		if (this.documentMatchers.isEmpty()) {
226 			if (this.logger.isDebugEnabled()) {
227 				this.logger.debug("Merging document (no matchers set)" + map);
228 			}
229 			callback.process(properties, map);
230 			return true;
231 		}
232 
233 		MatchStatus result = MatchStatus.ABSTAIN;
234 		for (DocumentMatcher matcher : this.documentMatchers) {
235 			MatchStatus match = matcher.matches(properties);
236 			result = MatchStatus.getMostSpecific(match, result);
237 			if (match == MatchStatus.FOUND) {
238 				if (this.logger.isDebugEnabled()) {
239 					this.logger.debug("Matched document with document matcher: " + properties);
240 				}
241 				callback.process(properties, map);
242 				return true;
243 			}
244 		}
245 
246 		if (result == MatchStatus.ABSTAIN && this.matchDefault) {
247 			if (this.logger.isDebugEnabled()) {
248 				this.logger.debug("Matched document with default matcher: " + map);
249 			}
250 			callback.process(properties, map);
251 			return true;
252 		}
253 
254 		this.logger.debug("Unmatched document");
255 		return false;
256 	}
257 
258 	/**
259 	 * Return a flattened version of the given map, recursively following any nested Map
260 	 * or Collection values. Entries from the resulting map retain the same order as the
261 	 * source. When called with the Map from a {@link MatchCallback} the result will
262 	 * contain the same values as the {@link MatchCallback} Properties.
263 	 * @param source the source map
264 	 * @return a flattened map
265 	 * @since 4.1.3
266 	 */
267 	protected final Map<String, Object> getFlattenedMap(Map<String, Object> source) {
268 		Map<String, Object> result = new LinkedHashMap<String, Object>();
269 		buildFlattenedMap(result, source, null);
270 		return result;
271 	}
272 
273 	private void buildFlattenedMap(Map<String, Object> result, Map<String, Object> source, String path) {
274 		for (Entry<String, Object> entry : source.entrySet()) {
275 			String key = entry.getKey();
276 			if (StringUtils.hasText(path)) {
277 				if (key.startsWith("[")) {
278 					key = path + key;
279 				}
280 				else {
281 					key = path + "." + key;
282 				}
283 			}
284 			Object value = entry.getValue();
285 			if (value instanceof String) {
286 				result.put(key, value);
287 			}
288 			else if (value instanceof Map) {
289 				// Need a compound key
290 				@SuppressWarnings("unchecked")
291 				Map<String, Object> map = (Map<String, Object>) value;
292 				buildFlattenedMap(result, map, key);
293 			}
294 			else if (value instanceof Collection) {
295 				// Need a compound key
296 				@SuppressWarnings("unchecked")
297 				Collection<Object> collection = (Collection<Object>) value;
298 				int count = 0;
299 				for (Object object : collection) {
300 					buildFlattenedMap(result,
301 							Collections.singletonMap("[" + (count++) + "]", object), key);
302 				}
303 			}
304 			else {
305 				result.put(key, value == null ? "" : value);
306 			}
307 		}
308 	}
309 
310 
311 	/**
312 	 * Callback interface used to process properties in a resulting map.
313 	 */
314 	public interface MatchCallback {
315 
316 		/**
317 		 * Process the properties.
318 		 * @param properties the properties to process
319 		 * @param map a mutable result map
320 		 */
321 		void process(Properties properties, Map<String, Object> map);
322 	}
323 
324 
325 	/**
326 	 * Strategy interface used to test if properties match.
327 	 */
328 	public interface DocumentMatcher {
329 
330 		/**
331 		 * Test if the given properties match.
332 		 * @param properties the properties to test
333 		 * @return the status of the match.
334 		 */
335 		MatchStatus matches(Properties properties);
336 	}
337 
338 
339 	/**
340 	 * Status returned from {@link DocumentMatcher#matches(java.util.Properties)}
341 	 */
342 	public enum MatchStatus {
343 
344 		/**
345 		 * A match was found.
346 		 */
347 		FOUND,
348 
349 		/**
350 		 * No match was found.
351 		 */
352 		NOT_FOUND,
353 
354 		/**
355 		 * The matcher should not be considered.
356 		 */
357 		ABSTAIN;
358 
359 		/**
360 		 * Compare two {@link MatchStatus} items, returning the most specific status.
361 		 */
362 		public static MatchStatus getMostSpecific(MatchStatus a, MatchStatus b) {
363 			return (a.ordinal() < b.ordinal() ? a : b);
364 		}
365 	}
366 
367 
368 	/**
369 	 * Method to use for resolving resources.
370 	 */
371 	public enum ResolutionMethod {
372 
373 		/**
374 		 * Replace values from earlier in the list.
375 		 */
376 		OVERRIDE,
377 
378 		/**
379 		 * Replace values from earlier in the list, ignoring any failures.
380 		 */
381 		OVERRIDE_AND_IGNORE,
382 
383 		/**
384 		 * Take the first resource in the list that exists and use just that.
385 		 */
386 		FIRST_FOUND
387 	}
388 
389 
390 	/**
391 	 * A specialized {@link Constructor} that checks for duplicate keys.
392 	 */
393 	protected static class StrictMapAppenderConstructor extends Constructor {
394 
395 		public 	StrictMapAppenderConstructor() {
396 			super();
397 		}
398 
399 		@Override
400 		protected Map<Object, Object> constructMapping(MappingNode node) {
401 			try {
402 				return super.constructMapping(node);
403 			} catch (IllegalStateException e) {
404 				throw new ParserException("while parsing MappingNode",
405 						node.getStartMark(), e.getMessage(), node.getEndMark());
406 			}
407 		}
408 
409 		@Override
410 		protected Map<Object, Object> createDefaultMap() {
411 			final Map<Object, Object> delegate = super.createDefaultMap();
412 			return new AbstractMap<Object, Object>() {
413 				@Override
414 				public Object put(Object key, Object value) {
415 					if (delegate.containsKey(key)) {
416 						throw new IllegalStateException("duplicate key: " + key);
417 					}
418 					return delegate.put(key, value);
419 				}
420 				@Override
421 				public Set<Entry<Object, Object>> entrySet() {
422 					return delegate.entrySet();
423 				}
424 			};
425 		}
426 
427 	}
428 
429 }