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}