001package ca.uhn.fhir.narrative; 002 003/* 004 * #%L 005 * HAPI FHIR - Core Library 006 * %% 007 * Copyright (C) 2014 - 2018 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.messageresolver.IMessageResolver; 038import org.thymeleaf.model.IProcessableElementTag; 039import org.thymeleaf.processor.IProcessor; 040import org.thymeleaf.processor.element.AbstractAttributeTagProcessor; 041import org.thymeleaf.processor.element.IElementTagStructureHandler; 042import org.thymeleaf.standard.StandardDialect; 043import org.thymeleaf.standard.expression.*; 044import org.thymeleaf.templatemode.TemplateMode; 045import org.thymeleaf.templateresolver.DefaultTemplateResolver; 046import org.thymeleaf.templateresource.ITemplateResource; 047import org.thymeleaf.templateresource.StringTemplateResource; 048 049import ca.uhn.fhir.context.ConfigurationException; 050import ca.uhn.fhir.context.FhirContext; 051import ca.uhn.fhir.model.api.IDatatype; 052import ca.uhn.fhir.parser.DataFormatException; 053import ca.uhn.fhir.rest.api.Constants; 054 055public abstract class BaseThymeleafNarrativeGenerator implements INarrativeGenerator { 056 057 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(BaseThymeleafNarrativeGenerator.class); 058 059 private boolean myApplyDefaultDatatypeTemplates = true; 060 061 private HashMap<Class<?>, String> myClassToName; 062 private boolean myCleanWhitespace = true; 063 private boolean myIgnoreFailures = true; 064 private boolean myIgnoreMissingTemplates = true; 065 private volatile boolean myInitialized; 066 private HashMap<String, String> myNameToNarrativeTemplate; 067 private TemplateEngine myProfileTemplateEngine; 068 069 private IMessageResolver resolver; 070 071 /** 072 * Constructor 073 */ 074 public BaseThymeleafNarrativeGenerator() { 075 super(); 076 } 077 078 @Override 079 public void generateNarrative(FhirContext theContext, IBaseResource theResource, INarrative theNarrative) { 080 if (!myInitialized) { 081 initialize(theContext); 082 } 083 084 String name = myClassToName.get(theResource.getClass()); 085 if (name == null) { 086 name = theContext.getResourceDefinition(theResource).getName().toLowerCase(); 087 } 088 089 if (name == null || !myNameToNarrativeTemplate.containsKey(name)) { 090 if (myIgnoreMissingTemplates) { 091 ourLog.debug("No narrative template available for resorce: {}", name); 092 return; 093 } 094 throw new DataFormatException("No narrative template for class " + theResource.getClass().getCanonicalName()); 095 } 096 097 try { 098 Context context = new Context(); 099 context.setVariable("resource", theResource); 100 context.setVariable("fhirVersion", theContext.getVersion().getVersion().name()); 101 102 String result = myProfileTemplateEngine.process(name, context); 103 104 if (myCleanWhitespace) { 105 ourLog.trace("Pre-whitespace cleaning: ", result); 106 result = cleanWhitespace(result); 107 ourLog.trace("Post-whitespace cleaning: ", result); 108 } 109 110 if (isBlank(result)) { 111 return; 112 } 113 114 theNarrative.setDivAsString(result); 115 theNarrative.setStatusAsString("generated"); 116 return; 117 } catch (Exception e) { 118 if (myIgnoreFailures) { 119 ourLog.error("Failed to generate narrative", e); 120 try { 121 theNarrative.setDivAsString("<div>No narrative available - Error: " + e.getMessage() + "</div>"); 122 } catch (Exception e1) { 123 // last resort.. 124 } 125 theNarrative.setStatusAsString("empty"); 126 return; 127 } 128 throw new DataFormatException(e); 129 } 130 } 131 132 protected abstract List<String> getPropertyFile(); 133 134 private synchronized void initialize(final FhirContext theContext) { 135 if (myInitialized) { 136 return; 137 } 138 139 ourLog.info("Initializing narrative generator"); 140 141 myClassToName = new HashMap<Class<?>, String>(); 142 myNameToNarrativeTemplate = new HashMap<String, String>(); 143 144 List<String> propFileName = getPropertyFile(); 145 146 try { 147 if (myApplyDefaultDatatypeTemplates) { 148 loadProperties(DefaultThymeleafNarrativeGenerator.NARRATIVES_PROPERTIES); 149 } 150 for (String next : propFileName) { 151 loadProperties(next); 152 } 153 } catch (IOException e) { 154 ourLog.info("Failed to load property file " + propFileName, e); 155 throw new ConfigurationException("Can not load property file " + propFileName, e); 156 } 157 158 { 159 myProfileTemplateEngine = new TemplateEngine(); 160 ProfileResourceResolver resolver = new ProfileResourceResolver(); 161 myProfileTemplateEngine.setTemplateResolver(resolver); 162 StandardDialect dialect = new StandardDialect() { 163 @Override 164 public Set<IProcessor> getProcessors(String theDialectPrefix) { 165 Set<IProcessor> retVal = super.getProcessors(theDialectPrefix); 166 retVal.add(new NarrativeAttributeProcessor(theContext, theDialectPrefix)); 167 return retVal; 168 } 169 170 }; 171 myProfileTemplateEngine.setDialect(dialect); 172 if (this.resolver != null) { 173 myProfileTemplateEngine.setMessageResolver(this.resolver); 174 } 175 } 176 177 myInitialized = true; 178 } 179 180 public void setMessageResolver(IMessageResolver resolver) { 181 this.resolver = resolver; 182 if (myProfileTemplateEngine != null && resolver != null) { 183 myProfileTemplateEngine.setMessageResolver(resolver); 184 } 185 } 186 187 /** 188 * If set to <code>true</code> (which is the default), most whitespace will be trimmed from the generated narrative 189 * before it is returned. 190 * <p> 191 * Note that in order to preserve formatting, not all whitespace is trimmed. Repeated whitespace characters (e.g. 192 * "\n \n ") will be trimmed to a single space. 193 * </p> 194 */ 195 public boolean isCleanWhitespace() { 196 return myCleanWhitespace; 197 } 198 199 /** 200 * If set to <code>true</code>, which is the default, if any failure occurs during narrative generation the 201 * generator will suppress any generated exceptions, and simply return a default narrative indicating that no 202 * narrative is available. 203 */ 204 public boolean isIgnoreFailures() { 205 return myIgnoreFailures; 206 } 207 208 /** 209 * If set to true, will return an empty narrative block for any profiles where no template is available 210 */ 211 public boolean isIgnoreMissingTemplates() { 212 return myIgnoreMissingTemplates; 213 } 214 215 private void loadProperties(String propFileName) throws IOException { 216 ourLog.debug("Loading narrative properties file: {}", propFileName); 217 218 Properties file = new Properties(); 219 220 InputStream resource = loadResource(propFileName); 221 file.load(resource); 222 for (Object nextKeyObj : file.keySet()) { 223 String nextKey = (String) nextKeyObj; 224 if (nextKey.endsWith(".profile")) { 225 String name = nextKey.substring(0, nextKey.indexOf(".profile")); 226 if (isBlank(name)) { 227 continue; 228 } 229 230 String narrativePropName = name + ".narrative"; 231 String narrativeName = file.getProperty(narrativePropName); 232 if (isBlank(narrativeName)) { 233 //FIXME resource leak 234 throw new ConfigurationException("Found property '" + nextKey + "' but no corresponding property '" + narrativePropName + "' in file " + propFileName); 235 } 236 237 if (StringUtils.isNotBlank(narrativeName)) { 238 String narrative = IOUtils.toString(loadResource(narrativeName), Constants.CHARSET_UTF8); 239 myNameToNarrativeTemplate.put(name, narrative); 240 } 241 242 } else if (nextKey.endsWith(".class")) { 243 244 String name = nextKey.substring(0, nextKey.indexOf(".class")); 245 if (isBlank(name)) { 246 continue; 247 } 248 249 String className = file.getProperty(nextKey); 250 251 Class<?> clazz; 252 try { 253 clazz = Class.forName(className); 254 } catch (ClassNotFoundException e) { 255 ourLog.debug("Unknown datatype class '{}' identified in narrative file {}", name, propFileName); 256 clazz = null; 257 } 258 259 if (clazz != null) { 260 myClassToName.put(clazz, name); 261 } 262 263 } else if (nextKey.endsWith(".narrative")) { 264 String name = nextKey.substring(0, nextKey.indexOf(".narrative")); 265 if (isBlank(name)) { 266 continue; 267 } 268 String narrativePropName = name + ".narrative"; 269 String narrativeName = file.getProperty(narrativePropName); 270 if (StringUtils.isNotBlank(narrativeName)) { 271 String narrative = IOUtils.toString(loadResource(narrativeName), Constants.CHARSET_UTF8); 272 myNameToNarrativeTemplate.put(name, narrative); 273 } 274 continue; 275 } else if (nextKey.endsWith(".title")) { 276 ourLog.debug("Ignoring title property as narrative generator no longer generates titles: {}", nextKey); 277 } else { 278 throw new ConfigurationException("Invalid property name: " + nextKey); 279 } 280 281 } 282 } 283 284 private InputStream loadResource(String name) throws IOException { 285 if (name.startsWith("classpath:")) { 286 String cpName = name.substring("classpath:".length()); 287 InputStream resource = DefaultThymeleafNarrativeGenerator.class.getResourceAsStream(cpName); 288 if (resource == null) { 289 resource = DefaultThymeleafNarrativeGenerator.class.getResourceAsStream("/" + cpName); 290 if (resource == null) { 291 throw new IOException("Can not find '" + cpName + "' on classpath"); 292 } 293 } 294 //FIXME resource leak 295 return resource; 296 } else if (name.startsWith("file:")) { 297 File file = new File(name.substring("file:".length())); 298 if (file.exists() == false) { 299 throw new IOException("File not found: " + file.getAbsolutePath()); 300 } 301 return new FileInputStream(file); 302 } else { 303 throw new IOException("Invalid resource name: '" + name + "' (must start with classpath: or file: )"); 304 } 305 } 306 307 /** 308 * If set to <code>true</code> (which is the default), most whitespace will be trimmed from the generated narrative 309 * before it is returned. 310 * <p> 311 * Note that in order to preserve formatting, not all whitespace is trimmed. Repeated whitespace characters (e.g. 312 * "\n \n ") will be trimmed to a single space. 313 * </p> 314 */ 315 public void setCleanWhitespace(boolean theCleanWhitespace) { 316 myCleanWhitespace = theCleanWhitespace; 317 } 318 319 /** 320 * If set to <code>true</code>, which is the default, if any failure occurs during narrative generation the 321 * generator will suppress any generated exceptions, and simply return a default narrative indicating that no 322 * narrative is available. 323 */ 324 public void setIgnoreFailures(boolean theIgnoreFailures) { 325 myIgnoreFailures = theIgnoreFailures; 326 } 327 328 /** 329 * If set to true, will return an empty narrative block for any profiles where no template is available 330 */ 331 public void setIgnoreMissingTemplates(boolean theIgnoreMissingTemplates) { 332 myIgnoreMissingTemplates = theIgnoreMissingTemplates; 333 } 334 335 static String cleanWhitespace(String theResult) { 336 StringBuilder b = new StringBuilder(); 337 boolean inWhitespace = false; 338 boolean betweenTags = false; 339 boolean lastNonWhitespaceCharWasTagEnd = false; 340 boolean inPre = false; 341 for (int i = 0; i < theResult.length(); i++) { 342 char nextChar = theResult.charAt(i); 343 if (inPre) { 344 b.append(nextChar); 345 continue; 346 } else if (nextChar == '>') { 347 b.append(nextChar); 348 betweenTags = true; 349 lastNonWhitespaceCharWasTagEnd = true; 350 continue; 351 } else if (nextChar == '\n' || nextChar == '\r') { 352 // if (inWhitespace) { 353 // b.append(' '); 354 // inWhitespace = false; 355 // } 356 continue; 357 } 358 359 if (betweenTags) { 360 if (Character.isWhitespace(nextChar)) { 361 inWhitespace = true; 362 } else if (nextChar == '<') { 363 if (inWhitespace && !lastNonWhitespaceCharWasTagEnd) { 364 b.append(' '); 365 } 366 inWhitespace = false; 367 b.append(nextChar); 368 inWhitespace = false; 369 betweenTags = false; 370 lastNonWhitespaceCharWasTagEnd = false; 371 if (i + 3 < theResult.length()) { 372 char char1 = Character.toLowerCase(theResult.charAt(i + 1)); 373 char char2 = Character.toLowerCase(theResult.charAt(i + 2)); 374 char char3 = Character.toLowerCase(theResult.charAt(i + 3)); 375 char char4 = Character.toLowerCase((i + 4 < theResult.length()) ? theResult.charAt(i + 4) : ' '); 376 if (char1 == 'p' && char2 == 'r' && char3 == 'e') { 377 inPre = true; 378 } else if (char1 == '/' && char2 == 'p' && char3 == 'r' && char4 == 'e') { 379 inPre = false; 380 } 381 } 382 } else { 383 lastNonWhitespaceCharWasTagEnd = false; 384 if (inWhitespace) { 385 b.append(' '); 386 inWhitespace = false; 387 } 388 b.append(nextChar); 389 } 390 } else { 391 b.append(nextChar); 392 } 393 } 394 return b.toString(); 395 } 396 397 public class NarrativeAttributeProcessor extends AbstractAttributeTagProcessor { 398 399 private FhirContext myContext; 400 401 protected NarrativeAttributeProcessor(FhirContext theContext, String theDialectPrefix) { 402 super(TemplateMode.XML, theDialectPrefix, null, false, "narrative", true, 0, true); 403 myContext = theContext; 404 } 405 406 @SuppressWarnings("unchecked") 407 @Override 408 protected void doProcess(ITemplateContext theContext, IProcessableElementTag theTag, AttributeName theAttributeName, String theAttributeValue, IElementTagStructureHandler theStructureHandler) { 409 IEngineConfiguration configuration = theContext.getConfiguration(); 410 IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(configuration); 411 412 final IStandardExpression expression = expressionParser.parseExpression(theContext, theAttributeValue); 413 final Object value = expression.execute(theContext); 414 415 if (value == null) { 416 return; 417 } 418 419 Context context = new Context(); 420 context.setVariable("fhirVersion", myContext.getVersion().getVersion().name()); 421 context.setVariable("resource", value); 422 423 String name = null; 424 425 Class<? extends Object> nextClass = value.getClass(); 426 do { 427 name = myClassToName.get(nextClass); 428 nextClass = nextClass.getSuperclass(); 429 } while (name == null && nextClass.equals(Object.class) == false); 430 431 if (name == null) { 432 if (value instanceof IBaseResource) { 433 name = myContext.getResourceDefinition((Class<? extends IBaseResource>) value).getName(); 434 } else if (value instanceof IDatatype) { 435 name = value.getClass().getSimpleName(); 436 name = name.substring(0, name.length() - 2); 437 } else if (value instanceof IBaseDatatype) { 438 name = value.getClass().getSimpleName(); 439 if (name.endsWith("Type")) { 440 name = name.substring(0, name.length() - 4); 441 } 442 } else { 443 throw new DataFormatException("Don't know how to determine name for type: " + value.getClass()); 444 } 445 name = name.toLowerCase(); 446 if (!myNameToNarrativeTemplate.containsKey(name)) { 447 name = null; 448 } 449 } 450 451 if (name == null) { 452 if (myIgnoreMissingTemplates) { 453 ourLog.debug("No narrative template available for type: {}", value.getClass()); 454 return; 455 } 456 throw new DataFormatException("No narrative template for class " + value.getClass()); 457 } 458 459 String result = myProfileTemplateEngine.process(name, context); 460 String trim = result.trim(); 461 462 theStructureHandler.setBody(trim, true); 463 464 } 465 466 } 467 468 // public class NarrativeAttributeProcessor extends AbstractAttributeTagProcessor { 469 // 470 // private FhirContext myContext; 471 // 472 // protected NarrativeAttributeProcessor(FhirContext theContext) { 473 // super() 474 // myContext = theContext; 475 // } 476 // 477 // @Override 478 // public int getPrecedence() { 479 // return 0; 480 // } 481 // 482 // @SuppressWarnings("unchecked") 483 // @Override 484 // protected ProcessorResult processAttribute(Arguments theArguments, Element theElement, String theAttributeName) { 485 // final String attributeValue = theElement.getAttributeValue(theAttributeName); 486 // 487 // final Configuration configuration = theArguments.getConfiguration(); 488 // final IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(configuration); 489 // 490 // final IStandardExpression expression = expressionParser.parseExpression(configuration, theArguments, attributeValue); 491 // final Object value = expression.execute(configuration, theArguments); 492 // 493 // theElement.removeAttribute(theAttributeName); 494 // theElement.clearChildren(); 495 // 496 // if (value == null) { 497 // return ProcessorResult.ok(); 498 // } 499 // 500 // Context context = new Context(); 501 // context.setVariable("fhirVersion", myContext.getVersion().getVersion().name()); 502 // context.setVariable("resource", value); 503 // 504 // String name = null; 505 // if (value != null) { 506 // Class<? extends Object> nextClass = value.getClass(); 507 // do { 508 // name = myClassToName.get(nextClass); 509 // nextClass = nextClass.getSuperclass(); 510 // } while (name == null && nextClass.equals(Object.class) == false); 511 // 512 // if (name == null) { 513 // if (value instanceof IBaseResource) { 514 // name = myContext.getResourceDefinition((Class<? extends IBaseResource>) value).getName(); 515 // } else if (value instanceof IDatatype) { 516 // name = value.getClass().getSimpleName(); 517 // name = name.substring(0, name.length() - 2); 518 // } else if (value instanceof IBaseDatatype) { 519 // name = value.getClass().getSimpleName(); 520 // if (name.endsWith("Type")) { 521 // name = name.substring(0, name.length() - 4); 522 // } 523 // } else { 524 // throw new DataFormatException("Don't know how to determine name for type: " + value.getClass()); 525 // } 526 // name = name.toLowerCase(); 527 // if (!myNameToNarrativeTemplate.containsKey(name)) { 528 // name = null; 529 // } 530 // } 531 // } 532 // 533 // if (name == null) { 534 // if (myIgnoreMissingTemplates) { 535 // ourLog.debug("No narrative template available for type: {}", value.getClass()); 536 // return ProcessorResult.ok(); 537 // } else { 538 // throw new DataFormatException("No narrative template for class " + value.getClass()); 539 // } 540 // } 541 // 542 // String result = myProfileTemplateEngine.process(name, context); 543 // String trim = result.trim(); 544 // if (!isBlank(trim + "AAA")) { 545 // Document dom = getXhtmlDOMFor(new StringReader(trim)); 546 // 547 // Element firstChild = (Element) dom.getFirstChild(); 548 // for (int i = 0; i < firstChild.getChildren().size(); i++) { 549 // Node next = firstChild.getChildren().get(i); 550 // if (i == 0 && firstChild.getChildren().size() == 1) { 551 // if (next instanceof org.thymeleaf.dom.Text) { 552 // org.thymeleaf.dom.Text nextText = (org.thymeleaf.dom.Text) next; 553 // nextText.setContent(nextText.getContent().trim()); 554 // } 555 // } 556 // theElement.addChild(next); 557 // } 558 // 559 // } 560 // 561 // 562 // return ProcessorResult.ok(); 563 // } 564 // 565 // } 566 567 // public String generateString(Patient theValue) { 568 // 569 // Context context = new Context(); 570 // context.setVariable("resource", theValue); 571 // String result = 572 // myProfileTemplateEngine.process("ca/uhn/fhir/narrative/Patient.html", 573 // context); 574 // 575 // ourLog.info("Result: {}", result); 576 // 577 // return result; 578 // } 579 580 private final class ProfileResourceResolver extends DefaultTemplateResolver { 581 582 @Override 583 protected boolean computeResolvable(IEngineConfiguration theConfiguration, String theOwnerTemplate, String theTemplate, Map<String, Object> theTemplateResolutionAttributes) { 584 String template = myNameToNarrativeTemplate.get(theTemplate); 585 return template != null; 586 } 587 588 @Override 589 protected TemplateMode computeTemplateMode(IEngineConfiguration theConfiguration, String theOwnerTemplate, String theTemplate, Map<String, Object> theTemplateResolutionAttributes) { 590 return TemplateMode.XML; 591 } 592 593 @Override 594 protected ITemplateResource computeTemplateResource(IEngineConfiguration theConfiguration, String theOwnerTemplate, String theTemplate, Map<String, Object> theTemplateResolutionAttributes) { 595 String template = myNameToNarrativeTemplate.get(theTemplate); 596 return new StringTemplateResource(template); 597 } 598 599 @Override 600 protected ICacheEntryValidity computeValidity(IEngineConfiguration theConfiguration, String theOwnerTemplate, String theTemplate, Map<String, Object> theTemplateResolutionAttributes) { 601 return AlwaysValidCacheEntryValidity.INSTANCE; 602 } 603 604 } 605 606}