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