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