001package ca.uhn.fhir.narrative; 002 003/* 004 * #%L 005 * HAPI FHIR - Core Library 006 * %% 007 * Copyright (C) 2014 - 2017 University Health Network 008 * %% 009 * Licensed under the Apache License, Version 2.0 (the "License"); 010 * you may not use this file except in compliance with the License. 011 * You may obtain a copy of the License at 012 * 013 * http://www.apache.org/licenses/LICENSE-2.0 014 * 015 * Unless required by applicable law or agreed to in writing, software 016 * distributed under the License is distributed on an "AS IS" BASIS, 017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 018 * See the License for the specific language governing permissions and 019 * limitations under the License. 020 * #L% 021 */ 022import static org.apache.commons.lang3.StringUtils.isBlank; 023 024import java.io.*; 025import java.util.*; 026 027import org.apache.commons.io.IOUtils; 028import org.apache.commons.lang3.StringUtils; 029import org.hl7.fhir.instance.model.api.*; 030import org.thymeleaf.IEngineConfiguration; 031import org.thymeleaf.TemplateEngine; 032import org.thymeleaf.cache.AlwaysValidCacheEntryValidity; 033import org.thymeleaf.cache.ICacheEntryValidity; 034import org.thymeleaf.context.Context; 035import org.thymeleaf.context.ITemplateContext; 036import org.thymeleaf.engine.AttributeName; 037import org.thymeleaf.model.IProcessableElementTag; 038import org.thymeleaf.processor.IProcessor; 039import org.thymeleaf.processor.element.AbstractAttributeTagProcessor; 040import org.thymeleaf.processor.element.IElementTagStructureHandler; 041import org.thymeleaf.standard.StandardDialect; 042import org.thymeleaf.standard.expression.*; 043import org.thymeleaf.templatemode.TemplateMode; 044import org.thymeleaf.templateresolver.DefaultTemplateResolver; 045import org.thymeleaf.templateresource.ITemplateResource; 046import org.thymeleaf.templateresource.StringTemplateResource; 047 048import ca.uhn.fhir.context.ConfigurationException; 049import ca.uhn.fhir.context.FhirContext; 050import ca.uhn.fhir.model.api.IDatatype; 051import ca.uhn.fhir.parser.DataFormatException; 052import ca.uhn.fhir.rest.api.Constants; 053 054public abstract class BaseThymeleafNarrativeGenerator implements INarrativeGenerator { 055 056 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(BaseThymeleafNarrativeGenerator.class); 057 058 private boolean myApplyDefaultDatatypeTemplates = true; 059 060 private HashMap<Class<?>, String> myClassToName; 061 private boolean myCleanWhitespace = true; 062 private boolean myIgnoreFailures = true; 063 private boolean myIgnoreMissingTemplates = true; 064 private volatile boolean myInitialized; 065 private HashMap<String, String> myNameToNarrativeTemplate; 066 private TemplateEngine myProfileTemplateEngine; 067 068 /** 069 * Constructor 070 */ 071 public BaseThymeleafNarrativeGenerator() { 072 super(); 073 } 074 075 @Override 076 public void generateNarrative(FhirContext theContext, IBaseResource theResource, INarrative theNarrative) { 077 if (!myInitialized) { 078 initialize(theContext); 079 } 080 081 String name = myClassToName.get(theResource.getClass()); 082 if (name == null) { 083 name = theContext.getResourceDefinition(theResource).getName().toLowerCase(); 084 } 085 086 if (name == null || !myNameToNarrativeTemplate.containsKey(name)) { 087 if (myIgnoreMissingTemplates) { 088 ourLog.debug("No narrative template available for resorce: {}", name); 089 return; 090 } 091 throw new DataFormatException("No narrative template for class " + theResource.getClass().getCanonicalName()); 092 } 093 094 try { 095 Context context = new Context(); 096 context.setVariable("resource", theResource); 097 context.setVariable("fhirVersion", theContext.getVersion().getVersion().name()); 098 099 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}