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