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