
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 = new ResponseDetails(); 379 responseDetails.setResponseResource(oo); 380 responseDetails.setResponseCode(theException.getStatusCode()); 381 382 BaseResourceReturningMethodBinding.callOutgoingFailureOperationOutcomeHook(theRequestDetails, oo); 383 streamResponse( 384 theRequestDetails, 385 theServletResponse, 386 responseDetails.getResponseResource(), 387 null, 388 theServletRequest, 389 responseDetails.getResponseCode()); 390 391 return false; 392 } 393 394 /** 395 * If set to <code>true</code> (default is <code>false</code>) response will include the 396 * request headers 397 */ 398 public boolean isShowRequestHeaders() { 399 return myShowRequestHeaders; 400 } 401 402 /** 403 * If set to <code>true</code> (default is <code>false</code>) response will include the 404 * request headers 405 * 406 * @return Returns a reference to this for easy method chaining 407 */ 408 @SuppressWarnings("UnusedReturnValue") 409 public ResponseHighlighterInterceptor setShowRequestHeaders(boolean theShowRequestHeaders) { 410 myShowRequestHeaders = theShowRequestHeaders; 411 return this; 412 } 413 414 /** 415 * If set to <code>true</code> (default is <code>true</code>) response will include the 416 * response headers 417 */ 418 public boolean isShowResponseHeaders() { 419 return myShowResponseHeaders; 420 } 421 422 /** 423 * If set to <code>true</code> (default is <code>true</code>) response will include the 424 * response headers 425 * 426 * @return Returns a reference to this for easy method chaining 427 */ 428 @SuppressWarnings("UnusedReturnValue") 429 public ResponseHighlighterInterceptor setShowResponseHeaders(boolean theShowResponseHeaders) { 430 myShowResponseHeaders = theShowResponseHeaders; 431 return this; 432 } 433 434 @Hook(value = Pointcut.SERVER_OUTGOING_GRAPHQL_RESPONSE, order = InterceptorOrders.RESPONSE_HIGHLIGHTER_INTERCEPTOR) 435 public boolean outgoingGraphqlResponse( 436 RequestDetails theRequestDetails, 437 String theRequest, 438 String theResponse, 439 HttpServletRequest theServletRequest, 440 HttpServletResponse theServletResponse) 441 throws AuthenticationException { 442 443 /* 444 * Return true here so that we still fire SERVER_OUTGOING_GRAPHQL_RESPONSE! 445 */ 446 447 if (handleOutgoingResponse(theRequestDetails, null, theServletRequest, theServletResponse, theResponse, null)) { 448 return true; 449 } 450 451 theRequestDetails.getUserData().put(RESPONSE_HIGHLIGHTER_INTERCEPTOR_HANDLED_KEY, Boolean.TRUE); 452 453 return true; 454 } 455 456 @Hook(value = Pointcut.SERVER_OUTGOING_RESPONSE, order = InterceptorOrders.RESPONSE_HIGHLIGHTER_INTERCEPTOR) 457 public boolean outgoingResponse( 458 RequestDetails theRequestDetails, 459 ResponseDetails theResponseObject, 460 HttpServletRequest theServletRequest, 461 HttpServletResponse theServletResponse) 462 throws AuthenticationException { 463 464 if (!Boolean.TRUE.equals(theRequestDetails.getUserData().get(RESPONSE_HIGHLIGHTER_INTERCEPTOR_HANDLED_KEY))) { 465 String graphqlResponse = null; 466 IBaseResource resourceResponse = theResponseObject.getResponseResource(); 467 if (handleOutgoingResponse( 468 theRequestDetails, 469 theResponseObject, 470 theServletRequest, 471 theServletResponse, 472 graphqlResponse, 473 resourceResponse)) { 474 return true; 475 } 476 } 477 478 return false; 479 } 480 481 @Hook(Pointcut.SERVER_CAPABILITY_STATEMENT_GENERATED) 482 public void capabilityStatementGenerated( 483 RequestDetails theRequestDetails, IBaseConformance theCapabilityStatement) { 484 FhirTerser terser = theRequestDetails.getFhirContext().newTerser(); 485 486 Set<String> formats = terser.getValues(theCapabilityStatement, "format", IPrimitiveType.class).stream() 487 .map(t -> t.getValueAsString()) 488 .collect(Collectors.toSet()); 489 addFormatConditionally( 490 theCapabilityStatement, terser, formats, Constants.CT_FHIR_JSON_NEW, Constants.FORMATS_HTML_JSON); 491 addFormatConditionally( 492 theCapabilityStatement, terser, formats, Constants.CT_FHIR_XML_NEW, Constants.FORMATS_HTML_XML); 493 addFormatConditionally( 494 theCapabilityStatement, terser, formats, Constants.CT_RDF_TURTLE, Constants.FORMATS_HTML_TTL); 495 } 496 497 private void addFormatConditionally( 498 IBaseConformance theCapabilityStatement, 499 FhirTerser terser, 500 Set<String> formats, 501 String wanted, 502 String toAdd) { 503 if (formats.contains(wanted)) { 504 terser.addElement(theCapabilityStatement, "format", toAdd); 505 } 506 } 507 508 private boolean handleOutgoingResponse( 509 RequestDetails theRequestDetails, 510 ResponseDetails theResponseObject, 511 HttpServletRequest theServletRequest, 512 HttpServletResponse theServletResponse, 513 String theGraphqlResponse, 514 IBaseResource theResourceResponse) { 515 if (theResourceResponse == null && theGraphqlResponse == null) { 516 // this will happen during, for example, a bulk export polling request 517 return true; 518 } 519 /* 520 * Request for _raw 521 */ 522 String[] rawParamValues = theRequestDetails.getParameters().get(PARAM_RAW); 523 if (rawParamValues != null && rawParamValues.length > 0 && rawParamValues[0].equals(PARAM_RAW_TRUE)) { 524 ourLog.warn( 525 "Client is using non-standard/legacy _raw parameter - Use _format=json or _format=xml instead, as this parmameter will be removed at some point"); 526 return true; 527 } 528 529 boolean force = false; 530 String[] formatParams = theRequestDetails.getParameters().get(Constants.PARAM_FORMAT); 531 if (formatParams != null && formatParams.length > 0) { 532 String formatParam = defaultString(formatParams[0]); 533 int semiColonIdx = formatParam.indexOf(';'); 534 if (semiColonIdx != -1) { 535 formatParam = formatParam.substring(0, semiColonIdx); 536 } 537 formatParam = trim(formatParam); 538 539 if (Constants.FORMATS_HTML.contains(formatParam)) { // this is a set 540 force = true; 541 } else if (Constants.FORMATS_HTML_XML.equals(formatParam)) { 542 force = true; 543 theRequestDetails.addParameter(Constants.PARAM_FORMAT, PARAM_FORMAT_VALUE_XML); 544 } else if (Constants.FORMATS_HTML_JSON.equals(formatParam)) { 545 force = true; 546 theRequestDetails.addParameter(Constants.PARAM_FORMAT, PARAM_FORMAT_VALUE_JSON); 547 } else if (Constants.FORMATS_HTML_TTL.equals(formatParam)) { 548 force = true; 549 theRequestDetails.addParameter(Constants.PARAM_FORMAT, PARAM_FORMAT_VALUE_TTL); 550 } else { 551 return true; 552 } 553 } 554 555 /* 556 * It's not a browser... 557 */ 558 Set<String> highestRankedAcceptValues = 559 RestfulServerUtils.parseAcceptHeaderAndReturnHighestRankedOptions(theServletRequest); 560 if (!force && highestRankedAcceptValues.contains(Constants.CT_HTML) == false) { 561 return true; 562 } 563 564 /* 565 * It's an AJAX request, so no HTML 566 */ 567 if (!force && isNotBlank(theServletRequest.getHeader(Constants.HEADER_X_REQUESTED_WITH))) { 568 return true; 569 } 570 /* 571 * If the request has an Origin header, it is probably an AJAX request 572 */ 573 if (!force && isNotBlank(theServletRequest.getHeader(Constants.HEADER_CORS_ORIGIN))) { 574 return true; 575 } 576 577 /* 578 * Not a GET 579 */ 580 if (!force && theRequestDetails.getRequestType() != RequestTypeEnum.GET) { 581 return true; 582 } 583 584 /* 585 * Not binary 586 */ 587 if (!force && theResponseObject != null && (theResponseObject.getResponseResource() instanceof IBaseBinary)) { 588 return true; 589 } 590 591 streamResponse( 592 theRequestDetails, theServletResponse, theResourceResponse, theGraphqlResponse, theServletRequest, 200); 593 return false; 594 } 595 596 private void streamRequestHeaders(ServletRequest theServletRequest, StringBuilder b) { 597 if (theServletRequest instanceof HttpServletRequest) { 598 HttpServletRequest sr = (HttpServletRequest) theServletRequest; 599 b.append("<h1>Request</h1>"); 600 b.append("<div class=\"headersDiv\">"); 601 Enumeration<String> headerNamesEnum = sr.getHeaderNames(); 602 while (headerNamesEnum.hasMoreElements()) { 603 String nextHeaderName = headerNamesEnum.nextElement(); 604 Enumeration<String> headerValuesEnum = sr.getHeaders(nextHeaderName); 605 while (headerValuesEnum.hasMoreElements()) { 606 String nextHeaderValue = headerValuesEnum.nextElement(); 607 appendHeader(b, nextHeaderName, nextHeaderValue); 608 } 609 } 610 b.append("</div>"); 611 } 612 } 613 614 private void streamResponse( 615 RequestDetails theRequestDetails, 616 HttpServletResponse theServletResponse, 617 IBaseResource theResource, 618 String theGraphqlResponse, 619 ServletRequest theServletRequest, 620 int theStatusCode) { 621 EncodingEnum encoding; 622 String encoded; 623 Map<String, String[]> parameters = theRequestDetails.getParameters(); 624 625 if (isNotBlank(theGraphqlResponse)) { 626 627 encoded = theGraphqlResponse; 628 encoding = EncodingEnum.JSON; 629 630 } else { 631 632 IParser p; 633 if (parameters.containsKey(Constants.PARAM_FORMAT)) { 634 FhirVersionEnum forVersion = theResource.getStructureFhirVersionEnum(); 635 p = RestfulServerUtils.getNewParser( 636 theRequestDetails.getServer().getFhirContext(), forVersion, theRequestDetails); 637 } else { 638 EncodingEnum defaultResponseEncoding = 639 theRequestDetails.getServer().getDefaultResponseEncoding(); 640 p = defaultResponseEncoding.newParser( 641 theRequestDetails.getServer().getFhirContext()); 642 RestfulServerUtils.configureResponseParser(theRequestDetails, p); 643 } 644 645 // This interceptor defaults to pretty printing unless the user 646 // has specifically requested us not to 647 boolean prettyPrintResponse = true; 648 String[] prettyParams = parameters.get(Constants.PARAM_PRETTY); 649 if (prettyParams != null && prettyParams.length > 0) { 650 if (Constants.PARAM_PRETTY_VALUE_FALSE.equals(prettyParams[0])) { 651 prettyPrintResponse = false; 652 } 653 } 654 if (prettyPrintResponse) { 655 p.setPrettyPrint(true); 656 } 657 658 encoding = p.getEncoding(); 659 encoded = p.encodeResourceToString(theResource); 660 } 661 662 if (theRequestDetails.getServer() instanceof RestfulServer) { 663 RestfulServer rs = (RestfulServer) theRequestDetails.getServer(); 664 rs.addHeadersToResponse(theServletResponse); 665 } 666 667 try { 668 669 if (theStatusCode > 299) { 670 theServletResponse.setStatus(theStatusCode); 671 } 672 theServletResponse.setContentType(Constants.CT_HTML_WITH_UTF8); 673 674 StringBuilder outputBuffer = new StringBuilder(); 675 outputBuffer.append("<html lang=\"en\">\n"); 676 outputBuffer.append(" <head>\n"); 677 outputBuffer.append(" <meta charset=\"utf-8\" />\n"); 678 outputBuffer.append(" <style>\n"); 679 outputBuffer.append( 680 ClasspathUtil.loadResource("ca/uhn/fhir/rest/server/interceptor/ResponseHighlighter.css")); 681 outputBuffer.append(" </style>\n"); 682 outputBuffer.append(" </head>\n"); 683 outputBuffer.append("\n"); 684 outputBuffer.append(" <body>"); 685 686 outputBuffer.append("<p>"); 687 688 if (isBlank(theGraphqlResponse)) { 689 outputBuffer.append("This result is being rendered in HTML for easy viewing. "); 690 outputBuffer.append("You may access this content as "); 691 692 if (theRequestDetails.getFhirContext().isFormatJsonSupported()) { 693 outputBuffer.append("<a href=\""); 694 outputBuffer.append(createLinkHref(parameters, Constants.FORMAT_JSON)); 695 outputBuffer.append("\">Raw JSON</a> or "); 696 } 697 698 if (theRequestDetails.getFhirContext().isFormatXmlSupported()) { 699 outputBuffer.append("<a href=\""); 700 outputBuffer.append(createLinkHref(parameters, Constants.FORMAT_XML)); 701 outputBuffer.append("\">Raw XML</a> or "); 702 } 703 704 if (theRequestDetails.getFhirContext().isFormatRdfSupported()) { 705 outputBuffer.append("<a href=\""); 706 outputBuffer.append(createLinkHref(parameters, Constants.FORMAT_TURTLE)); 707 outputBuffer.append("\">Raw Turtle</a> or "); 708 } 709 710 outputBuffer.append("view this content in "); 711 712 if (theRequestDetails.getFhirContext().isFormatJsonSupported()) { 713 outputBuffer.append("<a href=\""); 714 outputBuffer.append(createLinkHref(parameters, Constants.FORMATS_HTML_JSON)); 715 outputBuffer.append("\">HTML JSON</a> "); 716 } 717 718 if (theRequestDetails.getFhirContext().isFormatXmlSupported()) { 719 outputBuffer.append("or "); 720 outputBuffer.append("<a href=\""); 721 outputBuffer.append(createLinkHref(parameters, Constants.FORMATS_HTML_XML)); 722 outputBuffer.append("\">HTML XML</a> "); 723 } 724 725 if (theRequestDetails.getFhirContext().isFormatRdfSupported()) { 726 outputBuffer.append("or "); 727 outputBuffer.append("<a href=\""); 728 outputBuffer.append(createLinkHref(parameters, Constants.FORMATS_HTML_TTL)); 729 outputBuffer.append("\">HTML Turtle</a> "); 730 } 731 732 outputBuffer.append("."); 733 } 734 735 Date startTime = (Date) theServletRequest.getAttribute(RestfulServer.REQUEST_START_TIME); 736 if (startTime != null) { 737 long time = System.currentTimeMillis() - startTime.getTime(); 738 outputBuffer.append(" Response generated in "); 739 outputBuffer.append(time); 740 outputBuffer.append("ms."); 741 } 742 743 outputBuffer.append("</p>"); 744 745 outputBuffer.append("\n"); 746 747 // status (e.g. HTTP 200 OK) 748 String statusName = Constants.HTTP_STATUS_NAMES.get(theServletResponse.getStatus()); 749 statusName = defaultString(statusName); 750 outputBuffer.append("<div class=\"httpStatusDiv\">"); 751 outputBuffer.append("HTTP "); 752 outputBuffer.append(theServletResponse.getStatus()); 753 outputBuffer.append(" "); 754 outputBuffer.append(statusName); 755 outputBuffer.append("</div>"); 756 757 outputBuffer.append("\n"); 758 outputBuffer.append("\n"); 759 760 try { 761 if (isShowRequestHeaders()) { 762 streamRequestHeaders(theServletRequest, outputBuffer); 763 } 764 if (isShowResponseHeaders()) { 765 streamResponseHeaders(theRequestDetails, theServletResponse, outputBuffer); 766 } 767 } catch (Throwable t) { 768 // ignore (this will hit if we're running in a servlet 2.5 environment) 769 } 770 771 if (myShowNarrative) { 772 String narrativeHtml = extractNarrativeHtml(theRequestDetails, theResource); 773 if (isNotBlank(narrativeHtml)) { 774 outputBuffer.append("<h1>Narrative</h1>"); 775 outputBuffer.append("<div class=\"narrativeBody\">"); 776 outputBuffer.append(narrativeHtml); 777 outputBuffer.append("</div>"); 778 } 779 } 780 781 outputBuffer.append("<h1>Response Body</h1>"); 782 783 outputBuffer.append("<div class=\"responseBodyTable\">"); 784 785 // Response Body 786 outputBuffer.append("<div class=\"responseBodyTableSecondColumn\"><pre>"); 787 StringBuilder target = new StringBuilder(); 788 int linesCount = format(encoded, target, encoding); 789 outputBuffer.append(target); 790 outputBuffer.append("</pre></div>"); 791 792 // Line Numbers 793 outputBuffer.append("<div class=\"responseBodyTableFirstColumn\"><pre>"); 794 for (int i = 1; i <= linesCount; i++) { 795 outputBuffer.append("<div class=\"lineAnchor\" id=\"anchor"); 796 outputBuffer.append(i); 797 outputBuffer.append("\">"); 798 799 outputBuffer.append("<a href=\"#L"); 800 outputBuffer.append(i); 801 outputBuffer.append("\" name=\"L"); 802 outputBuffer.append(i); 803 outputBuffer.append("\" id=\"L"); 804 outputBuffer.append(i); 805 outputBuffer.append("\">"); 806 outputBuffer.append(i); 807 outputBuffer.append("</a></div>"); 808 } 809 outputBuffer.append("</div></td>"); 810 811 outputBuffer.append("</div>"); 812 813 outputBuffer.append("\n"); 814 815 InputStream jsStream = ResponseHighlighterInterceptor.class.getResourceAsStream("ResponseHighlighter.js"); 816 String jsStr = jsStream != null 817 ? IOUtils.toString(jsStream, StandardCharsets.UTF_8) 818 : "console.log('ResponseHighlighterInterceptor: javascript theResource not found')"; 819 820 String baseUrl = theRequestDetails.getServerBaseForRequest(); 821 822 baseUrl = UrlUtil.sanitizeBaseUrl(baseUrl); 823 824 jsStr = jsStr.replace("FHIR_BASE", baseUrl); 825 outputBuffer.append("<script type=\"text/javascript\">"); 826 outputBuffer.append(jsStr); 827 outputBuffer.append("</script>\n"); 828 829 StopWatch writeSw = new StopWatch(); 830 theServletResponse.getWriter().append(outputBuffer); 831 theServletResponse.getWriter().flush(); 832 833 theServletResponse.getWriter().append("<div class=\"sizeInfo\">"); 834 theServletResponse.getWriter().append("Wrote "); 835 writeLength(theServletResponse, encoded.length()); 836 theServletResponse.getWriter().append(" ("); 837 writeLength(theServletResponse, outputBuffer.length()); 838 theServletResponse.getWriter().append(" total including HTML)"); 839 840 theServletResponse.getWriter().append(" in approximately "); 841 theServletResponse.getWriter().append(writeSw.toString()); 842 theServletResponse.getWriter().append("</div>"); 843 844 theServletResponse.getWriter().append("</body>"); 845 theServletResponse.getWriter().append("</html>"); 846 847 theServletResponse.getWriter().close(); 848 } catch (IOException e) { 849 throw new InternalErrorException(Msg.code(322) + e); 850 } 851 } 852 853 @VisibleForTesting 854 @Nullable 855 String extractNarrativeHtml(@Nonnull RequestDetails theRequestDetails, @Nullable IBaseResource theResource) { 856 if (theResource == null) { 857 return null; 858 } 859 860 FhirContext ctx = theRequestDetails.getFhirContext(); 861 862 // Try to extract the narrative from the resource. First, just see if there 863 // is a narrative in the normal spot. 864 XhtmlNode xhtmlNode = extractNarrativeFromElement(theResource, ctx); 865 866 // If the resource is a document, see if the Composition has a narrative 867 if (xhtmlNode == null && "Bundle".equals(ctx.getResourceType(theResource))) { 868 if ("document".equals(ctx.newTerser().getSinglePrimitiveValueOrNull(theResource, "type"))) { 869 IBaseResource firstResource = 870 ctx.newTerser().getSingleValueOrNull(theResource, "entry.resource", IBaseResource.class); 871 if (firstResource != null && "Composition".equals(ctx.getResourceType(firstResource))) { 872 xhtmlNode = extractNarrativeFromComposition(firstResource, ctx); 873 } 874 } 875 } 876 877 // If the resource is a Parameters, see if it has a narrative in the first 878 // parameter 879 if (xhtmlNode == null && "Parameters".equals(ctx.getResourceType(theResource))) { 880 String firstParameterName = ctx.newTerser().getSinglePrimitiveValueOrNull(theResource, "parameter.name"); 881 if ("Narrative".equals(firstParameterName)) { 882 String firstParameterValue = 883 ctx.newTerser().getSinglePrimitiveValueOrNull(theResource, "parameter.value[x]"); 884 if (defaultString(firstParameterValue).startsWith("<div")) { 885 xhtmlNode = new XhtmlNode(); 886 xhtmlNode.setValueAsString(firstParameterValue); 887 } 888 } 889 } 890 891 /* 892 * Sanitize the narrative so that it's safe to render (strip any 893 * links, potentially unsafe CSS, etc.) 894 */ 895 if (xhtmlNode != null) { 896 xhtmlNode = NarrativeUtil.sanitize(xhtmlNode); 897 return xhtmlNode.getValueAsString(); 898 } 899 900 return null; 901 } 902 903 private XhtmlNode extractNarrativeFromComposition(IBaseResource theComposition, FhirContext theCtx) { 904 XhtmlNode retVal = new XhtmlNode(NodeType.Element, "div"); 905 906 XhtmlNode xhtmlNode = extractNarrativeFromElement(theComposition, theCtx); 907 if (xhtmlNode != null) { 908 retVal.add(xhtmlNode); 909 } 910 911 List<IBase> sections = theCtx.newTerser().getValues(theComposition, "section"); 912 for (IBase section : sections) { 913 String title = theCtx.newTerser().getSinglePrimitiveValueOrNull(section, "title"); 914 if (isNotBlank(title)) { 915 XhtmlNode sectionNarrative = extractNarrativeFromElement(section, theCtx); 916 if (sectionNarrative != null && sectionNarrative.hasChildren()) { 917 XhtmlNode titleNode = new XhtmlNode(NodeType.Element, "h1"); 918 titleNode.addText(title); 919 retVal.add(titleNode); 920 retVal.add(sectionNarrative); 921 } 922 } 923 } 924 925 if (retVal.isEmpty()) { 926 return null; 927 } 928 return retVal; 929 } 930 931 private void writeLength(HttpServletResponse theServletResponse, int theLength) throws IOException { 932 double kb = ((double) theLength) / FileUtils.ONE_KB; 933 if (kb <= 1000) { 934 theServletResponse.getWriter().append(String.format("%.1f", kb)).append(" KB"); 935 } else { 936 double mb = kb / 1000; 937 theServletResponse.getWriter().append(String.format("%.1f", mb)).append(" MB"); 938 } 939 } 940 941 private void streamResponseHeaders( 942 RequestDetails theRequestDetails, HttpServletResponse theServletResponse, StringBuilder b) { 943 if (theServletResponse.getHeaderNames().isEmpty() == false) { 944 b.append("<h1>Response Headers</h1>"); 945 946 b.append("<div class=\"headersDiv\">"); 947 for (String nextHeaderName : theServletResponse.getHeaderNames()) { 948 for (String nextHeaderValue : theServletResponse.getHeaders(nextHeaderName)) { 949 /* 950 * Let's pretend we're returning a FHIR content type even though we're 951 * actually returning an HTML one 952 */ 953 if (nextHeaderName.equalsIgnoreCase(Constants.HEADER_CONTENT_TYPE)) { 954 ResponseEncoding responseEncoding = RestfulServerUtils.determineResponseEncodingNoDefault( 955 theRequestDetails, theRequestDetails.getServer().getDefaultResponseEncoding()); 956 if (responseEncoding != null && isNotBlank(responseEncoding.getResourceContentType())) { 957 nextHeaderValue = responseEncoding.getResourceContentType() + ";charset=utf-8"; 958 } 959 } 960 appendHeader(b, nextHeaderName, nextHeaderValue); 961 } 962 } 963 IRestfulResponse response = theRequestDetails.getResponse(); 964 for (Map.Entry<String, List<String>> next : response.getHeaders().entrySet()) { 965 String name = next.getKey(); 966 for (String nextValue : next.getValue()) { 967 appendHeader(b, name, nextValue); 968 } 969 } 970 971 b.append("</div>"); 972 } 973 } 974 975 private void appendHeader(StringBuilder theBuilder, String theHeaderName, String theHeaderValue) { 976 theBuilder.append("<div class=\"headersRow\">"); 977 theBuilder 978 .append("<span class=\"headerName\">") 979 .append(theHeaderName) 980 .append(": ") 981 .append("</span>"); 982 theBuilder.append("<span class=\"headerValue\">").append(theHeaderValue).append("</span>"); 983 theBuilder.append("</div>"); 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 "{@code <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 * @return Should the narrative be rendered? 1011 * @since 6.6.0 1012 */ 1013 public boolean isShowNarrative() { 1014 return myShowNarrative; 1015 } 1016 1017 /** 1018 * If set to {@literal true} (default is {@literal true}), if the response is a FHIR 1019 * resource, and that resource includes a <a href="http://hl7.org/fhir/narrative.html">Narrative</div>, 1020 * the narrative will be rendered in the HTML response page as actual rendered HTML. 1021 * <p> 1022 * The narrative to be rendered will be sourced from one of 3 possible locations, 1023 * depending on the resource being returned by the server: 1024 * <ul> 1025 * <li>if the resource is a DomainResource, the narrative in Resource.text will be rendered.</li> 1026 * <li>If the resource is a document bundle, the narrative in the document Composition will be rendered.</li> 1027 * <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> 1028 * </ul> 1029 * </p> 1030 * <p> 1031 * In all cases, the narrative is scanned to ensure that it does not contain any tags 1032 * or attributes that are not explicitly allowed by the FHIR specification in order 1033 * to <a href="http://hl7.org/fhir/narrative.html#xhtml">prevent active content</a>. 1034 * If any such tags or attributes are found, the narrative is not rendered and 1035 * instead a warning is displayed. Note that while this scanning is helpful, it does 1036 * not completely mitigate the security risks associated with narratives. See 1037 * <a href="http://hl7.org/fhir/security.html#narrative">FHIR Security: Narrative</a> 1038 * for more information. 1039 * </p> 1040 * 1041 * @param theShowNarrative Should the narrative be rendered? 1042 * @since 6.6.0 1043 */ 1044 public void setShowNarrative(boolean theShowNarrative) { 1045 myShowNarrative = theShowNarrative; 1046 } 1047 1048 /** 1049 * Extracts the narrative from an element (typically a FHIR resource) that holds 1050 * a "text" element 1051 */ 1052 @Nullable 1053 private static XhtmlNode extractNarrativeFromElement(@Nonnull IBase theElement, FhirContext ctx) { 1054 if (ctx.getElementDefinition(theElement.getClass()).getChildByName("text") != null) { 1055 return ctx.newTerser() 1056 .getSingleValue(theElement, "text.div", XhtmlNode.class) 1057 .orElse(null); 1058 } 1059 return null; 1060 } 1061}