Add response size capturing interceptor (#1776)

* Work on capturing repsonse sizes

* Interceptor complete

* Add changelog

* Test fix
This commit is contained in:
James Agnew 2020-03-30 11:24:10 -04:00 committed by GitHub
parent 97ac551c87
commit fe48313100
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 285 additions and 11 deletions

View File

@ -27,7 +27,12 @@ import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
import javax.annotation.Nonnull;
import java.util.*;
import java.io.Writer;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Value for {@link Hook#value()}
@ -374,6 +379,44 @@ public enum Pointcut {
),
/**
* <b>Server Hook:</b>
* This method is called when a stream writer is generated that will be used to stream a non-binary response to
* a client. Hooks may return a wrapped writer which adds additional functionality as needed.
*
* <p>
* Hooks may accept the following parameters:
* <ul>
* <li>
* java.io.Writer - The response writing Writer. Typically a hook will wrap this writer and layer additional functionality
* into the wrapping writer.
* </li>
* <li>
* ca.uhn.fhir.rest.api.server.RequestDetails - A bean containing details about the request that is about to be processed, including details such as the
* resource type and logical ID (if any) and other FHIR-specific aspects of the request which have been
* pulled out of the servlet request.
* </li>
* <li>
* ca.uhn.fhir.rest.server.servlet.ServletRequestDetails - A bean containing details about the request that is about to be processed, including details such as the
* resource type and logical ID (if any) and other FHIR-specific aspects of the request which have been
* pulled out of the servlet request. This parameter is identical to the RequestDetails parameter above but will
* only be populated when operating in a RestfulServer implementation. It is provided as a convenience.
* </li>
* </ul>
* </p>
* <p>
* Hook methods should return a {@link Writer} instance that will be used to stream the response. Hook methods
* should not throw any exception.
* </p>
*
* @since 5.0.0
*/
SERVER_OUTGOING_WRITER_CREATED(Writer.class,
"java.io.Writer",
"ca.uhn.fhir.rest.api.server.RequestDetails",
"ca.uhn.fhir.rest.server.servlet.ServletRequestDetails"
),
/**
* <b>Server Hook:</b>
@ -1722,9 +1765,7 @@ public enum Pointcut {
* This pointcut is used only for unit tests. Do not use in production code as it may be changed or
* removed at any time.
*/
TEST_RO(BaseServerResponseException.class, String.class.getName(), String.class.getName())
;
TEST_RO(BaseServerResponseException.class, String.class.getName(), String.class.getName());
private final List<String> myParameterTypes;
private final Class<?> myReturnType;

View File

@ -0,0 +1,6 @@
---
type: add
issue: 1776
title: A new server interceptor called `ResponseSizeCapturingInterceptor` has been added. This interceptor captures and makes
available the number of characters written (pre-compression if Gzip compression is being used) to the HTTP response
stream for FHIR responses.

View File

@ -154,3 +154,10 @@ If you wish to override the value of `Resource.meta.source` using the value supp
* [CaptureResourceSourceFromHeaderInterceptor JavaDoc](/apidocs/hapi-fhir-server/ca/uhn/fhir/rest/server/interceptor/CaptureResourceSourceFromHeaderInterceptor.html)
* [CaptureResourceSourceFromHeaderInterceptor Source](https://github.com/jamesagnew/hapi-fhir/blob/master/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/CaptureResourceSourceFromHeaderInterceptor.java)
# Utility: ResponseSizeCapturingInterceptor
The ResponseSizeCapturingInterceptor can be used to capture the number of characters written in each HTTP FHIR response.
* [ResponseSizeCapturingInterceptor JavaDoc](/apidocs/hapi-fhir-server/ca/uhn/fhir/rest/server/interceptor/ResponseSizeCapturingInterceptor.html)
* [ResponseSizeCapturingInterceptor Source](https://github.com/jamesagnew/hapi-fhir/blob/master/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/ResponseSizeCapturingInterceptor.java)

View File

@ -462,6 +462,15 @@ public abstract class RequestDetails {
if (myRequestContents == null) {
myRequestContents = getByteStreamRequestContents();
}
return getRequestContentsIfLoaded();
}
/**
* Returns the request contents if they were loaded, returns <code>null</code> otherwise
*
* @see #loadRequestContents()
*/
public byte[] getRequestContentsIfLoaded() {
return myRequestContents;
}

View File

@ -22,6 +22,8 @@ package ca.uhn.fhir.rest.server;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.interceptor.api.HookParams;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.model.api.IResource;
import ca.uhn.fhir.model.api.Include;
import ca.uhn.fhir.model.api.ResourceMetadataKeyEnum;
@ -42,6 +44,7 @@ import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import ca.uhn.fhir.rest.server.method.ElementsParameter;
import ca.uhn.fhir.rest.server.method.SummaryEnumParameter;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import ca.uhn.fhir.util.BinaryUtil;
import ca.uhn.fhir.util.DateUtils;
import ca.uhn.fhir.util.UrlUtil;
@ -74,7 +77,7 @@ public class RestfulServerUtils {
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(RestfulServerUtils.class);
private static final HashSet<String> TEXT_ENCODE_ELEMENTS = new HashSet<>(Arrays.asList("*.text", "*.id", "*.meta", "*.(mandatory)"));
private static Map<FhirVersionEnum, FhirContext> myFhirContextMap = Collections.synchronizedMap(new HashMap<FhirVersionEnum, FhirContext>());
private static Map<FhirVersionEnum, FhirContext> myFhirContextMap = Collections.synchronizedMap(new HashMap<>());
private enum NarrativeModeEnum {
NORMAL, ONLY, SUPPRESS;
@ -893,6 +896,19 @@ public class RestfulServerUtils {
String charset = Constants.CHARSET_NAME_UTF8;
Writer writer = response.getResponseWriter(theStatusCode, theStatusMessage, contentType, charset, respondGzip);
// Interceptor call: SERVER_OUTGOING_WRITER_CREATED
if (theServer.getInterceptorService() != null && theServer.getInterceptorService().hasHooks(Pointcut.SERVER_OUTGOING_WRITER_CREATED)) {
HookParams params = new HookParams()
.add(Writer.class, writer)
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails);
Object newWriter = theServer.getInterceptorService().callHooksAndReturnObject(Pointcut.SERVER_OUTGOING_WRITER_CREATED, params);
if (newWriter != null) {
writer = (Writer) newWriter;
}
}
if (theResource == null) {
// No response is being returned
} else if (encodingDomainResourceAsText && theResource instanceof IResource) {

View File

@ -23,10 +23,12 @@ package ca.uhn.fhir.rest.server.interceptor;
public class InterceptorOrders {
public static final int SERVE_MEDIA_RESOURCE_RAW_INTERCEPTOR = 1000;
public static final int RESPONSE_HIGHLIGHTER_INTERCEPTOR = 10000;
public static final int RESPONSE_SIZE_CAPTURING_INTERCEPTOR_COMPLETED = -1;
/** Non instantiable */
/**
* Non instantiable
*/
private InterceptorOrders() {
// nothing
}

View File

@ -0,0 +1,123 @@
package ca.uhn.fhir.rest.server.interceptor;
import ca.uhn.fhir.interceptor.api.Hook;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import org.apache.commons.lang3.Validate;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.io.Writer;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* This interceptor captures and makes
* available the number of characters written (pre-compression if Gzip compression is being used) to the HTTP response
* stream for FHIR responses.
* <p>
* Response details are made available in the request {@link RequestDetails#getUserData() RequestDetails UserData map}
* with {@link #RESPONSE_RESULT_KEY} as the key.
* </p>
*
* @since 5.0.0
*/
public class ResponseSizeCapturingInterceptor {
/**
* If the response was a character stream, a character count will be placed in the
* {@link RequestDetails#getUserData() RequestDetails UserData map} with this key, containing
* an {@link Result} value.
* <p>
* The value will be placed at the start of the {@link Pointcut#SERVER_PROCESSING_COMPLETED} pointcut, so it will not
* be available before that time.
* </p>
*/
public static final String RESPONSE_RESULT_KEY = ResponseSizeCapturingInterceptor.class.getName() + "_RESPONSE_RESULT_KEY";
private static final String COUNTING_WRITER_KEY = ResponseSizeCapturingInterceptor.class.getName() + "_COUNTING_WRITER_KEY";
private List<Consumer<Result>> myConsumers = new ArrayList<>();
@Hook(Pointcut.SERVER_OUTGOING_WRITER_CREATED)
public Writer capture(RequestDetails theRequestDetails, Writer theWriter) {
CountingWriter retVal = new CountingWriter(theWriter);
theRequestDetails.getUserData().put(COUNTING_WRITER_KEY, retVal);
return retVal;
}
@Hook(value = Pointcut.SERVER_PROCESSING_COMPLETED, order = InterceptorOrders.RESPONSE_SIZE_CAPTURING_INTERCEPTOR_COMPLETED)
public void completed(RequestDetails theRequestDetails) {
CountingWriter countingWriter = (CountingWriter) theRequestDetails.getUserData().get(COUNTING_WRITER_KEY);
if (countingWriter != null) {
int charCount = countingWriter.getCount();
Result result = new Result(charCount);
notifyConsumers(result);
theRequestDetails.getUserData().put(RESPONSE_RESULT_KEY, result);
}
}
/**
* Registers a new consumer. All consumers will be notified each time a request is complete.
*
* @param theConsumer The consumer
*/
public void registerConsumer(@Nonnull Consumer<Result> theConsumer) {
Validate.notNull(theConsumer);
myConsumers.add(theConsumer);
}
private void notifyConsumers(Result theResult) {
myConsumers.forEach(t -> t.accept(theResult));
}
/**
* Contains the results of the capture
*/
public static class Result {
private final int myWrittenChars;
public Result(int theWrittenChars) {
myWrittenChars = theWrittenChars;
}
public int getWrittenChars() {
return myWrittenChars;
}
}
private static class CountingWriter extends Writer {
private final Writer myWrap;
private int myCount;
private CountingWriter(Writer theWrap) {
myWrap = theWrap;
}
@Override
public void write(char[] cbuf, int off, int len) throws IOException {
myCount += len;
myWrap.write(cbuf, off, len);
}
@Override
public void flush() throws IOException {
myWrap.flush();
}
@Override
public void close() throws IOException {
myWrap.close();
}
public int getCount() {
return myCount;
}
}
}

View File

@ -0,0 +1,70 @@
package ca.uhn.fhir.rest.server.interceptor;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.test.utilities.server.HashMapResourceProviderRule;
import ca.uhn.fhir.test.utilities.server.RestfulServerRule;
import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Patient;
import org.junit.After;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@RunWith(MockitoJUnitRunner.class)
public class ResponseSizeCapturingInterceptorTest {
private static FhirContext ourCtx = FhirContext.forR4();
@ClassRule
public static RestfulServerRule ourServerRule = new RestfulServerRule(ourCtx);
private ResponseSizeCapturingInterceptor myInterceptor;
@Rule
public HashMapResourceProviderRule<Patient> myPatientProviderRule = new HashMapResourceProviderRule<>(ourServerRule, Patient.class);
@Mock
private Consumer<ResponseSizeCapturingInterceptor.Result> myConsumer;
@Captor
private ArgumentCaptor<ResponseSizeCapturingInterceptor.Result> myResultCaptor;
@Before
public void before() {
myInterceptor = new ResponseSizeCapturingInterceptor();
ourServerRule.getRestfulServer().registerInterceptor(myInterceptor);
}
@After
public void after() {
ourServerRule.getRestfulServer().unregisterInterceptor(myInterceptor);
}
@Test
public void testReadResource() {
Patient resource = new Patient();
resource.setActive(true);
IIdType id = ourServerRule.getFhirClient().create().resource(resource).execute().getId().toUnqualifiedVersionless();
myInterceptor.registerConsumer(myConsumer);
resource = ourServerRule.getFhirClient().read().resource(Patient.class).withId(id).execute();
assertEquals(true, resource.getActive());
verify(myConsumer, times(1)).accept(myResultCaptor.capture());
assertEquals(100, myResultCaptor.getValue().getWrittenChars());
}
}