Render narrative in HTML resource view (#4702)

* Working

* Add changelog
This commit is contained in:
James Agnew 2023-03-30 10:18:35 -04:00 committed by GitHub
parent e69fe05e96
commit ee8b5b39d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 536 additions and 178 deletions

View File

@ -0,0 +1,6 @@
---
type: add
issue: 4702
title: "The ResponseHighlightingInterceptor (which renders a stylized HTML view
of resources) will now include the resource narrative in the rendered view
if one is present."

View File

@ -19,8 +19,9 @@
*/
package ca.uhn.fhir.rest.server.interceptor;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.interceptor.api.Hook;
import ca.uhn.fhir.interceptor.api.Interceptor;
import ca.uhn.fhir.interceptor.api.Pointcut;
@ -38,24 +39,31 @@ import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.rest.server.method.BaseResourceReturningMethodBinding;
import ca.uhn.fhir.util.ClasspathUtil;
import ca.uhn.fhir.util.FhirTerser;
import ca.uhn.fhir.util.StopWatch;
import ca.uhn.fhir.util.UrlUtil;
import com.google.common.annotations.VisibleForTesting;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.Validate;
import org.apache.commons.text.StringEscapeUtils;
import org.hl7.fhir.instance.model.api.IBaseBinary;
import org.hl7.fhir.instance.model.api.IBaseConformance;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IPrimitiveType;
import org.hl7.fhir.utilities.xhtml.XhtmlNode;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.Enumeration;
import java.util.List;
@ -63,6 +71,7 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static ca.uhn.fhir.util.UrlUtil.sanitizeUrlPart;
import static org.apache.commons.lang3.StringUtils.defaultString;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
@ -89,6 +98,7 @@ public class ResponseHighlighterInterceptor {
private static final String[] PARAM_FORMAT_VALUE_TTL = new String[]{Constants.FORMAT_TURTLE};
private boolean myShowRequestHeaders = false;
private boolean myShowResponseHeaders = true;
private boolean myShowNarrative = true;
/**
* Constructor
@ -427,7 +437,6 @@ public class ResponseHighlighterInterceptor {
}
}
private boolean handleOutgoingResponse(RequestDetails theRequestDetails, ResponseDetails theResponseObject, HttpServletRequest theServletRequest, HttpServletResponse theServletResponse, String theGraphqlResponse, IBaseResource theResourceResponse) {
if (theResourceResponse == null && theGraphqlResponse == null) {
// this will happen during, for example, a bulk export polling request
@ -582,90 +591,7 @@ public class ResponseHighlighterInterceptor {
outputBuffer.append(" <head>\n");
outputBuffer.append(" <meta charset=\"utf-8\" />\n");
outputBuffer.append(" <style>\n");
outputBuffer.append(".httpStatusDiv {");
outputBuffer.append(" font-size: 1.2em;");
outputBuffer.append(" font-weight: bold;");
outputBuffer.append("}");
outputBuffer.append(".hlQuot { color: #88F; }\n");
outputBuffer.append(".hlQuot a { text-decoration: underline; text-decoration-color: #CCC; }\n");
outputBuffer.append(".hlQuot a:HOVER { text-decoration: underline; text-decoration-color: #008; }\n");
outputBuffer.append(".hlQuot .uuid, .hlQuot .dateTime {\n");
outputBuffer.append(" user-select: all;\n");
outputBuffer.append(" -moz-user-select: all;\n");
outputBuffer.append(" -webkit-user-select: all;\n");
outputBuffer.append(" -ms-user-select: element;\n");
outputBuffer.append("}\n");
outputBuffer.append(".hlAttr {\n");
outputBuffer.append(" color: #888;\n");
outputBuffer.append("}\n");
outputBuffer.append(".hlTagName {\n");
outputBuffer.append(" color: #006699;\n");
outputBuffer.append("}\n");
outputBuffer.append(".hlControl {\n");
outputBuffer.append(" color: #660000;\n");
outputBuffer.append("}\n");
outputBuffer.append(".hlText {\n");
outputBuffer.append(" color: #000000;\n");
outputBuffer.append("}\n");
outputBuffer.append(".hlUrlBase {\n");
outputBuffer.append("}");
outputBuffer.append(".headersDiv {\n");
outputBuffer.append(" padding: 10px;");
outputBuffer.append(" margin-left: 10px;");
outputBuffer.append(" border: 1px solid #CCC;");
outputBuffer.append(" border-radius: 10px;");
outputBuffer.append("}");
outputBuffer.append(".headersRow {\n");
outputBuffer.append("}");
outputBuffer.append(".headerName {\n");
outputBuffer.append(" color: #888;\n");
outputBuffer.append(" font-family: monospace;\n");
outputBuffer.append("}");
outputBuffer.append(".headerValue {\n");
outputBuffer.append(" color: #88F;\n");
outputBuffer.append(" font-family: monospace;\n");
outputBuffer.append("}");
outputBuffer.append(".responseBodyTable {");
outputBuffer.append(" width: 100%;\n");
outputBuffer.append(" margin-left: 0px;\n");
outputBuffer.append(" margin-top: -10px;\n");
outputBuffer.append(" position: relative;\n");
outputBuffer.append("}");
outputBuffer.append(".responseBodyTableFirstColumn {");
outputBuffer.append("}");
outputBuffer.append(".responseBodyTableSecondColumn {");
outputBuffer.append(" position: absolute;\n");
outputBuffer.append(" margin-left: 70px;\n");
outputBuffer.append(" vertical-align: top;\n");
outputBuffer.append(" left: 0px;\n");
outputBuffer.append(" right: 0px;\n");
outputBuffer.append("}");
outputBuffer.append(".responseBodyTableSecondColumn PRE {");
outputBuffer.append(" margin: 0px;");
outputBuffer.append("}");
outputBuffer.append(".sizeInfo {");
outputBuffer.append(" margin-top: 20px;");
outputBuffer.append(" font-size: 0.8em;");
outputBuffer.append("}");
outputBuffer.append(".lineAnchor A {");
outputBuffer.append(" text-decoration: none;");
outputBuffer.append(" padding-left: 20px;");
outputBuffer.append("}");
outputBuffer.append(".lineAnchor {");
outputBuffer.append(" display: block;");
outputBuffer.append(" padding-right: 20px;");
outputBuffer.append("}");
outputBuffer.append(".selectedLine {");
outputBuffer.append(" background-color: #EEF;");
outputBuffer.append(" font-weight: bold;");
outputBuffer.append("}");
outputBuffer.append("H1 {");
outputBuffer.append(" font-size: 1.1em;");
outputBuffer.append(" color: #666;");
outputBuffer.append("}");
outputBuffer.append("BODY {\n");
outputBuffer.append(" font-family: Arial;\n");
outputBuffer.append("}");
outputBuffer.append(ClasspathUtil.loadResource("ca/uhn/fhir/rest/server/interceptor/ResponseHighlighter.css"));
outputBuffer.append(" </style>\n");
outputBuffer.append(" </head>\n");
outputBuffer.append("\n");
@ -756,6 +682,16 @@ public class ResponseHighlighterInterceptor {
// ignore (this will hit if we're running in a servlet 2.5 environment)
}
if (myShowNarrative) {
String narrativeHtml = extractNarrativeHtml(theRequestDetails, theResource);
if (isNotBlank(narrativeHtml)) {
outputBuffer.append("<h1>Narrative</h1>");
outputBuffer.append("<div class=\"narrativeBody\">");
outputBuffer.append(narrativeHtml);
outputBuffer.append("</div>");
}
}
outputBuffer.append("<h1>Response Body</h1>");
outputBuffer.append("<div class=\"responseBodyTable\">");
@ -827,6 +763,71 @@ public class ResponseHighlighterInterceptor {
}
}
@VisibleForTesting
@Nullable
String extractNarrativeHtml(@Nonnull RequestDetails theRequestDetails, @Nullable IBaseResource theResource) {
if (theResource == null) {
return null;
}
FhirContext ctx = theRequestDetails.getFhirContext();
// Try to extract the narrative from the resource. First, just see if there
// is a narrative in the normal spot.
XhtmlNode xhtmlNode = extractNarrativeFromDomainResource(theResource, ctx);
// If the resource is a document, see if the Composition has a narrative
if (xhtmlNode == null && "Bundle".equals(ctx.getResourceType(theResource))) {
if ("document".equals(ctx.newTerser().getSinglePrimitiveValueOrNull(theResource, "type"))) {
IBaseResource firstResource = ctx.newTerser().getSingleValueOrNull(theResource, "entry.resource", IBaseResource.class);
if (firstResource != null && "Composition".equals(ctx.getResourceType(firstResource))) {
xhtmlNode = extractNarrativeFromDomainResource(firstResource, ctx);
}
}
}
// If the resource is a Parameters, see if it has a narrative in the first
// parameter
if (xhtmlNode == null && "Parameters".equals(ctx.getResourceType(theResource))) {
String firstParameterName = ctx.newTerser().getSinglePrimitiveValueOrNull(theResource, "parameter.name");
if ("Narrative".equals(firstParameterName)) {
String firstParameterValue = ctx.newTerser().getSinglePrimitiveValueOrNull(theResource, "parameter.value[x]");
if (defaultString(firstParameterValue).startsWith("<div")) {
xhtmlNode = new XhtmlNode();
xhtmlNode.setValueAsString(firstParameterValue);
}
}
}
/*
* FHIR only allows a pretty restricted set of HTML tags and attributes, in order
* to avoid any risk of injection attacks. If anything that isn't explicitly allowed
* by FHIR is present in the narrative we won't render it and instead we'll explain
* what validation problems we found.
*/
if (xhtmlNode != null) {
List<String> errors = new ArrayList<>();
Validate.isTrue(xhtmlNode.getName() == null);
xhtmlNode.getFirstElement().validate(errors, "", true, false, false);
if (errors.size() > 0) {
StringBuilder errorNarrative = new StringBuilder();
errorNarrative.append("Can not render narrative due to validation errors:");
errorNarrative.append("<ul>");
errors.forEach(next -> {
errorNarrative.append("<li>");
errorNarrative.append(sanitizeUrlPart(next));
errorNarrative.append("</li>");
});
errorNarrative.append("</ul>");
return errorNarrative.toString();
}
return xhtmlNode.getValueAsString();
}
return null;
}
private void writeLength(HttpServletResponse theServletResponse, int theLength) throws IOException {
double kb = ((double) theLength) / FileUtils.ONE_KB;
if (kb <= 1000) {
@ -876,4 +877,75 @@ public class ResponseHighlighterInterceptor {
theBuilder.append("</div>");
}
/**
* If set to {@literal true} (default is {@literal true}), if the response is a FHIR
* resource, and that resource includes a <a href="http://hl7.org/fhir/narrative.html">Narrative</div>,
* the narrative will be rendered in the HTML response page as actual rendered HTML.
* <p>
* The narrative to be rendered will be sourced from one of 3 possible locations,
* depending on the resource being returned by the server:
* <ul>
* <li>if the resource is a DomainResource, the narrative in Resource.text will be rendered.</li>
* <li>If the resource is a document bundle, the narrative in the document Composition will be rendered.</li>
* <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>
* </ul>
* </p>
* <p>
* In all cases, the narrative is scanned to ensure that it does not contain any tags
* or attributes that are not explicitly allowed by the FHIR specification in order
* to <a href="http://hl7.org/fhir/narrative.html#xhtml">prevent active content</a>.
* If any such tags or attributes are found, the narrative is not rendered and
* instead a warning is displayed. Note that while this scanning is helpful, it does
* not completely mitigate the security risks associated with narratives. See
* <a href="http://hl7.org/fhir/security.html#narrative">FHIR Security: Narrative</a>
* for more information.
* </p>
*
* @return Should the narrative be rendered?
* @since 6.6.0
*/
public boolean isShowNarrative() {
return myShowNarrative;
}
/**
* If set to {@literal true} (default is {@literal true}), if the response is a FHIR
* resource, and that resource includes a <a href="http://hl7.org/fhir/narrative.html">Narrative</div>,
* the narrative will be rendered in the HTML response page as actual rendered HTML.
* <p>
* The narrative to be rendered will be sourced from one of 3 possible locations,
* depending on the resource being returned by the server:
* <ul>
* <li>if the resource is a DomainResource, the narrative in Resource.text will be rendered.</li>
* <li>If the resource is a document bundle, the narrative in the document Composition will be rendered.</li>
* <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>
* </ul>
* </p>
* <p>
* In all cases, the narrative is scanned to ensure that it does not contain any tags
* or attributes that are not explicitly allowed by the FHIR specification in order
* to <a href="http://hl7.org/fhir/narrative.html#xhtml">prevent active content</a>.
* If any such tags or attributes are found, the narrative is not rendered and
* instead a warning is displayed. Note that while this scanning is helpful, it does
* not completely mitigate the security risks associated with narratives. See
* <a href="http://hl7.org/fhir/security.html#narrative">FHIR Security: Narrative</a>
* for more information.
* </p>
*
* @param theShowNarrative Should the narrative be rendered?
* @since 6.6.0
*/
public void setShowNarrative(boolean theShowNarrative) {
myShowNarrative = theShowNarrative;
}
@Nullable
private static XhtmlNode extractNarrativeFromDomainResource(@Nonnull IBaseResource theResource, FhirContext ctx) {
if (ctx.getResourceDefinition(theResource).getChildByName("text") != null) {
return ctx.newTerser().getSingleValue(theResource, "text.div", XhtmlNode.class).orElse(null);
}
return null;
}
}

View File

@ -0,0 +1,143 @@
.httpStatusDiv {
font-size: 1.2em;
font-weight: bold;
}
.hlQuot {
color: #88F;
}
.hlQuot a {
text-decoration: underline;
text-decoration-color: #CCC;
}
.hlQuot a:HOVER {
text-decoration: underline;
text-decoration-color: #008;
}
.hlQuot .uuid, .hlQuot .dateTime {
user-select: all;
-moz-user-select: all;
-webkit-user-select: all;
-ms-user-select: element;
}
.hlAttr {
color: #888;
}
.hlTagName {
color: #006699;
}
.hlControl {
color: #660000;
}
.hlText {
color: #000000;
}
.hlUrlBase {
}
.headersDiv {
padding: 10px;
margin-left: 10px;
border: 1px solid #CCC;
border-radius: 10px;
}
.headersRow {
}
.headerName {
color: #888;
font-family: monospace;
}
.headerValue {
color: #88F;
font-family: monospace;
}
.narrativeBody {
padding: 10px;
margin-left: 10px;
border: 1px solid #CCC;
border-radius: 10px;
}
.narrativeBody DIV,
.narrativeBody TABLE {
font-size: 0.9em;
}
.narrativeBody TABLE > THEAD {
background: #AAA;
}
.narrativeBody TABLE > TBODY > TR:nth-child(odd) > TD {
background: #CCC;
}
.narrativeBody TABLE > TBODY > TR:nth-child(even) > TD {
background: #EEE;
}
.narrativeBody TABLE TD, .narrativeBody TABLE TH {
padding: 5px;
}
.responseBodyTable {
width: 100%;
margin-left: 0px;
margin-top: -10px;
position: relative;
}
.responseBodyTableFirstColumn {
}
.responseBodyTableSecondColumn {
position: absolute;
margin-left: 70px;
vertical-align: top;
left: 0px;
right: 0px;
}
.responseBodyTableSecondColumn PRE {
margin: 0px;
}
.sizeInfo {
margin-top: 20px;
font-size: 0.8em;
}
.lineAnchor A {
text-decoration: none;
padding-left: 20px;
}
.lineAnchor {
display: block;
padding-right: 20px;
}
.selectedLine {
background-color: #EEF;
font-weight: bold;
}
H1 {
font-size: 1.1em;
color: #666;
}
BODY {
font-family: Arial;
}

View File

@ -17,6 +17,7 @@ import ca.uhn.fhir.rest.api.EncodingEnum;
import ca.uhn.fhir.rest.api.RequestTypeEnum;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.api.server.ResponseDetails;
import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
import ca.uhn.fhir.rest.server.IResourceProvider;
import ca.uhn.fhir.rest.server.IRestfulServerDefaults;
import ca.uhn.fhir.rest.server.RestfulServer;
@ -40,20 +41,28 @@ import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.Binary;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Composition;
import org.hl7.fhir.r4.model.HumanName;
import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.Identifier;
import org.hl7.fhir.r4.model.OperationOutcome;
import org.hl7.fhir.r4.model.Organization;
import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.Patient;
import org.hl7.fhir.r4.model.Quantity;
import org.hl7.fhir.r4.model.StringType;
import org.hl7.fhir.r4.model.Type;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.cors.CorsConfiguration;
import javax.annotation.Nonnull;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
@ -82,17 +91,20 @@ import static org.mockito.Mockito.when;
public class ResponseHighlightingInterceptorTest {
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ResponseHighlightingInterceptorTest.class);
private static ResponseHighlighterInterceptor ourInterceptor = new ResponseHighlighterInterceptor();
private static Server ourServer;
private static final ResponseHighlighterInterceptor ourInterceptor = new ResponseHighlighterInterceptor();
private static final FhirContext ourCtx = FhirContext.forR4Cached();
private static Server ourServer;
private static CloseableHttpClient ourClient;
private static FhirContext ourCtx = FhirContext.forR4();
private static int ourPort;
private static RestfulServer ourServlet;
private static DummyPatientResourceProvider ourPatientProvider = new DummyPatientResourceProvider();
@BeforeEach
public void before() {
ourInterceptor.setShowRequestHeaders(new ResponseHighlighterInterceptor().isShowRequestHeaders());
ourInterceptor.setShowResponseHeaders(new ResponseHighlighterInterceptor().isShowResponseHeaders());
ResponseHighlighterInterceptor defaults = new ResponseHighlighterInterceptor();
ourInterceptor.setShowRequestHeaders(defaults.isShowRequestHeaders());
ourInterceptor.setShowResponseHeaders(defaults.isShowResponseHeaders());
ourInterceptor.setShowNarrative(defaults.isShowNarrative());
}
/**
@ -189,8 +201,6 @@ public class ResponseHighlightingInterceptorTest {
@Test
public void testDontHighlightWhenOriginHeaderPresent() throws Exception {
ResponseHighlighterInterceptor ic = ourInterceptor;
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeaders(Constants.HEADER_ACCEPT)).thenAnswer(theInvocation -> new ArrayEnumeration<>("text/html,application/xhtml+xml,application/xml;q=0.9"));
when(req.getHeader(Constants.HEADER_ORIGIN)).thenAnswer(theInvocation -> "http://example.com");
@ -210,10 +220,86 @@ public class ResponseHighlightingInterceptorTest {
reqDetails.setServletRequest(req);
// true means it decided to not handle the request..
assertTrue(ic.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
assertTrue(ourInterceptor.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
}
@Test
public void testExtractNarrativeHtml_DomainResource() {
Patient patient = new Patient();
patient.addName().setFamily("Simpson");
patient.getText().setDivAsString("<div>HELLO</div>");
String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), patient);
assertEquals("<div xmlns=\"http://www.w3.org/1999/xhtml\">HELLO</div>", outcome);
}
@Test
public void testExtractNarrativeHtml_NonDomainResource() {
Bundle bundle = new Bundle();
bundle.setType(Bundle.BundleType.TRANSACTION);
String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), bundle);
assertNull(outcome);
}
@Test
public void testExtractNarrativeHtml_DocumentWithCompositionNarrative() {
Bundle bundle = new Bundle();
bundle.setType(Bundle.BundleType.DOCUMENT);
Composition composition = new Composition();
composition.getText().setDivAsString("<div>HELLO</div>");
bundle.addEntry().setResource(composition);
String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), bundle);
assertEquals("<div xmlns=\"http://www.w3.org/1999/xhtml\">HELLO</div>", outcome);
}
@Test
public void testExtractNarrativeHtml_ParametersWithNarrativeAsFirstParameter() {
Parameters parameters = new Parameters();
parameters.addParameter("Narrative", new StringType("<div>HELLO</div>"));
String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), parameters);
assertEquals("<div xmlns=\"http://www.w3.org/1999/xhtml\">HELLO</div>", outcome);
}
@Test
public void testExtractNarrativeHtml_Parameters() {
Parameters parameters = new Parameters();
parameters.addParameter("Foo", new StringType("<div>HELLO</div>"));
String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), parameters);
assertNull(outcome);
}
@Test
public void testExtractNarrativeHtml_ParametersWithNonNarrativeFirstParameter_1() {
Parameters parameters = new Parameters();
parameters.addParameter("Narrative", new Quantity(123L));
String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), parameters);
assertNull(outcome);
}
@Test
public void testExtractNarrativeHtml_ParametersWithNonNarrativeFirstParameter_2() {
Parameters parameters = new Parameters();
parameters.addParameter("Narrative", (Type)null);
String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), parameters);
assertNull(outcome);
}
@Test
public void testExtractNarrativeHtml_ParametersWithNonNarrativeFirstParameter_3() {
Parameters parameters = new Parameters();
parameters.addParameter("Narrative", new StringType("hello"));
String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), parameters);
assertNull(outcome);
}
@Test
public void testForceApplicationJson() throws Exception {
HttpGet httpGet = new HttpGet("http://localhost:" + ourPort + "/Patient/1?_format=application/json");
@ -467,8 +553,6 @@ public class ResponseHighlightingInterceptorTest {
@Test
public void testHighlightException() throws Exception {
ResponseHighlighterInterceptor ic = ourInterceptor;
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeaders(Constants.HEADER_ACCEPT)).thenAnswer(theInvocation -> new ArrayEnumeration<>("text/html,application/xhtml+xml,application/xml;q=0.9"));
@ -492,7 +576,7 @@ public class ResponseHighlightingInterceptorTest {
ResourceNotFoundException exception = new ResourceNotFoundException("Not found");
exception.setOperationOutcome(new OperationOutcome().addIssue(new OperationOutcome.OperationOutcomeIssueComponent().setDiagnostics("Hello")));
assertFalse(ic.handleException(reqDetails, exception, req, resp));
assertFalse(ourInterceptor.handleException(reqDetails, exception, req, resp));
String output = sw.getBuffer().toString();
ourLog.info(output);
@ -526,14 +610,11 @@ public class ResponseHighlightingInterceptorTest {
}
/**
* See #346
*/
@Test
public void testHighlightForceHtmlCt() throws Exception {
ResponseHighlighterInterceptor ic = ourInterceptor;
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeaders(Constants.HEADER_ACCEPT)).thenAnswer(theInvocation -> new ArrayEnumeration<>("application/xml+fhir"));
@ -553,7 +634,7 @@ public class ResponseHighlightingInterceptorTest {
reqDetails.setServletRequest(req);
// false means it decided to handle the request..
assertFalse(ic.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
assertFalse(ourInterceptor.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
}
/**
@ -561,7 +642,6 @@ public class ResponseHighlightingInterceptorTest {
*/
@Test
public void testHighlightForceHtmlFormat() throws Exception {
ResponseHighlighterInterceptor ic = ourInterceptor;
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeaders(Constants.HEADER_ACCEPT)).thenAnswer(theInvocation -> new ArrayEnumeration<>("application/xml+fhir"));
@ -582,13 +662,11 @@ public class ResponseHighlightingInterceptorTest {
reqDetails.setServletRequest(req);
// false means it decided to handle the request..
assertFalse(ic.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
assertFalse(ourInterceptor.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
}
@Test
public void testHighlightForceRaw() throws Exception {
ResponseHighlighterInterceptor ic = ourInterceptor;
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeaders(Constants.HEADER_ACCEPT)).thenAnswer(theInvocation -> new ArrayEnumeration<>("text/html,application/xhtml+xml,application/xml;q=0.9"));
@ -610,13 +688,12 @@ public class ResponseHighlightingInterceptorTest {
reqDetails.setServletRequest(req);
// true means it decided to not handle the request..
assertTrue(ic.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
assertTrue(ourInterceptor.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
}
@Test
public void testHighlightNormalResponse() throws Exception {
ResponseHighlighterInterceptor ic = ourInterceptor;
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeaders(Constants.HEADER_ACCEPT)).thenAnswer(theInvocation -> new ArrayEnumeration<>("text/html,application/xhtml+xml,application/xml;q=0.9"));
@ -636,7 +713,7 @@ public class ResponseHighlightingInterceptorTest {
reqDetails.setServer(server);
reqDetails.setServletRequest(req);
assertFalse(ic.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
assertFalse(ourInterceptor.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
String output = sw.getBuffer().toString();
ourLog.info(output);
@ -647,8 +724,6 @@ public class ResponseHighlightingInterceptorTest {
@Test
public void testHighlightNormalResponseForcePrettyPrint() throws Exception {
ResponseHighlighterInterceptor ic = ourInterceptor;
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeaders(Constants.HEADER_ACCEPT)).thenAnswer(theInvocation -> new ArrayEnumeration<>("text/html,application/xhtml+xml,application/xml;q=0.9"));
@ -669,7 +744,7 @@ public class ResponseHighlightingInterceptorTest {
reqDetails.setServer(server);
reqDetails.setServletRequest(req);
assertFalse(ic.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
assertFalse(ourInterceptor.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
String output = sw.getBuffer().toString();
ourLog.info(output);
@ -682,8 +757,6 @@ public class ResponseHighlightingInterceptorTest {
*/
@Test
public void testHighlightProducesDefaultJsonWithBrowserRequest() throws Exception {
ResponseHighlighterInterceptor ic = ourInterceptor;
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeaders(Constants.HEADER_ACCEPT)).thenAnswer(theInvocation -> new ArrayEnumeration<>("text/html,application/xhtml+xml,application/xml;q=0.9"));
@ -703,7 +776,7 @@ public class ResponseHighlightingInterceptorTest {
reqDetails.setServer(server);
reqDetails.setServletRequest(req);
assertFalse(ic.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
assertFalse(ourInterceptor.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
String output = sw.getBuffer().toString();
ourLog.info(output);
@ -712,8 +785,6 @@ public class ResponseHighlightingInterceptorTest {
@Test
public void testHighlightProducesDefaultJsonWithBrowserRequest2() throws Exception {
ResponseHighlighterInterceptor ic = ourInterceptor;
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeaders(Constants.HEADER_ACCEPT)).thenAnswer(theInvocation -> new ArrayEnumeration<>("text/html;q=0.8,application/xhtml+xml,application/xml;q=0.9"));
@ -734,7 +805,7 @@ public class ResponseHighlightingInterceptorTest {
reqDetails.setServletRequest(req);
// True here means the interceptor didn't handle the request, because HTML wasn't the top ranked accept header
assertTrue(ic.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
assertTrue(ourInterceptor.outgoingResponse(reqDetails, new ResponseDetails(resource), req, resp));
}
/**
@ -871,6 +942,60 @@ public class ResponseHighlightingInterceptorTest {
assertThat(responseContent, (containsStringIgnoringCase("Content-Type")));
}
@Test
public void testNarrative() throws IOException {
Patient patient = new Patient();
patient.addName().setFamily("Simpson");
patient.getText().setDivAsString("<div><table><thead><tr><th>Header1</th><th>Header2</th></tr></thead><tr><td>A cell</td><td>A cell</td></tr><tr><td>A cell 2</td><td>A cell 2</td></tr></table></div>");
ourPatientProvider.myNextPatientOpResponse = patient;
String url = "http://localhost:" + ourPort + "/Patient/1/$patientOp?_format=html/json";
HttpGet httpGet = new HttpGet(url);
try (CloseableHttpResponse response = ourClient.execute(httpGet)) {
String resp = IOUtils.toString(response.getEntity().getContent(), Charsets.UTF_8);
assertThat(resp, containsString("<h1>Narrative</h1>"));
assertThat(resp, containsString("<thead><tr><th>Header1</th><th>Header2</th></tr></thead>"));
}
}
@Test
public void testNarrative_Disabled() throws IOException {
Patient patient = new Patient();
patient.addName().setFamily("Simpson");
patient.getText().setDivAsString("<div><table><thead><tr><th>Header1</th><th>Header2</th></tr></thead><tr><td>A cell</td><td>A cell</td></tr><tr><td>A cell 2</td><td>A cell 2</td></tr></table></div>");
ourPatientProvider.myNextPatientOpResponse = patient;
ourInterceptor.setShowNarrative(false);
String url = "http://localhost:" + ourPort + "/Patient/1/$patientOp?_format=html/json";
HttpGet httpGet = new HttpGet(url);
try (CloseableHttpResponse response = ourClient.execute(httpGet)) {
String resp = IOUtils.toString(response.getEntity().getContent(), Charsets.UTF_8);
assertThat(resp, not(containsString("<h1>Narrative</h1>")));
assertThat(resp, not(containsString("<thead><tr><th>Header1</th><th>Header2</th></tr></thead>")));
}
}
@Test
public void testNarrative_SketchyTagBlocked() throws IOException {
Patient patient = new Patient();
patient.addName().setFamily("Simpson");
patient.getText().setDivAsString("<div><table onclick=\"foo();\"><thead><tr><th>Header1</th><th>Header2</th></tr></thead><tr><td>A cell</td><td>A cell</td></tr><tr><td>A cell 2</td><td>A cell 2</td></tr></table></div>");
ourPatientProvider.myNextPatientOpResponse = patient;
String url = "http://localhost:" + ourPort + "/Patient/1/$patientOp?_format=html/json";
HttpGet httpGet = new HttpGet(url);
try (CloseableHttpResponse response = ourClient.execute(httpGet)) {
String resp = IOUtils.toString(response.getEntity().getContent(), Charsets.UTF_8);
assertThat(resp, not(containsString("<thead><tr><th>Header1</th><th>Header2</th></tr></thead>")));
assertThat(resp, containsString("Error at div/table: Found attribute table.onclick in a resource"));
}
}
@Test
public void testNullResponseResource() {
ourInterceptor.setShowResponseHeaders(true);
@ -895,64 +1020,6 @@ public class ResponseHighlightingInterceptorTest {
assertTrue(ourInterceptor.outgoingResponse(requestDetails, responseObject, servletRequest, servletResponse));
}
@AfterAll
public static void afterClassClearContext() throws Exception {
JettyUtil.closeServer(ourServer);
TestUtil.randomizeLocaleAndTimezone();
}
public static class GraphQLProvider {
@GraphQL
public String processGraphQlRequest(ServletRequestDetails theRequestDetails, @IdParam IIdType theId, @GraphQLQueryUrl String theQuery) {
return "{\"foo\":\"bar\"}";
}
}
@BeforeAll
public static void beforeClass() throws Exception {
ourServer = new Server(0);
DummyPatientResourceProvider patientProvider = new DummyPatientResourceProvider();
ServletHandler proxyHandler = new ServletHandler();
ourServlet = new RestfulServer(ourCtx);
ourServlet.setDefaultResponseEncoding(EncodingEnum.XML);
/*
* Enable CORS
*/
CorsConfiguration config = new CorsConfiguration();
CorsInterceptor corsInterceptor = new CorsInterceptor(config);
config.addAllowedHeader("Origin");
config.addAllowedHeader("Accept");
config.addAllowedHeader("X-Requested-With");
config.addAllowedHeader("Content-Type");
config.addAllowedHeader("Access-Control-Request-Method");
config.addAllowedHeader("Access-Control-Request-Headers");
config.addAllowedOrigin("*");
config.addExposedHeader("Location");
config.addExposedHeader("Content-Location");
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
ourServlet.registerInterceptor(corsInterceptor);
ourServlet.registerInterceptor(ourInterceptor);
ourServlet.registerProviders(patientProvider, new DummyBinaryResourceProvider(), new GraphQLProvider());
ourServlet.setBundleInclusionRule(BundleInclusionRule.BASED_ON_RESOURCE_PRESENCE);
ServletHolder servletHolder = new ServletHolder(ourServlet);
proxyHandler.addServletWithMapping(servletHolder, "/*");
ourServer.setHandler(proxyHandler);
JettyUtil.startServer(ourServer);
ourPort = JettyUtil.getPortForStartedServer(ourServer);
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(5000, TimeUnit.MILLISECONDS);
HttpClientBuilder builder = HttpClientBuilder.create();
builder.setConnectionManager(connectionManager);
ourClient = builder.build();
}
class TestServletRequestDetails extends ServletRequestDetails {
TestServletRequestDetails(IInterceptorBroadcaster theInterceptorBroadcaster) {
super(theInterceptorBroadcaster);
@ -964,6 +1031,13 @@ public class ResponseHighlightingInterceptorTest {
}
}
public static class GraphQLProvider {
@GraphQL
public String processGraphQlRequest(ServletRequestDetails theRequestDetails, @IdParam IIdType theId, @GraphQLQueryUrl String theQuery) {
return "{\"foo\":\"bar\"}";
}
}
public static class DummyBinaryResourceProvider implements IResourceProvider {
@Override
@ -978,7 +1052,7 @@ public class ResponseHighlightingInterceptorTest {
if (theId.getIdPart().equals("html")) {
retVal.setContent("<html>DATA</html>".getBytes(Charsets.UTF_8));
retVal.setContentType("text/html");
}else {
} else {
retVal.setContent(new byte[]{1, 2, 3, 4});
retVal.setContentType(theId.getIdPart());
}
@ -998,6 +1072,8 @@ public class ResponseHighlightingInterceptorTest {
public static class DummyPatientResourceProvider implements IResourceProvider {
private Patient myNextPatientOpResponse;
private Patient createPatient1() {
Patient patient = new Patient();
patient.addIdentifier();
@ -1031,14 +1107,14 @@ public class ResponseHighlightingInterceptorTest {
return Collections.singletonList(p);
}
@Operation(name="binaryOp", idempotent = true)
@Operation(name = "binaryOp", idempotent = true)
public Binary binaryOp(@IdParam IdType theId) {
Binary retVal = new Binary();
retVal.setId(theId);
if (theId.getIdPart().equals("html")) {
retVal.setContent("<html>DATA</html>".getBytes(Charsets.UTF_8));
retVal.setContentType("text/html");
}else {
} else {
retVal.setContent(new byte[]{1, 2, 3, 4});
retVal.setContentType(theId.getIdPart());
}
@ -1108,6 +1184,67 @@ public class ResponseHighlightingInterceptorTest {
return Collections.singletonList(p);
}
@Operation(name = "patientOp", idempotent = true)
public Patient patientOp(@IdParam IIdType theId) {
return myNextPatientOpResponse;
}
}
@AfterAll
public static void afterClassClearContext() throws Exception {
JettyUtil.closeServer(ourServer);
TestUtil.randomizeLocaleAndTimezone();
}
@BeforeAll
public static void beforeClass() throws Exception {
ourServer = new Server(0);
ServletHandler proxyHandler = new ServletHandler();
ourServlet = new RestfulServer(ourCtx);
ourServlet.setDefaultResponseEncoding(EncodingEnum.XML);
/*
* Enable CORS
*/
CorsConfiguration config = new CorsConfiguration();
CorsInterceptor corsInterceptor = new CorsInterceptor(config);
config.addAllowedHeader("Origin");
config.addAllowedHeader("Accept");
config.addAllowedHeader("X-Requested-With");
config.addAllowedHeader("Content-Type");
config.addAllowedHeader("Access-Control-Request-Method");
config.addAllowedHeader("Access-Control-Request-Headers");
config.addAllowedOrigin("*");
config.addExposedHeader("Location");
config.addExposedHeader("Content-Location");
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
ourServlet.registerInterceptor(corsInterceptor);
ourServlet.registerInterceptor(ourInterceptor);
ourServlet.registerProviders(ourPatientProvider, new DummyBinaryResourceProvider(), new GraphQLProvider());
ourServlet.setBundleInclusionRule(BundleInclusionRule.BASED_ON_RESOURCE_PRESENCE);
ServletHolder servletHolder = new ServletHolder(ourServlet);
proxyHandler.addServletWithMapping(servletHolder, "/*");
ourServer.setHandler(proxyHandler);
JettyUtil.startServer(ourServer);
ourPort = JettyUtil.getPortForStartedServer(ourServer);
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(5000, TimeUnit.MILLISECONDS);
HttpClientBuilder builder = HttpClientBuilder.create();
builder.setConnectionManager(connectionManager);
ourClient = builder.build();
}
@Nonnull
private static SystemRequestDetails newRequest() {
SystemRequestDetails retVal = new SystemRequestDetails();
retVal.setFhirContext(ourCtx);
return retVal;
}
}