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