View Javadoc
1   package ca.uhn.fhir.narrative;
2   
3   /*
4    * #%L
5    * HAPI FHIR - Core Library
6    * %%
7    * Copyright (C) 2014 - 2018 University Health Network
8    * %%
9    * Licensed under the Apache License, Version 2.0 (the "License");
10   * you may not use this file except in compliance with the License.
11   * You may obtain a copy of the License at
12   * 
13   * http://www.apache.org/licenses/LICENSE-2.0
14   * 
15   * Unless required by applicable law or agreed to in writing, software
16   * distributed under the License is distributed on an "AS IS" BASIS,
17   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18   * See the License for the specific language governing permissions and
19   * limitations under the License.
20   * #L%
21   */
22  import static org.apache.commons.lang3.StringUtils.isBlank;
23  
24  import java.io.*;
25  import java.util.*;
26  
27  import org.apache.commons.io.IOUtils;
28  import org.apache.commons.lang3.StringUtils;
29  import org.hl7.fhir.instance.model.api.*;
30  import org.thymeleaf.IEngineConfiguration;
31  import org.thymeleaf.TemplateEngine;
32  import org.thymeleaf.cache.AlwaysValidCacheEntryValidity;
33  import org.thymeleaf.cache.ICacheEntryValidity;
34  import org.thymeleaf.context.Context;
35  import org.thymeleaf.context.ITemplateContext;
36  import org.thymeleaf.engine.AttributeName;
37  import org.thymeleaf.model.IProcessableElementTag;
38  import org.thymeleaf.processor.IProcessor;
39  import org.thymeleaf.processor.element.AbstractAttributeTagProcessor;
40  import org.thymeleaf.processor.element.IElementTagStructureHandler;
41  import org.thymeleaf.standard.StandardDialect;
42  import org.thymeleaf.standard.expression.*;
43  import org.thymeleaf.templatemode.TemplateMode;
44  import org.thymeleaf.templateresolver.DefaultTemplateResolver;
45  import org.thymeleaf.templateresource.ITemplateResource;
46  import org.thymeleaf.templateresource.StringTemplateResource;
47  
48  import ca.uhn.fhir.context.ConfigurationException;
49  import ca.uhn.fhir.context.FhirContext;
50  import ca.uhn.fhir.model.api.IDatatype;
51  import ca.uhn.fhir.parser.DataFormatException;
52  import ca.uhn.fhir.rest.api.Constants;
53  
54  public abstract class BaseThymeleafNarrativeGenerator implements INarrativeGenerator {
55  
56  	private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(BaseThymeleafNarrativeGenerator.class);
57  
58  	private boolean myApplyDefaultDatatypeTemplates = true;
59  
60  	private HashMap<Class<?>, String> myClassToName;
61  	private boolean myCleanWhitespace = true;
62  	private boolean myIgnoreFailures = true;
63  	private boolean myIgnoreMissingTemplates = true;
64  	private volatile boolean myInitialized;
65  	private HashMap<String, String> myNameToNarrativeTemplate;
66  	private TemplateEngine myProfileTemplateEngine;
67  
68  	/**
69  	 * Constructor
70  	 */
71  	public BaseThymeleafNarrativeGenerator() {
72  		super();
73  	}
74  
75  	@Override
76  	public void generateNarrative(FhirContext theContext, IBaseResource theResource, INarrative theNarrative) {
77  		if (!myInitialized) {
78  			initialize(theContext);
79  		}
80  
81  		String name = myClassToName.get(theResource.getClass());
82  		if (name == null) {
83  			name = theContext.getResourceDefinition(theResource).getName().toLowerCase();
84  		}
85  
86  		if (name == null || !myNameToNarrativeTemplate.containsKey(name)) {
87  			if (myIgnoreMissingTemplates) {
88  				ourLog.debug("No narrative template available for resorce: {}", name);
89  				return;
90  			}
91  			throw new DataFormatException("No narrative template for class " + theResource.getClass().getCanonicalName());
92  		}
93  
94  		try {
95  			Context context = new Context();
96  			context.setVariable("resource", theResource);
97  			context.setVariable("fhirVersion", theContext.getVersion().getVersion().name());
98  
99  			String result = myProfileTemplateEngine.process(name, context);
100 
101 			if (myCleanWhitespace) {
102 				ourLog.trace("Pre-whitespace cleaning: ", result);
103 				result = cleanWhitespace(result);
104 				ourLog.trace("Post-whitespace cleaning: ", result);
105 			}
106 
107 			if (isBlank(result)) {
108 				return;
109 			}
110 
111 			theNarrative.setDivAsString(result);
112 			theNarrative.setStatusAsString("generated");
113 			return;
114 		} catch (Exception e) {
115 			if (myIgnoreFailures) {
116 				ourLog.error("Failed to generate narrative", e);
117 				try {
118 					theNarrative.setDivAsString("<div>No narrative available - Error: " + e.getMessage() + "</div>");
119 				} catch (Exception e1) {
120 					// last resort..
121 				}
122 				theNarrative.setStatusAsString("empty");
123 				return;
124 			}
125 				throw new DataFormatException(e);
126 			}
127 	}
128 
129 	protected abstract List<String> getPropertyFile();
130 
131 	private synchronized void initialize(final FhirContext theContext) {
132 		if (myInitialized) {
133 			return;
134 		}
135 
136 		ourLog.info("Initializing narrative generator");
137 
138 		myClassToName = new HashMap<Class<?>, String>();
139 		myNameToNarrativeTemplate = new HashMap<String, String>();
140 
141 		List<String> propFileName = getPropertyFile();
142 
143 		try {
144 			if (myApplyDefaultDatatypeTemplates) {
145 				loadProperties(DefaultThymeleafNarrativeGenerator.NARRATIVES_PROPERTIES);
146 			}
147 			for (String next : propFileName) {
148 				loadProperties(next);
149 			}
150 		} catch (IOException e) {
151 			ourLog.info("Failed to load property file " + propFileName, e);
152 			throw new ConfigurationException("Can not load property file " + propFileName, e);
153 		}
154 
155 		{
156 			myProfileTemplateEngine = new TemplateEngine();
157 			ProfileResourceResolver resolver = new ProfileResourceResolver();
158 			myProfileTemplateEngine.setTemplateResolver(resolver);
159 			StandardDialect dialect = new StandardDialect() {
160 				@Override
161 				public Set<IProcessor> getProcessors(String theDialectPrefix) {
162 					Set<IProcessor> retVal = super.getProcessors(theDialectPrefix);
163 					retVal.add(new NarrativeAttributeProcessor(theContext, theDialectPrefix));
164 					return retVal;
165 				}
166 
167 			};
168 			myProfileTemplateEngine.setDialect(dialect);
169 		}
170 
171 		myInitialized = true;
172 	}
173 
174 	/**
175 	 * If set to <code>true</code> (which is the default), most whitespace will be trimmed from the generated narrative
176 	 * before it is returned.
177 	 * <p>
178 	 * Note that in order to preserve formatting, not all whitespace is trimmed. Repeated whitespace characters (e.g.
179 	 * "\n \n ") will be trimmed to a single space.
180 	 * </p>
181 	 */
182 	public boolean isCleanWhitespace() {
183 		return myCleanWhitespace;
184 	}
185 
186 	/**
187 	 * If set to <code>true</code>, which is the default, if any failure occurs during narrative generation the
188 	 * generator will suppress any generated exceptions, and simply return a default narrative indicating that no
189 	 * narrative is available.
190 	 */
191 	public boolean isIgnoreFailures() {
192 		return myIgnoreFailures;
193 	}
194 
195 	/**
196 	 * If set to true, will return an empty narrative block for any profiles where no template is available
197 	 */
198 	public boolean isIgnoreMissingTemplates() {
199 		return myIgnoreMissingTemplates;
200 	}
201 
202 	private void loadProperties(String propFileName) throws IOException {
203 		ourLog.debug("Loading narrative properties file: {}", propFileName);
204 
205 		Properties file = new Properties();
206 
207 		InputStream resource = loadResource(propFileName);
208 		file.load(resource);
209 		for (Object nextKeyObj : file.keySet()) {
210 			String nextKey = (String) nextKeyObj;
211 			if (nextKey.endsWith(".profile")) {
212 				String name = nextKey.substring(0, nextKey.indexOf(".profile"));
213 				if (isBlank(name)) {
214 					continue;
215 				}
216 
217 				String narrativePropName = name + ".narrative";
218 				String narrativeName = file.getProperty(narrativePropName);
219 				if (isBlank(narrativeName)) {
220 					//FIXME resource leak
221 					throw new ConfigurationException("Found property '" + nextKey + "' but no corresponding property '" + narrativePropName + "' in file " + propFileName);
222 				}
223 
224 				if (StringUtils.isNotBlank(narrativeName)) {
225 					String narrative = IOUtils.toString(loadResource(narrativeName), Constants.CHARSET_UTF8);
226 					myNameToNarrativeTemplate.put(name, narrative);
227 				}
228 
229 			} else if (nextKey.endsWith(".class")) {
230 
231 				String name = nextKey.substring(0, nextKey.indexOf(".class"));
232 				if (isBlank(name)) {
233 					continue;
234 				}
235 
236 				String className = file.getProperty(nextKey);
237 
238 				Class<?> clazz;
239 				try {
240 					clazz = Class.forName(className);
241 				} catch (ClassNotFoundException e) {
242 					ourLog.debug("Unknown datatype class '{}' identified in narrative file {}", name, propFileName);
243 					clazz = null;
244 				}
245 
246 				if (clazz != null) {
247 					myClassToName.put(clazz, name);
248 				}
249 
250 			} else if (nextKey.endsWith(".narrative")) {
251 				String name = nextKey.substring(0, nextKey.indexOf(".narrative"));
252 				if (isBlank(name)) {
253 					continue;
254 				}
255 				String narrativePropName = name + ".narrative";
256 				String narrativeName = file.getProperty(narrativePropName);
257 				if (StringUtils.isNotBlank(narrativeName)) {
258 					String narrative = IOUtils.toString(loadResource(narrativeName), Constants.CHARSET_UTF8);
259 					myNameToNarrativeTemplate.put(name, narrative);
260 				}
261 				continue;
262 			} else if (nextKey.endsWith(".title")) {
263 				ourLog.debug("Ignoring title property as narrative generator no longer generates titles: {}", nextKey);
264 			} else {
265 				throw new ConfigurationException("Invalid property name: " + nextKey);
266 			}
267 
268 		}
269 	}
270 
271 	private InputStream loadResource(String name) throws IOException {
272 		if (name.startsWith("classpath:")) {
273 			String cpName = name.substring("classpath:".length());
274 			InputStream resource = DefaultThymeleafNarrativeGenerator.class.getResourceAsStream(cpName);
275 			if (resource == null) {
276 				resource = DefaultThymeleafNarrativeGenerator.class.getResourceAsStream("/" + cpName);
277 				if (resource == null) {
278 					throw new IOException("Can not find '" + cpName + "' on classpath");
279 				}
280 			}
281 			//FIXME resource leak
282 			return resource;
283 		} else if (name.startsWith("file:")) {
284 			File file = new File(name.substring("file:".length()));
285 			if (file.exists() == false) {
286 				throw new IOException("File not found: " + file.getAbsolutePath());
287 			}
288 			return new FileInputStream(file);
289 		} else {
290 			throw new IOException("Invalid resource name: '" + name + "' (must start with classpath: or file: )");
291 		}
292 	}
293 
294 	/**
295 	 * If set to <code>true</code> (which is the default), most whitespace will be trimmed from the generated narrative
296 	 * before it is returned.
297 	 * <p>
298 	 * Note that in order to preserve formatting, not all whitespace is trimmed. Repeated whitespace characters (e.g.
299 	 * "\n \n ") will be trimmed to a single space.
300 	 * </p>
301 	 */
302 	public void setCleanWhitespace(boolean theCleanWhitespace) {
303 		myCleanWhitespace = theCleanWhitespace;
304 	}
305 
306 	/**
307 	 * If set to <code>true</code>, which is the default, if any failure occurs during narrative generation the
308 	 * generator will suppress any generated exceptions, and simply return a default narrative indicating that no
309 	 * narrative is available.
310 	 */
311 	public void setIgnoreFailures(boolean theIgnoreFailures) {
312 		myIgnoreFailures = theIgnoreFailures;
313 	}
314 
315 	/**
316 	 * If set to true, will return an empty narrative block for any profiles where no template is available
317 	 */
318 	public void setIgnoreMissingTemplates(boolean theIgnoreMissingTemplates) {
319 		myIgnoreMissingTemplates = theIgnoreMissingTemplates;
320 	}
321 
322 	static String cleanWhitespace(String theResult) {
323 		StringBuilder b = new StringBuilder();
324 		boolean inWhitespace = false;
325 		boolean betweenTags = false;
326 		boolean lastNonWhitespaceCharWasTagEnd = false;
327 		boolean inPre = false;
328 		for (int i = 0; i < theResult.length(); i++) {
329 			char nextChar = theResult.charAt(i);
330 			if (inPre) {
331 				b.append(nextChar);
332 				continue;
333 			} else if (nextChar == '>') {
334 				b.append(nextChar);
335 				betweenTags = true;
336 				lastNonWhitespaceCharWasTagEnd = true;
337 				continue;
338 			} else if (nextChar == '\n' || nextChar == '\r') {
339 				// if (inWhitespace) {
340 				// b.append(' ');
341 				// inWhitespace = false;
342 				// }
343 				continue;
344 			}
345 
346 			if (betweenTags) {
347 				if (Character.isWhitespace(nextChar)) {
348 					inWhitespace = true;
349 				} else if (nextChar == '<') {
350 					if (inWhitespace && !lastNonWhitespaceCharWasTagEnd) {
351 						b.append(' ');
352 					}
353 					inWhitespace = false;
354 					b.append(nextChar);
355 					inWhitespace = false;
356 					betweenTags = false;
357 					lastNonWhitespaceCharWasTagEnd = false;
358 					if (i + 3 < theResult.length()) {
359 						char char1 = Character.toLowerCase(theResult.charAt(i + 1));
360 						char char2 = Character.toLowerCase(theResult.charAt(i + 2));
361 						char char3 = Character.toLowerCase(theResult.charAt(i + 3));
362 						char char4 = Character.toLowerCase((i + 4 < theResult.length()) ? theResult.charAt(i + 4) : ' ');
363 						if (char1 == 'p' && char2 == 'r' && char3 == 'e') {
364 							inPre = true;
365 						} else if (char1 == '/' && char2 == 'p' && char3 == 'r' && char4 == 'e') {
366 							inPre = false;
367 						}
368 					}
369 				} else {
370 					lastNonWhitespaceCharWasTagEnd = false;
371 					if (inWhitespace) {
372 						b.append(' ');
373 						inWhitespace = false;
374 					}
375 					b.append(nextChar);
376 				}
377 			} else {
378 				b.append(nextChar);
379 			}
380 		}
381 		return b.toString();
382 	}
383 
384 	public class NarrativeAttributeProcessor extends AbstractAttributeTagProcessor {
385 
386 		private FhirContext myContext;
387 
388 		protected NarrativeAttributeProcessor(FhirContext theContext, String theDialectPrefix) {
389 			super(TemplateMode.XML, theDialectPrefix, null, false, "narrative", true, 0, true);
390 			myContext = theContext;
391 		}
392 
393 		@SuppressWarnings("unchecked")
394 		@Override
395 		protected void doProcess(ITemplateContext theContext, IProcessableElementTag theTag, AttributeName theAttributeName, String theAttributeValue, IElementTagStructureHandler theStructureHandler) {
396 			IEngineConfiguration configuration = theContext.getConfiguration();
397 			IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(configuration);
398 
399 			final IStandardExpression expression = expressionParser.parseExpression(theContext, theAttributeValue);
400 			final Object value = expression.execute(theContext);
401 
402 			if (value == null) {
403 				return;
404 			}
405 
406 			Context context = new Context();
407 			context.setVariable("fhirVersion", myContext.getVersion().getVersion().name());
408 			context.setVariable("resource", value);
409 
410 			String name = null;
411 
412 			Class<? extends Object> nextClass = value.getClass();
413 			do {
414 				name = myClassToName.get(nextClass);
415 				nextClass = nextClass.getSuperclass();
416 			} while (name == null && nextClass.equals(Object.class) == false);
417 
418 			if (name == null) {
419 				if (value instanceof IBaseResource) {
420 					name = myContext.getResourceDefinition((Class<? extends IBaseResource>) value).getName();
421 				} else if (value instanceof IDatatype) {
422 					name = value.getClass().getSimpleName();
423 					name = name.substring(0, name.length() - 2);
424 				} else if (value instanceof IBaseDatatype) {
425 					name = value.getClass().getSimpleName();
426 					if (name.endsWith("Type")) {
427 						name = name.substring(0, name.length() - 4);
428 					}
429 				} else {
430 					throw new DataFormatException("Don't know how to determine name for type: " + value.getClass());
431 				}
432 				name = name.toLowerCase();
433 				if (!myNameToNarrativeTemplate.containsKey(name)) {
434 					name = null;
435 				}
436 			}
437 
438 			if (name == null) {
439 				if (myIgnoreMissingTemplates) {
440 					ourLog.debug("No narrative template available for type: {}", value.getClass());
441 					return;
442 				}
443 				throw new DataFormatException("No narrative template for class " + value.getClass());
444 			}
445 
446 			String result = myProfileTemplateEngine.process(name, context);
447 			String trim = result.trim();
448 
449 			theStructureHandler.setBody(trim, true);
450 
451 		}
452 
453 	}
454 
455 	// public class NarrativeAttributeProcessor extends AbstractAttributeTagProcessor {
456 	//
457 	// private FhirContext myContext;
458 	//
459 	// protected NarrativeAttributeProcessor(FhirContext theContext) {
460 	// super()
461 	// myContext = theContext;
462 	// }
463 	//
464 	// @Override
465 	// public int getPrecedence() {
466 	// return 0;
467 	// }
468 	//
469 	// @SuppressWarnings("unchecked")
470 	// @Override
471 	// protected ProcessorResult processAttribute(Arguments theArguments, Element theElement, String theAttributeName) {
472 	// final String attributeValue = theElement.getAttributeValue(theAttributeName);
473 	//
474 	// final Configuration configuration = theArguments.getConfiguration();
475 	// final IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(configuration);
476 	//
477 	// final IStandardExpression expression = expressionParser.parseExpression(configuration, theArguments, attributeValue);
478 	// final Object value = expression.execute(configuration, theArguments);
479 	//
480 	// theElement.removeAttribute(theAttributeName);
481 	// theElement.clearChildren();
482 	//
483 	// if (value == null) {
484 	// return ProcessorResult.ok();
485 	// }
486 	//
487 	// Context context = new Context();
488 	// context.setVariable("fhirVersion", myContext.getVersion().getVersion().name());
489 	// context.setVariable("resource", value);
490 	//
491 	// String name = null;
492 	// if (value != null) {
493 	// Class<? extends Object> nextClass = value.getClass();
494 	// do {
495 	// name = myClassToName.get(nextClass);
496 	// nextClass = nextClass.getSuperclass();
497 	// } while (name == null && nextClass.equals(Object.class) == false);
498 	//
499 	// if (name == null) {
500 	// if (value instanceof IBaseResource) {
501 	// name = myContext.getResourceDefinition((Class<? extends IBaseResource>) value).getName();
502 	// } else if (value instanceof IDatatype) {
503 	// name = value.getClass().getSimpleName();
504 	// name = name.substring(0, name.length() - 2);
505 	// } else if (value instanceof IBaseDatatype) {
506 	// name = value.getClass().getSimpleName();
507 	// if (name.endsWith("Type")) {
508 	// name = name.substring(0, name.length() - 4);
509 	// }
510 	// } else {
511 	// throw new DataFormatException("Don't know how to determine name for type: " + value.getClass());
512 	// }
513 	// name = name.toLowerCase();
514 	// if (!myNameToNarrativeTemplate.containsKey(name)) {
515 	// name = null;
516 	// }
517 	// }
518 	// }
519 	//
520 	// if (name == null) {
521 	// if (myIgnoreMissingTemplates) {
522 	// ourLog.debug("No narrative template available for type: {}", value.getClass());
523 	// return ProcessorResult.ok();
524 	// } else {
525 	// throw new DataFormatException("No narrative template for class " + value.getClass());
526 	// }
527 	// }
528 	//
529 	// String result = myProfileTemplateEngine.process(name, context);
530 	// String trim = result.trim();
531 	// if (!isBlank(trim + "AAA")) {
532 	// Document dom = getXhtmlDOMFor(new StringReader(trim));
533 	//
534 	// Element firstChild = (Element) dom.getFirstChild();
535 	// for (int i = 0; i < firstChild.getChildren().size(); i++) {
536 	// Node next = firstChild.getChildren().get(i);
537 	// if (i == 0 && firstChild.getChildren().size() == 1) {
538 	// if (next instanceof org.thymeleaf.dom.Text) {
539 	// org.thymeleaf.dom.Text nextText = (org.thymeleaf.dom.Text) next;
540 	// nextText.setContent(nextText.getContent().trim());
541 	// }
542 	// }
543 	// theElement.addChild(next);
544 	// }
545 	//
546 	// }
547 	//
548 	//
549 	// return ProcessorResult.ok();
550 	// }
551 	//
552 	// }
553 
554 	// public String generateString(Patient theValue) {
555 	//
556 	// Context context = new Context();
557 	// context.setVariable("resource", theValue);
558 	// String result =
559 	// myProfileTemplateEngine.process("ca/uhn/fhir/narrative/Patient.html",
560 	// context);
561 	//
562 	// ourLog.info("Result: {}", result);
563 	//
564 	// return result;
565 	// }
566 
567 	private final class ProfileResourceResolver extends DefaultTemplateResolver {
568 
569 		@Override
570 		protected boolean computeResolvable(IEngineConfiguration theConfiguration, String theOwnerTemplate, String theTemplate, Map<String, Object> theTemplateResolutionAttributes) {
571 			String template = myNameToNarrativeTemplate.get(theTemplate);
572 			return template != null;
573 		}
574 
575 		@Override
576 		protected TemplateMode computeTemplateMode(IEngineConfiguration theConfiguration, String theOwnerTemplate, String theTemplate, Map<String, Object> theTemplateResolutionAttributes) {
577 			return TemplateMode.XML;
578 		}
579 
580 		@Override
581 		protected ITemplateResource computeTemplateResource(IEngineConfiguration theConfiguration, String theOwnerTemplate, String theTemplate, Map<String, Object> theTemplateResolutionAttributes) {
582 			String template = myNameToNarrativeTemplate.get(theTemplate);
583 			return new StringTemplateResource(template);
584 		}
585 
586 		@Override
587 		protected ICacheEntryValidity computeValidity(IEngineConfiguration theConfiguration, String theOwnerTemplate, String theTemplate, Map<String, Object> theTemplateResolutionAttributes) {
588 			return AlwaysValidCacheEntryValidity.INSTANCE;
589 		}
590 
591 	}
592 
593 }