
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'>""); 231 } else { 232 theTarget.append("<span class='hlTagName'>""); 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'>""); 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'>></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'>""); 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'><</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}