001/*
002 * #%L
003 * HAPI FHIR - Server Framework
004 * %%
005 * Copyright (C) 2014 - 2025 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 * http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.rest.server.interceptor;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.FhirVersionEnum;
024import ca.uhn.fhir.i18n.Msg;
025import ca.uhn.fhir.interceptor.api.Hook;
026import ca.uhn.fhir.interceptor.api.Interceptor;
027import ca.uhn.fhir.interceptor.api.Pointcut;
028import ca.uhn.fhir.parser.IParser;
029import ca.uhn.fhir.rest.api.Constants;
030import ca.uhn.fhir.rest.api.EncodingEnum;
031import ca.uhn.fhir.rest.api.RequestTypeEnum;
032import ca.uhn.fhir.rest.api.server.IRestfulResponse;
033import ca.uhn.fhir.rest.api.server.RequestDetails;
034import ca.uhn.fhir.rest.api.server.ResponseDetails;
035import ca.uhn.fhir.rest.server.RestfulServer;
036import ca.uhn.fhir.rest.server.RestfulServerUtils;
037import ca.uhn.fhir.rest.server.RestfulServerUtils.ResponseEncoding;
038import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
039import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
040import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
041import ca.uhn.fhir.rest.server.method.BaseResourceReturningMethodBinding;
042import ca.uhn.fhir.rest.server.util.NarrativeUtil;
043import ca.uhn.fhir.util.ClasspathUtil;
044import ca.uhn.fhir.util.FhirTerser;
045import ca.uhn.fhir.util.StopWatch;
046import ca.uhn.fhir.util.UrlUtil;
047import com.google.common.annotations.VisibleForTesting;
048import jakarta.annotation.Nonnull;
049import jakarta.annotation.Nullable;
050import jakarta.servlet.ServletRequest;
051import jakarta.servlet.http.HttpServletRequest;
052import jakarta.servlet.http.HttpServletResponse;
053import org.apache.commons.io.FileUtils;
054import org.apache.commons.io.IOUtils;
055import org.apache.commons.text.StringEscapeUtils;
056import org.hl7.fhir.instance.model.api.IBase;
057import org.hl7.fhir.instance.model.api.IBaseBinary;
058import org.hl7.fhir.instance.model.api.IBaseConformance;
059import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
060import org.hl7.fhir.instance.model.api.IBaseResource;
061import org.hl7.fhir.instance.model.api.IPrimitiveType;
062import org.hl7.fhir.utilities.xhtml.NodeType;
063import org.hl7.fhir.utilities.xhtml.XhtmlNode;
064
065import java.io.IOException;
066import java.io.InputStream;
067import java.nio.charset.StandardCharsets;
068import java.util.Date;
069import java.util.Enumeration;
070import java.util.List;
071import java.util.Map;
072import java.util.Set;
073import java.util.stream.Collectors;
074
075import static org.apache.commons.lang3.StringUtils.defaultString;
076import static org.apache.commons.lang3.StringUtils.isBlank;
077import static org.apache.commons.lang3.StringUtils.isNotBlank;
078import static org.apache.commons.lang3.StringUtils.trim;
079
080/**
081 * This interceptor detects when a request is coming from a browser, and automatically returns a response with syntax
082 * highlighted (coloured) HTML for the response instead of just returning raw XML/JSON.
083 *
084 * @since 1.0
085 */
086@Interceptor
087public class ResponseHighlighterInterceptor {
088
089        /**
090         * TODO: As of HAPI 1.6 (2016-06-10) this parameter has been replaced with simply
091         * requesting _format=json or xml so eventually this parameter should be removed
092         */
093        public static final String PARAM_RAW = "_raw";
094
095        public static final String PARAM_RAW_TRUE = "true";
096        private static final org.slf4j.Logger ourLog =
097                        org.slf4j.LoggerFactory.getLogger(ResponseHighlighterInterceptor.class);
098        private static final String[] PARAM_FORMAT_VALUE_JSON = new String[] {Constants.FORMAT_JSON};
099        private static final String[] PARAM_FORMAT_VALUE_XML = new String[] {Constants.FORMAT_XML};
100        private static final String[] PARAM_FORMAT_VALUE_TTL = new String[] {Constants.FORMAT_TURTLE};
101        public static final String RESPONSE_HIGHLIGHTER_INTERCEPTOR_HANDLED_KEY = "ResponseHighlighterInterceptorHandled";
102        private boolean myShowRequestHeaders = false;
103        private boolean myShowResponseHeaders = true;
104        private boolean myShowNarrative = true;
105
106        /**
107         * Constructor
108         */
109        public ResponseHighlighterInterceptor() {
110                super();
111        }
112
113        private String createLinkHref(Map<String, String[]> parameters, String formatValue) {
114                StringBuilder rawB = new StringBuilder();
115                for (String next : parameters.keySet()) {
116                        if (Constants.PARAM_FORMAT.equals(next)) {
117                                continue;
118                        }
119                        for (String nextValue : parameters.get(next)) {
120                                if (isBlank(nextValue)) {
121                                        continue;
122                                }
123                                if (rawB.length() == 0) {
124                                        rawB.append('?');
125                                } else {
126                                        rawB.append('&');
127                                }
128                                rawB.append(UrlUtil.escapeUrlParam(next));
129                                rawB.append('=');
130                                rawB.append(UrlUtil.escapeUrlParam(nextValue));
131                        }
132                }
133                if (rawB.length() == 0) {
134                        rawB.append('?');
135                } else {
136                        rawB.append('&');
137                }
138                rawB.append(Constants.PARAM_FORMAT).append('=').append(formatValue);
139
140                String link = rawB.toString();
141                return link;
142        }
143
144        private int format(String theResultBody, StringBuilder theTarget, EncodingEnum theEncodingEnum) {
145                String str = StringEscapeUtils.escapeHtml4(theResultBody);
146                if (str == null || theEncodingEnum == null) {
147                        theTarget.append(str);
148                        return 0;
149                }
150
151                theTarget.append("<div id=\"line1\">");
152
153                boolean inValue = false;
154                boolean inQuote = false;
155                boolean inTag = false;
156                boolean inTurtleDirective = false;
157                boolean startingLineNext = true;
158                boolean startingLine = false;
159                int lineCount = 1;
160
161                for (int i = 0; i < str.length(); i++) {
162                        char prevChar = (i > 0) ? str.charAt(i - 1) : ' ';
163                        char nextChar = str.charAt(i);
164                        char nextChar2 = (i + 1) < str.length() ? str.charAt(i + 1) : ' ';
165                        char nextChar3 = (i + 2) < str.length() ? str.charAt(i + 2) : ' ';
166                        char nextChar4 = (i + 3) < str.length() ? str.charAt(i + 3) : ' ';
167                        char nextChar5 = (i + 4) < str.length() ? str.charAt(i + 4) : ' ';
168                        char nextChar6 = (i + 5) < str.length() ? str.charAt(i + 5) : ' ';
169
170                        if (nextChar == '\n') {
171                                if (inTurtleDirective) {
172                                        theTarget.append("</span>");
173                                        inTurtleDirective = false;
174                                }
175                                lineCount++;
176                                theTarget.append("</div><div id=\"line");
177                                theTarget.append(lineCount);
178                                theTarget.append("\" onclick=\"updateHighlightedLineTo('#L");
179                                theTarget.append(lineCount);
180                                theTarget.append("');\">");
181                                startingLineNext = true;
182                                continue;
183                        } else if (startingLineNext) {
184                                startingLineNext = false;
185                                startingLine = true;
186                        } else {
187                                startingLine = false;
188                        }
189
190                        if (theEncodingEnum == EncodingEnum.JSON) {
191
192                                if (inQuote) {
193                                        theTarget.append(nextChar);
194                                        if (prevChar != '\\'
195                                                        && nextChar == '&'
196                                                        && nextChar2 == 'q'
197                                                        && nextChar3 == 'u'
198                                                        && nextChar4 == 'o'
199                                                        && nextChar5 == 't'
200                                                        && nextChar6 == ';') {
201                                                theTarget.append("quot;</span>");
202                                                i += 5;
203                                                inQuote = false;
204                                        } else if (nextChar == '\\' && nextChar2 == '"') {
205                                                theTarget.append("quot;</span>");
206                                                i += 5;
207                                                inQuote = false;
208                                        }
209                                } else {
210                                        if (nextChar == ':') {
211                                                inValue = true;
212                                                theTarget.append(nextChar);
213                                        } else if (nextChar == '[' || nextChar == '{') {
214                                                theTarget.append("<span class='hlControl'>");
215                                                theTarget.append(nextChar);
216                                                theTarget.append("</span>");
217                                                inValue = false;
218                                        } else if (nextChar == '{' || nextChar == '}' || nextChar == ',') {
219                                                theTarget.append("<span class='hlControl'>");
220                                                theTarget.append(nextChar);
221                                                theTarget.append("</span>");
222                                                inValue = false;
223                                        } else if (nextChar == '&'
224                                                        && nextChar2 == 'q'
225                                                        && nextChar3 == 'u'
226                                                        && nextChar4 == 'o'
227                                                        && nextChar5 == 't'
228                                                        && nextChar6 == ';') {
229                                                if (inValue) {
230                                                        theTarget.append("<span class='hlQuot'>&quot;");
231                                                } else {
232                                                        theTarget.append("<span class='hlTagName'>&quot;");
233                                                }
234                                                inQuote = true;
235                                                i += 5;
236                                        } else if (nextChar == ':') {
237                                                theTarget.append("<span class='hlControl'>");
238                                                theTarget.append(nextChar);
239                                                theTarget.append("</span>");
240                                                inValue = true;
241                                        } else {
242                                                theTarget.append(nextChar);
243                                        }
244                                }
245
246                        } else if (theEncodingEnum == EncodingEnum.RDF) {
247
248                                if (inQuote) {
249                                        theTarget.append(nextChar);
250                                        if (prevChar != '\\'
251                                                        && nextChar == '&'
252                                                        && nextChar2 == 'q'
253                                                        && nextChar3 == 'u'
254                                                        && nextChar4 == 'o'
255                                                        && nextChar5 == 't'
256                                                        && nextChar6 == ';') {
257                                                theTarget.append("quot;</span>");
258                                                i += 5;
259                                                inQuote = false;
260                                        } else if (nextChar == '\\' && nextChar2 == '"') {
261                                                theTarget.append("quot;</span>");
262                                                i += 5;
263                                                inQuote = false;
264                                        }
265                                } else if (startingLine && nextChar == '@') {
266                                        inTurtleDirective = true;
267                                        theTarget.append("<span class='hlTagName'>");
268                                        theTarget.append(nextChar);
269                                } else if (startingLine) {
270                                        inTurtleDirective = true;
271                                        theTarget.append("<span class='hlTagName'>");
272                                        theTarget.append(nextChar);
273                                } else if (nextChar == '[' || nextChar == ']' || nextChar == ';' || nextChar == ':') {
274                                        theTarget.append("<span class='hlControl'>");
275                                        theTarget.append(nextChar);
276                                        theTarget.append("</span>");
277                                } else {
278                                        if (nextChar == '&'
279                                                        && nextChar2 == 'q'
280                                                        && nextChar3 == 'u'
281                                                        && nextChar4 == 'o'
282                                                        && nextChar5 == 't'
283                                                        && nextChar6 == ';') {
284                                                theTarget.append("<span class='hlQuot'>&quot;");
285                                                inQuote = true;
286                                                i += 5;
287                                        } else {
288                                                theTarget.append(nextChar);
289                                        }
290                                }
291
292                        } else {
293
294                                // Ok it's XML
295
296                                if (inQuote) {
297                                        theTarget.append(nextChar);
298                                        if (nextChar == '&'
299                                                        && nextChar2 == 'q'
300                                                        && nextChar3 == 'u'
301                                                        && nextChar4 == 'o'
302                                                        && nextChar5 == 't'
303                                                        && nextChar6 == ';') {
304                                                theTarget.append("quot;</span>");
305                                                i += 5;
306                                                inQuote = false;
307                                        }
308                                } else if (inTag) {
309                                        if (nextChar == '&' && nextChar2 == 'g' && nextChar3 == 't' && nextChar4 == ';') {
310                                                theTarget.append("</span><span class='hlControl'>&gt;</span>");
311                                                inTag = false;
312                                                i += 3;
313                                        } else if (nextChar == ' ') {
314                                                theTarget.append("</span><span class='hlAttr'>");
315                                                theTarget.append(nextChar);
316                                        } else if (nextChar == '&'
317                                                        && nextChar2 == 'q'
318                                                        && nextChar3 == 'u'
319                                                        && nextChar4 == 'o'
320                                                        && nextChar5 == 't'
321                                                        && nextChar6 == ';') {
322                                                theTarget.append("<span class='hlQuot'>&quot;");
323                                                inQuote = true;
324                                                i += 5;
325                                        } else {
326                                                theTarget.append(nextChar);
327                                        }
328                                } else {
329                                        if (nextChar == '&' && nextChar2 == 'l' && nextChar3 == 't' && nextChar4 == ';') {
330                                                theTarget.append("<span class='hlControl'>&lt;</span><span class='hlTagName'>");
331                                                inTag = true;
332                                                i += 3;
333                                        } else {
334                                                theTarget.append(nextChar);
335                                        }
336                                }
337                        }
338                }
339
340                theTarget.append("</div>");
341                return lineCount;
342        }
343
344        @Hook(value = Pointcut.SERVER_HANDLE_EXCEPTION, order = InterceptorOrders.RESPONSE_HIGHLIGHTER_INTERCEPTOR)
345        public boolean handleException(
346                        RequestDetails theRequestDetails,
347                        BaseServerResponseException theException,
348                        HttpServletRequest theServletRequest,
349                        HttpServletResponse theServletResponse) {
350                /*
351                 * It's not a browser...
352                 */
353                Set<String> accept = RestfulServerUtils.parseAcceptHeaderAndReturnHighestRankedOptions(theServletRequest);
354                if (!accept.contains(Constants.CT_HTML)) {
355                        return true;
356                }
357
358                /*
359                 * It's an AJAX request, so no HTML
360                 */
361                String requestedWith = theServletRequest.getHeader(Constants.HEADER_X_REQUESTED_WITH);
362                if (requestedWith != null) {
363                        return true;
364                }
365
366                /*
367                 * Not a GET
368                 */
369                if (theRequestDetails.getRequestType() != RequestTypeEnum.GET) {
370                        return true;
371                }
372
373                IBaseOperationOutcome oo = theException.getOperationOutcome();
374                if (oo == null) {
375                        return true;
376                }
377
378                ResponseDetails responseDetails = new ResponseDetails();
379                responseDetails.setResponseResource(oo);
380                responseDetails.setResponseCode(theException.getStatusCode());
381
382                BaseResourceReturningMethodBinding.callOutgoingFailureOperationOutcomeHook(theRequestDetails, oo);
383                streamResponse(
384                                theRequestDetails,
385                                theServletResponse,
386                                responseDetails.getResponseResource(),
387                                null,
388                                theServletRequest,
389                                responseDetails.getResponseCode());
390
391                return false;
392        }
393
394        /**
395         * If set to <code>true</code> (default is <code>false</code>) response will include the
396         * request headers
397         */
398        public boolean isShowRequestHeaders() {
399                return myShowRequestHeaders;
400        }
401
402        /**
403         * If set to <code>true</code> (default is <code>false</code>) response will include the
404         * request headers
405         *
406         * @return Returns a reference to this for easy method chaining
407         */
408        @SuppressWarnings("UnusedReturnValue")
409        public ResponseHighlighterInterceptor setShowRequestHeaders(boolean theShowRequestHeaders) {
410                myShowRequestHeaders = theShowRequestHeaders;
411                return this;
412        }
413
414        /**
415         * If set to <code>true</code> (default is <code>true</code>) response will include the
416         * response headers
417         */
418        public boolean isShowResponseHeaders() {
419                return myShowResponseHeaders;
420        }
421
422        /**
423         * If set to <code>true</code> (default is <code>true</code>) response will include the
424         * response headers
425         *
426         * @return Returns a reference to this for easy method chaining
427         */
428        @SuppressWarnings("UnusedReturnValue")
429        public ResponseHighlighterInterceptor setShowResponseHeaders(boolean theShowResponseHeaders) {
430                myShowResponseHeaders = theShowResponseHeaders;
431                return this;
432        }
433
434        @Hook(value = Pointcut.SERVER_OUTGOING_GRAPHQL_RESPONSE, order = InterceptorOrders.RESPONSE_HIGHLIGHTER_INTERCEPTOR)
435        public boolean outgoingGraphqlResponse(
436                        RequestDetails theRequestDetails,
437                        String theRequest,
438                        String theResponse,
439                        HttpServletRequest theServletRequest,
440                        HttpServletResponse theServletResponse)
441                        throws AuthenticationException {
442
443                /*
444                 * Return true here so that we still fire SERVER_OUTGOING_GRAPHQL_RESPONSE!
445                 */
446
447                if (handleOutgoingResponse(theRequestDetails, null, theServletRequest, theServletResponse, theResponse, null)) {
448                        return true;
449                }
450
451                theRequestDetails.getUserData().put(RESPONSE_HIGHLIGHTER_INTERCEPTOR_HANDLED_KEY, Boolean.TRUE);
452
453                return true;
454        }
455
456        @Hook(value = Pointcut.SERVER_OUTGOING_RESPONSE, order = InterceptorOrders.RESPONSE_HIGHLIGHTER_INTERCEPTOR)
457        public boolean outgoingResponse(
458                        RequestDetails theRequestDetails,
459                        ResponseDetails theResponseObject,
460                        HttpServletRequest theServletRequest,
461                        HttpServletResponse theServletResponse)
462                        throws AuthenticationException {
463
464                if (!Boolean.TRUE.equals(theRequestDetails.getUserData().get(RESPONSE_HIGHLIGHTER_INTERCEPTOR_HANDLED_KEY))) {
465                        String graphqlResponse = null;
466                        IBaseResource resourceResponse = theResponseObject.getResponseResource();
467                        if (handleOutgoingResponse(
468                                        theRequestDetails,
469                                        theResponseObject,
470                                        theServletRequest,
471                                        theServletResponse,
472                                        graphqlResponse,
473                                        resourceResponse)) {
474                                return true;
475                        }
476                }
477
478                return false;
479        }
480
481        @Hook(Pointcut.SERVER_CAPABILITY_STATEMENT_GENERATED)
482        public void capabilityStatementGenerated(
483                        RequestDetails theRequestDetails, IBaseConformance theCapabilityStatement) {
484                FhirTerser terser = theRequestDetails.getFhirContext().newTerser();
485
486                Set<String> formats = terser.getValues(theCapabilityStatement, "format", IPrimitiveType.class).stream()
487                                .map(t -> t.getValueAsString())
488                                .collect(Collectors.toSet());
489                addFormatConditionally(
490                                theCapabilityStatement, terser, formats, Constants.CT_FHIR_JSON_NEW, Constants.FORMATS_HTML_JSON);
491                addFormatConditionally(
492                                theCapabilityStatement, terser, formats, Constants.CT_FHIR_XML_NEW, Constants.FORMATS_HTML_XML);
493                addFormatConditionally(
494                                theCapabilityStatement, terser, formats, Constants.CT_RDF_TURTLE, Constants.FORMATS_HTML_TTL);
495        }
496
497        private void addFormatConditionally(
498                        IBaseConformance theCapabilityStatement,
499                        FhirTerser terser,
500                        Set<String> formats,
501                        String wanted,
502                        String toAdd) {
503                if (formats.contains(wanted)) {
504                        terser.addElement(theCapabilityStatement, "format", toAdd);
505                }
506        }
507
508        private boolean handleOutgoingResponse(
509                        RequestDetails theRequestDetails,
510                        ResponseDetails theResponseObject,
511                        HttpServletRequest theServletRequest,
512                        HttpServletResponse theServletResponse,
513                        String theGraphqlResponse,
514                        IBaseResource theResourceResponse) {
515                if (theResourceResponse == null && theGraphqlResponse == null) {
516                        // this will happen during, for example, a bulk export polling request
517                        return true;
518                }
519                /*
520                 * Request for _raw
521                 */
522                String[] rawParamValues = theRequestDetails.getParameters().get(PARAM_RAW);
523                if (rawParamValues != null && rawParamValues.length > 0 && rawParamValues[0].equals(PARAM_RAW_TRUE)) {
524                        ourLog.warn(
525                                        "Client is using non-standard/legacy  _raw parameter - Use _format=json or _format=xml instead, as this parmameter will be removed at some point");
526                        return true;
527                }
528
529                boolean force = false;
530                String[] formatParams = theRequestDetails.getParameters().get(Constants.PARAM_FORMAT);
531                if (formatParams != null && formatParams.length > 0) {
532                        String formatParam = defaultString(formatParams[0]);
533                        int semiColonIdx = formatParam.indexOf(';');
534                        if (semiColonIdx != -1) {
535                                formatParam = formatParam.substring(0, semiColonIdx);
536                        }
537                        formatParam = trim(formatParam);
538
539                        if (Constants.FORMATS_HTML.contains(formatParam)) { // this is a set
540                                force = true;
541                        } else if (Constants.FORMATS_HTML_XML.equals(formatParam)) {
542                                force = true;
543                                theRequestDetails.addParameter(Constants.PARAM_FORMAT, PARAM_FORMAT_VALUE_XML);
544                        } else if (Constants.FORMATS_HTML_JSON.equals(formatParam)) {
545                                force = true;
546                                theRequestDetails.addParameter(Constants.PARAM_FORMAT, PARAM_FORMAT_VALUE_JSON);
547                        } else if (Constants.FORMATS_HTML_TTL.equals(formatParam)) {
548                                force = true;
549                                theRequestDetails.addParameter(Constants.PARAM_FORMAT, PARAM_FORMAT_VALUE_TTL);
550                        } else {
551                                return true;
552                        }
553                }
554
555                /*
556                 * It's not a browser...
557                 */
558                Set<String> highestRankedAcceptValues =
559                                RestfulServerUtils.parseAcceptHeaderAndReturnHighestRankedOptions(theServletRequest);
560                if (!force && highestRankedAcceptValues.contains(Constants.CT_HTML) == false) {
561                        return true;
562                }
563
564                /*
565                 * It's an AJAX request, so no HTML
566                 */
567                if (!force && isNotBlank(theServletRequest.getHeader(Constants.HEADER_X_REQUESTED_WITH))) {
568                        return true;
569                }
570                /*
571                 * If the request has an Origin header, it is probably an AJAX request
572                 */
573                if (!force && isNotBlank(theServletRequest.getHeader(Constants.HEADER_CORS_ORIGIN))) {
574                        return true;
575                }
576
577                /*
578                 * Not a GET
579                 */
580                if (!force && theRequestDetails.getRequestType() != RequestTypeEnum.GET) {
581                        return true;
582                }
583
584                /*
585                 * Not binary
586                 */
587                if (!force && theResponseObject != null && (theResponseObject.getResponseResource() instanceof IBaseBinary)) {
588                        return true;
589                }
590
591                streamResponse(
592                                theRequestDetails, theServletResponse, theResourceResponse, theGraphqlResponse, theServletRequest, 200);
593                return false;
594        }
595
596        private void streamRequestHeaders(ServletRequest theServletRequest, StringBuilder b) {
597                if (theServletRequest instanceof HttpServletRequest) {
598                        HttpServletRequest sr = (HttpServletRequest) theServletRequest;
599                        b.append("<h1>Request</h1>");
600                        b.append("<div class=\"headersDiv\">");
601                        Enumeration<String> headerNamesEnum = sr.getHeaderNames();
602                        while (headerNamesEnum.hasMoreElements()) {
603                                String nextHeaderName = headerNamesEnum.nextElement();
604                                Enumeration<String> headerValuesEnum = sr.getHeaders(nextHeaderName);
605                                while (headerValuesEnum.hasMoreElements()) {
606                                        String nextHeaderValue = headerValuesEnum.nextElement();
607                                        appendHeader(b, nextHeaderName, nextHeaderValue);
608                                }
609                        }
610                        b.append("</div>");
611                }
612        }
613
614        private void streamResponse(
615                        RequestDetails theRequestDetails,
616                        HttpServletResponse theServletResponse,
617                        IBaseResource theResource,
618                        String theGraphqlResponse,
619                        ServletRequest theServletRequest,
620                        int theStatusCode) {
621                EncodingEnum encoding;
622                String encoded;
623                Map<String, String[]> parameters = theRequestDetails.getParameters();
624
625                if (isNotBlank(theGraphqlResponse)) {
626
627                        encoded = theGraphqlResponse;
628                        encoding = EncodingEnum.JSON;
629
630                } else {
631
632                        IParser p;
633                        if (parameters.containsKey(Constants.PARAM_FORMAT)) {
634                                FhirVersionEnum forVersion = theResource.getStructureFhirVersionEnum();
635                                p = RestfulServerUtils.getNewParser(
636                                                theRequestDetails.getServer().getFhirContext(), forVersion, theRequestDetails);
637                        } else {
638                                EncodingEnum defaultResponseEncoding =
639                                                theRequestDetails.getServer().getDefaultResponseEncoding();
640                                p = defaultResponseEncoding.newParser(
641                                                theRequestDetails.getServer().getFhirContext());
642                                RestfulServerUtils.configureResponseParser(theRequestDetails, p);
643                        }
644
645                        // This interceptor defaults to pretty printing unless the user
646                        // has specifically requested us not to
647                        boolean prettyPrintResponse = true;
648                        String[] prettyParams = parameters.get(Constants.PARAM_PRETTY);
649                        if (prettyParams != null && prettyParams.length > 0) {
650                                if (Constants.PARAM_PRETTY_VALUE_FALSE.equals(prettyParams[0])) {
651                                        prettyPrintResponse = false;
652                                }
653                        }
654                        if (prettyPrintResponse) {
655                                p.setPrettyPrint(true);
656                        }
657
658                        encoding = p.getEncoding();
659                        encoded = p.encodeResourceToString(theResource);
660                }
661
662                if (theRequestDetails.getServer() instanceof RestfulServer) {
663                        RestfulServer rs = (RestfulServer) theRequestDetails.getServer();
664                        rs.addHeadersToResponse(theServletResponse);
665                }
666
667                try {
668
669                        if (theStatusCode > 299) {
670                                theServletResponse.setStatus(theStatusCode);
671                        }
672                        theServletResponse.setContentType(Constants.CT_HTML_WITH_UTF8);
673
674                        StringBuilder outputBuffer = new StringBuilder();
675                        outputBuffer.append("<html lang=\"en\">\n");
676                        outputBuffer.append("   <head>\n");
677                        outputBuffer.append("           <meta charset=\"utf-8\" />\n");
678                        outputBuffer.append("       <style>\n");
679                        outputBuffer.append(
680                                        ClasspathUtil.loadResource("ca/uhn/fhir/rest/server/interceptor/ResponseHighlighter.css"));
681                        outputBuffer.append("       </style>\n");
682                        outputBuffer.append("   </head>\n");
683                        outputBuffer.append("\n");
684                        outputBuffer.append("   <body>");
685
686                        outputBuffer.append("<p>");
687
688                        if (isBlank(theGraphqlResponse)) {
689                                outputBuffer.append("This result is being rendered in HTML for easy viewing. ");
690                                outputBuffer.append("You may access this content as ");
691
692                                if (theRequestDetails.getFhirContext().isFormatJsonSupported()) {
693                                        outputBuffer.append("<a href=\"");
694                                        outputBuffer.append(createLinkHref(parameters, Constants.FORMAT_JSON));
695                                        outputBuffer.append("\">Raw JSON</a> or ");
696                                }
697
698                                if (theRequestDetails.getFhirContext().isFormatXmlSupported()) {
699                                        outputBuffer.append("<a href=\"");
700                                        outputBuffer.append(createLinkHref(parameters, Constants.FORMAT_XML));
701                                        outputBuffer.append("\">Raw XML</a> or ");
702                                }
703
704                                if (theRequestDetails.getFhirContext().isFormatRdfSupported()) {
705                                        outputBuffer.append("<a href=\"");
706                                        outputBuffer.append(createLinkHref(parameters, Constants.FORMAT_TURTLE));
707                                        outputBuffer.append("\">Raw Turtle</a> or ");
708                                }
709
710                                outputBuffer.append("view this content in ");
711
712                                if (theRequestDetails.getFhirContext().isFormatJsonSupported()) {
713                                        outputBuffer.append("<a href=\"");
714                                        outputBuffer.append(createLinkHref(parameters, Constants.FORMATS_HTML_JSON));
715                                        outputBuffer.append("\">HTML JSON</a> ");
716                                }
717
718                                if (theRequestDetails.getFhirContext().isFormatXmlSupported()) {
719                                        outputBuffer.append("or ");
720                                        outputBuffer.append("<a href=\"");
721                                        outputBuffer.append(createLinkHref(parameters, Constants.FORMATS_HTML_XML));
722                                        outputBuffer.append("\">HTML XML</a> ");
723                                }
724
725                                if (theRequestDetails.getFhirContext().isFormatRdfSupported()) {
726                                        outputBuffer.append("or ");
727                                        outputBuffer.append("<a href=\"");
728                                        outputBuffer.append(createLinkHref(parameters, Constants.FORMATS_HTML_TTL));
729                                        outputBuffer.append("\">HTML Turtle</a> ");
730                                }
731
732                                outputBuffer.append(".");
733                        }
734
735                        Date startTime = (Date) theServletRequest.getAttribute(RestfulServer.REQUEST_START_TIME);
736                        if (startTime != null) {
737                                long time = System.currentTimeMillis() - startTime.getTime();
738                                outputBuffer.append(" Response generated in ");
739                                outputBuffer.append(time);
740                                outputBuffer.append("ms.");
741                        }
742
743                        outputBuffer.append("</p>");
744
745                        outputBuffer.append("\n");
746
747                        // status (e.g. HTTP 200 OK)
748                        String statusName = Constants.HTTP_STATUS_NAMES.get(theServletResponse.getStatus());
749                        statusName = defaultString(statusName);
750                        outputBuffer.append("<div class=\"httpStatusDiv\">");
751                        outputBuffer.append("HTTP ");
752                        outputBuffer.append(theServletResponse.getStatus());
753                        outputBuffer.append(" ");
754                        outputBuffer.append(statusName);
755                        outputBuffer.append("</div>");
756
757                        outputBuffer.append("\n");
758                        outputBuffer.append("\n");
759
760                        try {
761                                if (isShowRequestHeaders()) {
762                                        streamRequestHeaders(theServletRequest, outputBuffer);
763                                }
764                                if (isShowResponseHeaders()) {
765                                        streamResponseHeaders(theRequestDetails, theServletResponse, outputBuffer);
766                                }
767                        } catch (Throwable t) {
768                                // ignore (this will hit if we're running in a servlet 2.5 environment)
769                        }
770
771                        if (myShowNarrative) {
772                                String narrativeHtml = extractNarrativeHtml(theRequestDetails, theResource);
773                                if (isNotBlank(narrativeHtml)) {
774                                        outputBuffer.append("<h1>Narrative</h1>");
775                                        outputBuffer.append("<div class=\"narrativeBody\">");
776                                        outputBuffer.append(narrativeHtml);
777                                        outputBuffer.append("</div>");
778                                }
779                        }
780
781                        outputBuffer.append("<h1>Response Body</h1>");
782
783                        outputBuffer.append("<div class=\"responseBodyTable\">");
784
785                        // Response Body
786                        outputBuffer.append("<div class=\"responseBodyTableSecondColumn\"><pre>");
787                        StringBuilder target = new StringBuilder();
788                        int linesCount = format(encoded, target, encoding);
789                        outputBuffer.append(target);
790                        outputBuffer.append("</pre></div>");
791
792                        // Line Numbers
793                        outputBuffer.append("<div class=\"responseBodyTableFirstColumn\"><pre>");
794                        for (int i = 1; i <= linesCount; i++) {
795                                outputBuffer.append("<div class=\"lineAnchor\" id=\"anchor");
796                                outputBuffer.append(i);
797                                outputBuffer.append("\">");
798
799                                outputBuffer.append("<a href=\"#L");
800                                outputBuffer.append(i);
801                                outputBuffer.append("\" name=\"L");
802                                outputBuffer.append(i);
803                                outputBuffer.append("\" id=\"L");
804                                outputBuffer.append(i);
805                                outputBuffer.append("\">");
806                                outputBuffer.append(i);
807                                outputBuffer.append("</a></div>");
808                        }
809                        outputBuffer.append("</div></td>");
810
811                        outputBuffer.append("</div>");
812
813                        outputBuffer.append("\n");
814
815                        InputStream jsStream = ResponseHighlighterInterceptor.class.getResourceAsStream("ResponseHighlighter.js");
816                        String jsStr = jsStream != null
817                                        ? IOUtils.toString(jsStream, StandardCharsets.UTF_8)
818                                        : "console.log('ResponseHighlighterInterceptor: javascript theResource not found')";
819
820                        String baseUrl = theRequestDetails.getServerBaseForRequest();
821
822                        baseUrl = UrlUtil.sanitizeBaseUrl(baseUrl);
823
824                        jsStr = jsStr.replace("FHIR_BASE", baseUrl);
825                        outputBuffer.append("<script type=\"text/javascript\">");
826                        outputBuffer.append(jsStr);
827                        outputBuffer.append("</script>\n");
828
829                        StopWatch writeSw = new StopWatch();
830                        theServletResponse.getWriter().append(outputBuffer);
831                        theServletResponse.getWriter().flush();
832
833                        theServletResponse.getWriter().append("<div class=\"sizeInfo\">");
834                        theServletResponse.getWriter().append("Wrote ");
835                        writeLength(theServletResponse, encoded.length());
836                        theServletResponse.getWriter().append(" (");
837                        writeLength(theServletResponse, outputBuffer.length());
838                        theServletResponse.getWriter().append(" total including HTML)");
839
840                        theServletResponse.getWriter().append(" in approximately ");
841                        theServletResponse.getWriter().append(writeSw.toString());
842                        theServletResponse.getWriter().append("</div>");
843
844                        theServletResponse.getWriter().append("</body>");
845                        theServletResponse.getWriter().append("</html>");
846
847                        theServletResponse.getWriter().close();
848                } catch (IOException e) {
849                        throw new InternalErrorException(Msg.code(322) + e);
850                }
851        }
852
853        @VisibleForTesting
854        @Nullable
855        String extractNarrativeHtml(@Nonnull RequestDetails theRequestDetails, @Nullable IBaseResource theResource) {
856                if (theResource == null) {
857                        return null;
858                }
859
860                FhirContext ctx = theRequestDetails.getFhirContext();
861
862                // Try to extract the narrative from the resource. First, just see if there
863                // is a narrative in the normal spot.
864                XhtmlNode xhtmlNode = extractNarrativeFromElement(theResource, ctx);
865
866                // If the resource is a document, see if the Composition has a narrative
867                if (xhtmlNode == null && "Bundle".equals(ctx.getResourceType(theResource))) {
868                        if ("document".equals(ctx.newTerser().getSinglePrimitiveValueOrNull(theResource, "type"))) {
869                                IBaseResource firstResource =
870                                                ctx.newTerser().getSingleValueOrNull(theResource, "entry.resource", IBaseResource.class);
871                                if (firstResource != null && "Composition".equals(ctx.getResourceType(firstResource))) {
872                                        xhtmlNode = extractNarrativeFromComposition(firstResource, ctx);
873                                }
874                        }
875                }
876
877                // If the resource is a Parameters, see if it has a narrative in the first
878                // parameter
879                if (xhtmlNode == null && "Parameters".equals(ctx.getResourceType(theResource))) {
880                        String firstParameterName = ctx.newTerser().getSinglePrimitiveValueOrNull(theResource, "parameter.name");
881                        if ("Narrative".equals(firstParameterName)) {
882                                String firstParameterValue =
883                                                ctx.newTerser().getSinglePrimitiveValueOrNull(theResource, "parameter.value[x]");
884                                if (defaultString(firstParameterValue).startsWith("<div")) {
885                                        xhtmlNode = new XhtmlNode();
886                                        xhtmlNode.setValueAsString(firstParameterValue);
887                                }
888                        }
889                }
890
891                /*
892                 * Sanitize the narrative so that it's safe to render (strip any
893                 * links, potentially unsafe CSS, etc.)
894                 */
895                if (xhtmlNode != null) {
896                        xhtmlNode = NarrativeUtil.sanitize(xhtmlNode);
897                        return xhtmlNode.getValueAsString();
898                }
899
900                return null;
901        }
902
903        private XhtmlNode extractNarrativeFromComposition(IBaseResource theComposition, FhirContext theCtx) {
904                XhtmlNode retVal = new XhtmlNode(NodeType.Element, "div");
905
906                XhtmlNode xhtmlNode = extractNarrativeFromElement(theComposition, theCtx);
907                if (xhtmlNode != null) {
908                        retVal.add(xhtmlNode);
909                }
910
911                List<IBase> sections = theCtx.newTerser().getValues(theComposition, "section");
912                for (IBase section : sections) {
913                        String title = theCtx.newTerser().getSinglePrimitiveValueOrNull(section, "title");
914                        if (isNotBlank(title)) {
915                                XhtmlNode sectionNarrative = extractNarrativeFromElement(section, theCtx);
916                                if (sectionNarrative != null && sectionNarrative.hasChildren()) {
917                                        XhtmlNode titleNode = new XhtmlNode(NodeType.Element, "h1");
918                                        titleNode.addText(title);
919                                        retVal.add(titleNode);
920                                        retVal.add(sectionNarrative);
921                                }
922                        }
923                }
924
925                if (retVal.isEmpty()) {
926                        return null;
927                }
928                return retVal;
929        }
930
931        private void writeLength(HttpServletResponse theServletResponse, int theLength) throws IOException {
932                double kb = ((double) theLength) / FileUtils.ONE_KB;
933                if (kb <= 1000) {
934                        theServletResponse.getWriter().append(String.format("%.1f", kb)).append(" KB");
935                } else {
936                        double mb = kb / 1000;
937                        theServletResponse.getWriter().append(String.format("%.1f", mb)).append(" MB");
938                }
939        }
940
941        private void streamResponseHeaders(
942                        RequestDetails theRequestDetails, HttpServletResponse theServletResponse, StringBuilder b) {
943                if (theServletResponse.getHeaderNames().isEmpty() == false) {
944                        b.append("<h1>Response Headers</h1>");
945
946                        b.append("<div class=\"headersDiv\">");
947                        for (String nextHeaderName : theServletResponse.getHeaderNames()) {
948                                for (String nextHeaderValue : theServletResponse.getHeaders(nextHeaderName)) {
949                                        /*
950                                         * Let's pretend we're returning a FHIR content type even though we're
951                                         * actually returning an HTML one
952                                         */
953                                        if (nextHeaderName.equalsIgnoreCase(Constants.HEADER_CONTENT_TYPE)) {
954                                                ResponseEncoding responseEncoding = RestfulServerUtils.determineResponseEncodingNoDefault(
955                                                                theRequestDetails, theRequestDetails.getServer().getDefaultResponseEncoding());
956                                                if (responseEncoding != null && isNotBlank(responseEncoding.getResourceContentType())) {
957                                                        nextHeaderValue = responseEncoding.getResourceContentType() + ";charset=utf-8";
958                                                }
959                                        }
960                                        appendHeader(b, nextHeaderName, nextHeaderValue);
961                                }
962                        }
963                        IRestfulResponse response = theRequestDetails.getResponse();
964                        for (Map.Entry<String, List<String>> next : response.getHeaders().entrySet()) {
965                                String name = next.getKey();
966                                for (String nextValue : next.getValue()) {
967                                        appendHeader(b, name, nextValue);
968                                }
969                        }
970
971                        b.append("</div>");
972                }
973        }
974
975        private void appendHeader(StringBuilder theBuilder, String theHeaderName, String theHeaderValue) {
976                theBuilder.append("<div class=\"headersRow\">");
977                theBuilder
978                                .append("<span class=\"headerName\">")
979                                .append(theHeaderName)
980                                .append(": ")
981                                .append("</span>");
982                theBuilder.append("<span class=\"headerValue\">").append(theHeaderValue).append("</span>");
983                theBuilder.append("</div>");
984        }
985
986        /**
987         * If set to {@literal true} (default is {@literal true}), if the response is a FHIR
988         * resource, and that resource includes a <a href="http://hl7.org/fhir/narrative.html">Narrative</div>,
989         * the narrative will be rendered in the HTML response page as actual rendered HTML.
990         * <p>
991         * The narrative to be rendered will be sourced from one of 3 possible locations,
992         * depending on the resource being returned by the server:
993         *    <ul>
994         *       <li>if the resource is a DomainResource, the narrative in Resource.text will be rendered.</li>
995         *       <li>If the resource is a document bundle, the narrative in the document Composition will be rendered.</li>
996         *       <li>If the resource is a Parameters resource, and the first parameter has the name "Narrative" and a value consisting of a string starting with "{@code <div}", that will be rendered.</li>
997         *    </ul>
998         * </p>
999         * <p>
1000         *    In all cases, the narrative is scanned to ensure that it does not contain any tags
1001         *    or attributes that are not explicitly allowed by the FHIR specification in order
1002         *    to <a href="http://hl7.org/fhir/narrative.html#xhtml">prevent active content</a>.
1003         *    If any such tags or attributes are found, the narrative is not rendered and
1004         *    instead a warning is displayed. Note that while this scanning is helpful, it does
1005         *    not completely mitigate the security risks associated with narratives. See
1006         *    <a href="http://hl7.org/fhir/security.html#narrative">FHIR Security: Narrative</a>
1007         *    for more information.
1008         * </p>
1009         *
1010         * @return Should the narrative be rendered?
1011         * @since 6.6.0
1012         */
1013        public boolean isShowNarrative() {
1014                return myShowNarrative;
1015        }
1016
1017        /**
1018         * If set to {@literal true} (default is {@literal true}), if the response is a FHIR
1019         * resource, and that resource includes a <a href="http://hl7.org/fhir/narrative.html">Narrative</div>,
1020         * the narrative will be rendered in the HTML response page as actual rendered HTML.
1021         * <p>
1022         * The narrative to be rendered will be sourced from one of 3 possible locations,
1023         * depending on the resource being returned by the server:
1024         *    <ul>
1025         *       <li>if the resource is a DomainResource, the narrative in Resource.text will be rendered.</li>
1026         *       <li>If the resource is a document bundle, the narrative in the document Composition will be rendered.</li>
1027         *       <li>If the resource is a Parameters resource, and the first parameter has the name "Narrative" and a value consisting of a string starting with "{@code <div}", that will be rendered.</li>
1028         *    </ul>
1029         * </p>
1030         * <p>
1031         *    In all cases, the narrative is scanned to ensure that it does not contain any tags
1032         *    or attributes that are not explicitly allowed by the FHIR specification in order
1033         *    to <a href="http://hl7.org/fhir/narrative.html#xhtml">prevent active content</a>.
1034         *    If any such tags or attributes are found, the narrative is not rendered and
1035         *    instead a warning is displayed. Note that while this scanning is helpful, it does
1036         *    not completely mitigate the security risks associated with narratives. See
1037         *    <a href="http://hl7.org/fhir/security.html#narrative">FHIR Security: Narrative</a>
1038         *    for more information.
1039         * </p>
1040         *
1041         * @param theShowNarrative Should the narrative be rendered?
1042         * @since 6.6.0
1043         */
1044        public void setShowNarrative(boolean theShowNarrative) {
1045                myShowNarrative = theShowNarrative;
1046        }
1047
1048        /**
1049         * Extracts the narrative from an element (typically a FHIR resource) that holds
1050         * a "text" element
1051         */
1052        @Nullable
1053        private static XhtmlNode extractNarrativeFromElement(@Nonnull IBase theElement, FhirContext ctx) {
1054                if (ctx.getElementDefinition(theElement.getClass()).getChildByName("text") != null) {
1055                        return ctx.newTerser()
1056                                        .getSingleValue(theElement, "text.div", XhtmlNode.class)
1057                                        .orElse(null);
1058                }
1059                return null;
1060        }
1061}