From ee8b5b39d40ed7bc8a44fabed48efcc228321571 Mon Sep 17 00:00:00 2001 From: James Agnew Date: Thu, 30 Mar 2023 10:18:35 -0400 Subject: [PATCH] Render narrative in HTML resource view (#4702) * Working * Add changelog --- .../4702-include-narrative-in-html-view.yaml | 6 + .../ResponseHighlighterInterceptor.java | 244 ++++++++----- .../interceptor/ResponseHighlighter.css | 143 ++++++++ .../ResponseHighlightingInterceptorTest.java | 321 +++++++++++++----- 4 files changed, 536 insertions(+), 178 deletions(-) create mode 100644 hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4702-include-narrative-in-html-view.yaml create mode 100644 hapi-fhir-server/src/main/resources/ca/uhn/fhir/rest/server/interceptor/ResponseHighlighter.css diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4702-include-narrative-in-html-view.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4702-include-narrative-in-html-view.yaml new file mode 100644 index 00000000000..6fa210decf4 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4702-include-narrative-in-html-view.yaml @@ -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." diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/ResponseHighlighterInterceptor.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/ResponseHighlighterInterceptor.java index 9ebc7238cb4..775dd75b761 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/ResponseHighlighterInterceptor.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/ResponseHighlighterInterceptor.java @@ -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(" \n"); outputBuffer.append(" \n"); outputBuffer.append(" \n"); outputBuffer.append(" \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("

Narrative

"); + outputBuffer.append("
"); + outputBuffer.append(narrativeHtml); + outputBuffer.append("
"); + } + } + outputBuffer.append("

Response Body

"); outputBuffer.append("
"); @@ -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(" 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("
    "); + errors.forEach(next -> { + errorNarrative.append("
  • "); + errorNarrative.append(sanitizeUrlPart(next)); + errorNarrative.append("
  • "); + }); + errorNarrative.append("
"); + 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("
"); } + /** + * If set to {@literal true} (default is {@literal true}), if the response is a FHIR + * resource, and that resource includes a Narrative, + * the narrative will be rendered in the HTML response page as actual rendered HTML. + *

+ * The narrative to be rendered will be sourced from one of 3 possible locations, + * depending on the resource being returned by the server: + *

+ *

+ *

+ * 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 prevent active content. + * 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 + * FHIR Security: Narrative + * for more information. + *

+ * + * @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 Narrative, + * the narrative will be rendered in the HTML response page as actual rendered HTML. + *

+ * The narrative to be rendered will be sourced from one of 3 possible locations, + * depending on the resource being returned by the server: + *

+ *

+ *

+ * 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 prevent active content. + * 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 + * FHIR Security: Narrative + * for more information. + *

+ * + * @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; + } + } diff --git a/hapi-fhir-server/src/main/resources/ca/uhn/fhir/rest/server/interceptor/ResponseHighlighter.css b/hapi-fhir-server/src/main/resources/ca/uhn/fhir/rest/server/interceptor/ResponseHighlighter.css new file mode 100644 index 00000000000..797a053a849 --- /dev/null +++ b/hapi-fhir-server/src/main/resources/ca/uhn/fhir/rest/server/interceptor/ResponseHighlighter.css @@ -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; +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/ResponseHighlightingInterceptorTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/ResponseHighlightingInterceptorTest.java index 6dbde322bc4..46b61017b8f 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/ResponseHighlightingInterceptorTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/rest/server/interceptor/ResponseHighlightingInterceptorTest.java @@ -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("
HELLO
"); + + String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), patient); + assertEquals("
HELLO
", 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("
HELLO
"); + bundle.addEntry().setResource(composition); + + String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), bundle); + assertEquals("
HELLO
", outcome); + } + + @Test + public void testExtractNarrativeHtml_ParametersWithNarrativeAsFirstParameter() { + Parameters parameters = new Parameters(); + parameters.addParameter("Narrative", new StringType("
HELLO
")); + + String outcome = ourInterceptor.extractNarrativeHtml(newRequest(), parameters); + assertEquals("
HELLO
", outcome); + } + + @Test + public void testExtractNarrativeHtml_Parameters() { + Parameters parameters = new Parameters(); + parameters.addParameter("Foo", new StringType("
HELLO
")); + + 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("
Header1Header2
A cellA cell
A cell 2A cell 2
"); + 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("

Narrative

")); + assertThat(resp, containsString("Header1Header2")); + } + + } + + + @Test + public void testNarrative_Disabled() throws IOException { + Patient patient = new Patient(); + patient.addName().setFamily("Simpson"); + patient.getText().setDivAsString("
Header1Header2
A cellA cell
A cell 2A cell 2
"); + 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("

Narrative

"))); + assertThat(resp, not(containsString("Header1Header2"))); + } + + } + + @Test + public void testNarrative_SketchyTagBlocked() throws IOException { + Patient patient = new Patient(); + patient.addName().setFamily("Simpson"); + patient.getText().setDivAsString("
Header1Header2
A cellA cell
A cell 2A cell 2
"); + 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("Header1Header2"))); + 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("DATA".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("DATA".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; } } +