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}