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