diff --git a/bugsnag/build.gradle b/bugsnag/build.gradle index aa2ae9e3..8c69d35b 100644 --- a/bugsnag/build.gradle +++ b/bugsnag/build.gradle @@ -12,6 +12,8 @@ repositories { dependencies { compile "com.fasterxml.jackson.core:jackson-databind:2.13.3" compile "org.slf4j:slf4j-api:1.7.25" + compileOnly "org.jboss.resteasy:resteasy-core:${resteasyVersion}" + compileOnly "org.jboss.resteasy:resteasy-core-spi:${resteasyVersion}" compileOnly "javax.servlet:javax.servlet-api:${servletApiVersion}" compileOnly("ch.qos.logback:logback-classic:${logbackVersion}") { exclude group: "org.slf4j" @@ -24,6 +26,8 @@ dependencies { testCompile("ch.qos.logback:logback-classic:${logbackVersion}") { exclude group: "org.slf4j" } + testCompile "org.jboss.resteasy:resteasy-core:${resteasyVersion}" + testCompile "org.jboss.resteasy:resteasy-core-spi:${resteasyVersion}" } task testJar(type: Jar) { diff --git a/bugsnag/src/main/java/com/bugsnag/Bugsnag.java b/bugsnag/src/main/java/com/bugsnag/Bugsnag.java index 13846b41..ef124913 100644 --- a/bugsnag/src/main/java/com/bugsnag/Bugsnag.java +++ b/bugsnag/src/main/java/com/bugsnag/Bugsnag.java @@ -154,6 +154,15 @@ public Delivery getDelivery() { return config.delivery; } + /** + * Get the request callback. + * + * @return the used request callback. + */ + public String getRequestCallback() { + return config.requestCallback; + } + /** * Get the delivery to use to send sessions. * @@ -197,6 +206,15 @@ public void setDelivery(Delivery delivery) { config.delivery = delivery; } + /** + * Set which request callback to use. + * Accepted values are "servlet" for Java Servlet API and "jaxrs" for Jakarta RESTful Web Services + * + * @param requestCallback either "servlet" or "jaxrs" + */ + public void setRequestCallback(String requestCallback) { + config.requestCallback = requestCallback; + } /** * Set the method of delivery for Bugsnag sessions. By default we'll diff --git a/bugsnag/src/main/java/com/bugsnag/Configuration.java b/bugsnag/src/main/java/com/bugsnag/Configuration.java index b9010e8f..980f3f6f 100644 --- a/bugsnag/src/main/java/com/bugsnag/Configuration.java +++ b/bugsnag/src/main/java/com/bugsnag/Configuration.java @@ -3,6 +3,7 @@ import com.bugsnag.callbacks.AppCallback; import com.bugsnag.callbacks.Callback; import com.bugsnag.callbacks.DeviceCallback; +import com.bugsnag.callbacks.JaxrsCallback; import com.bugsnag.callbacks.ServletCallback; import com.bugsnag.delivery.AsyncHttpDelivery; import com.bugsnag.delivery.Delivery; @@ -42,6 +43,7 @@ public class Configuration { public String[] notifyReleaseStages = null; public String[] projectPackages; public String releaseStage; + public String requestCallback = "servlet"; public boolean sendThreads = false; Collection callbacks = new ConcurrentLinkedQueue(); @@ -57,8 +59,10 @@ public class Configuration { addCallback(new DeviceCallback()); DeviceCallback.initializeCache(); - if (ServletCallback.isAvailable()) { + if ("servlet".equals(requestCallback) && ServletCallback.isAvailable()) { addCallback(new ServletCallback()); + } else if ("jaxrs".equals(requestCallback) && JaxrsCallback.isAvailable()) { + addCallback(new JaxrsCallback()); } } diff --git a/bugsnag/src/main/java/com/bugsnag/callbacks/JaxrsCallback.java b/bugsnag/src/main/java/com/bugsnag/callbacks/JaxrsCallback.java new file mode 100644 index 00000000..36852dd3 --- /dev/null +++ b/bugsnag/src/main/java/com/bugsnag/callbacks/JaxrsCallback.java @@ -0,0 +1,92 @@ +package com.bugsnag.callbacks; + +import com.bugsnag.Report; +import com.bugsnag.filters.BugsnagContainerRequestFilter; + +import org.jboss.resteasy.spi.HttpRequest; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class JaxrsCallback implements Callback { + private static final String HEADER_X_FORWARDED_FOR = "X-FORWARDED-FOR"; + + /** + * @return true if the servlet request listener is available. + */ + public static boolean isAvailable() { + try { + Class.forName("javax.ws.rs.container.ContainerRequestFilter", false, + JaxrsCallback.class.getClassLoader()); + return true; + } catch (ClassNotFoundException ex) { + return false; + } + } + + @Override + public void beforeNotify(Report report) { + // Check if we have any servlet request data available + HttpRequest request = BugsnagContainerRequestFilter.getRequest(); + + if (request == null) { + return; + } + + // Add request information to metaData + report + .addToTab("request", "url", request.getUri().getRequestUri().toString()) + .addToTab("request", "method", request.getHttpMethod()) + .addToTab("request", "params", request.getFormParameters()) + .addToTab("request", "clientIp", getClientIp(request)) + .addToTab("request", "headers", getHeaderMap(request)); + + // Set default context + if (report.getContext() == null) { + report.setContext(request.getHttpMethod() + " " + request.getUri().getRequestUri()); + } + + // Clear servlet request data + BugsnagContainerRequestFilter.clearRequest(); + } + + private String getClientIp(HttpRequest request) { + String remoteAddr = request.getRemoteAddress(); + String forwardedAddr = request.getHttpHeaders().getHeaderString(HEADER_X_FORWARDED_FOR); + if (forwardedAddr != null) { + remoteAddr = forwardedAddr; + int idx = remoteAddr.indexOf(','); + if (idx > -1) { + remoteAddr = remoteAddr.substring(0, idx); + } + } + return remoteAddr; + } + + private Map getHeaderMap(HttpRequest request) { + Map headers = new HashMap(); + Set>> headerNames = request.getMutableHeaders().entrySet(); + for (Map.Entry> header : headerNames) { + Iterator headerValues = header.getValue().iterator(); + StringBuilder value = new StringBuilder(); + + if (headerValues.hasNext()) { + value.append(headerValues.next()); + + // If there are multiple values for the header, do comma-separated concat + // as per RFC 2616: + // https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + while (headerValues.hasNext()) { + value.append(",").append(headerValues.next()); + } + } + + headers.put(header.getKey(), value.toString()); + } + + return headers; + } +} diff --git a/bugsnag/src/main/java/com/bugsnag/filters/BugsnagContainerRequestFilter.java b/bugsnag/src/main/java/com/bugsnag/filters/BugsnagContainerRequestFilter.java new file mode 100644 index 00000000..732af52b --- /dev/null +++ b/bugsnag/src/main/java/com/bugsnag/filters/BugsnagContainerRequestFilter.java @@ -0,0 +1,48 @@ +package com.bugsnag.filters; + +import static javax.ws.rs.Priorities.AUTHENTICATION; + +import com.bugsnag.Bugsnag; + +import org.jboss.resteasy.core.interception.jaxrs.PreMatchContainerRequestContext; +import org.jboss.resteasy.spi.HttpRequest; + +import javax.annotation.Priority; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.container.PreMatching; +import javax.ws.rs.ext.Provider; + +@Provider +@PreMatching +@Priority(AUTHENTICATION) +public class BugsnagContainerRequestFilter implements ContainerRequestFilter { + + private static final ThreadLocal HTTP_REQUEST = new ThreadLocal(); + + public static HttpRequest getRequest() { + return HTTP_REQUEST.get(); + } + + public static void clearRequest() { + HTTP_REQUEST.remove(); + Bugsnag.clearThreadMetaData(); + } + + private void trackServletSession() { + for (Bugsnag bugsnag : Bugsnag.uncaughtExceptionClients()) { + if (bugsnag.shouldAutoCaptureSessions()) { + bugsnag.startSession(); + } + } + } + + @Override + public void filter(ContainerRequestContext containerRequestContext) { + trackServletSession(); + if (containerRequestContext instanceof PreMatchContainerRequestContext) { + HTTP_REQUEST.set(((PreMatchContainerRequestContext) containerRequestContext).getHttpRequest()); + } + } +} diff --git a/bugsnag/src/test/java/com/bugsnag/ConfigurationTest.java b/bugsnag/src/test/java/com/bugsnag/ConfigurationTest.java index 4e81727a..28842e8b 100644 --- a/bugsnag/src/test/java/com/bugsnag/ConfigurationTest.java +++ b/bugsnag/src/test/java/com/bugsnag/ConfigurationTest.java @@ -39,6 +39,7 @@ public void setUp() { @Test public void testDefaults() { assertTrue(config.shouldAutoCaptureSessions()); + assertEquals("servlet", config.requestCallback); } @Test diff --git a/bugsnag/src/test/java/com/bugsnag/JaxrsCallbackTest.java b/bugsnag/src/test/java/com/bugsnag/JaxrsCallbackTest.java new file mode 100644 index 00000000..f6679ca5 --- /dev/null +++ b/bugsnag/src/test/java/com/bugsnag/JaxrsCallbackTest.java @@ -0,0 +1,153 @@ +package com.bugsnag; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bugsnag.callbacks.JaxrsCallback; +import com.bugsnag.callbacks.ServletCallback; +import com.bugsnag.filters.BugsnagContainerRequestFilter; + +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.core.util.VersionUtil; + +import org.jboss.resteasy.core.Headers; +import org.jboss.resteasy.core.interception.jaxrs.PreMatchContainerRequestContext; +import org.jboss.resteasy.specimpl.ResteasyHttpHeaders; +import org.jboss.resteasy.specimpl.ResteasyUriInfo; +import org.jboss.resteasy.spi.HttpRequest; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import javax.ws.rs.core.MultivaluedMap; + +public class JaxrsCallbackTest { + + private Bugsnag bugsnag; + + /** + * Only run class if java runtime version is 1.8 or later + */ + @BeforeClass + public static void checkJavaRuntimeVersion() { + Version runtimeVersion = VersionUtil.parseVersion(System.getProperty("java.runtime.version"), null, null); + Version minimalVersion = new Version(1, 8, 0, null); + assumeTrue(runtimeVersion.compareTo(minimalVersion) >= 0); + } + + /** + * Generate a new request instance which will be read by the servlet + * context and callback + */ + @Before + public void setUp() throws URISyntaxException { + bugsnag = new Bugsnag("apikey", false); + bugsnag.setDelivery(null); + bugsnag.setRequestCallback("jaxrs"); + + HttpRequest request = mock(HttpRequest.class); + + MultivaluedMap params = new Headers(); + params.put("account", Collections.singletonList("Acme Co")); + params.put("name", Collections.singletonList("Bill")); + when(request.getFormParameters()).thenReturn(params); + + when(request.getHttpMethod()).thenReturn("PATCH"); + when(request.getUri()).thenReturn(new ResteasyUriInfo(new URI("/foo/bar"))); + when(request.getRemoteAddress()).thenReturn("12.0.4.57"); + + MultivaluedMap headers = new Headers(); + headers.put("Content-Type", Collections.singletonList("application/json")); + headers.put("Content-Length", Collections.singletonList("54")); + headers.put("X-Custom-Header", Arrays.asList("some-data-1", "some-data-2")); + headers.put("Authorization", Collections.singletonList("Basic ABC123")); + headers.put("Cookie", Collections.singletonList("name1=val1; name2=val2")); + ResteasyHttpHeaders httpHeaders = new ResteasyHttpHeaders(headers); + when(request.getHttpHeaders()).thenReturn(httpHeaders); + when(request.getMutableHeaders()).thenReturn(headers); + + PreMatchContainerRequestContext context = mock(PreMatchContainerRequestContext.class); + when(context.getHttpRequest()).thenReturn(request); + BugsnagContainerRequestFilter filter = new BugsnagContainerRequestFilter(); + filter.filter(context); + } + + /** + * Close test Bugsnag + */ + @After + public void closeBugsnag() { + bugsnag.close(); + } + + @SuppressWarnings("unchecked") + @Test + public void testRequestMetadataAdded() { + Report report = generateReport(new java.lang.Exception("Spline reticulation failed")); + JaxrsCallback callback = new JaxrsCallback(); + callback.beforeNotify(report); + + Map metadata = report.getMetaData(); + assertTrue(metadata.containsKey("request")); + + Map request = (Map) metadata.get("request"); + assertEquals("/foo/bar", request.get("url")); + assertEquals("PATCH", request.get("method")); + assertEquals("12.0.4.57", request.get("clientIp")); + + assertTrue(request.containsKey("headers")); + Map headers = (Map) request.get("headers"); + assertEquals("application/json", headers.get("Content-Type")); + assertEquals("54", headers.get("Content-Length")); + assertEquals("some-data-1,some-data-2", headers.get("X-Custom-Header")); + + // Make sure that actual Authorization header value is not in the report + assertEquals("[FILTERED]", headers.get("Authorization")); + + // Make sure that actual cookies are not in the report + assertEquals("[FILTERED]", headers.get("Cookie")); + + assertTrue(request.containsKey("params")); + Map> params = (Map>) request.get("params"); + assertTrue(params.containsKey("account")); + List account = params.get("account"); + assertEquals("Acme Co", account.get(0)); + + assertTrue(params.containsKey("name")); + List name = params.get("name"); + assertEquals("Bill", name.get(0)); + } + + @Test + public void testRequestContextSet() { + Report report = generateReport(new java.lang.Exception("Spline reticulation failed")); + JaxrsCallback callback = new JaxrsCallback(); + callback.beforeNotify(report); + + assertEquals("PATCH /foo/bar", report.getContext()); + } + + @Test + public void testExistingContextNotOverridden() { + Report report = generateReport(new java.lang.Exception("Spline reticulation failed")); + report.setContext("Honey nut corn flakes"); + ServletCallback callback = new ServletCallback(); + callback.beforeNotify(report); + + assertEquals("Honey nut corn flakes", report.getContext()); + } + + private Report generateReport(java.lang.Exception exception) { + return bugsnag.buildReport(exception); + } +} diff --git a/common.gradle b/common.gradle index fb779f1b..177413ee 100644 --- a/common.gradle +++ b/common.gradle @@ -1,6 +1,7 @@ ext { servletApiVersion = "3.1.0" logbackVersion = "1.2.3" + resteasyVersion = "4.5.12.Final" } if (JavaVersion.current().isJava8Compatible()) {