diff --git a/logbook-core/src/main/java/org/zalando/logbook/JsonHttpLogFormatter.java b/logbook-core/src/main/java/org/zalando/logbook/JsonHttpLogFormatter.java index 7b0b2af23..002b76bab 100644 --- a/logbook-core/src/main/java/org/zalando/logbook/JsonHttpLogFormatter.java +++ b/logbook-core/src/main/java/org/zalando/logbook/JsonHttpLogFormatter.java @@ -20,17 +20,27 @@ * #L% */ +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.CharMatcher; import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Multimap; +import com.google.common.net.MediaType; import java.io.IOException; -import java.util.Collection; +import java.io.StringWriter; import java.util.Map; import java.util.function.Predicate; public final class JsonHttpLogFormatter implements HttpLogFormatter { + private static final MediaType APPLICATION_JSON = MediaType.create("application", "json"); + private static final CharMatcher PRETTY_PRINT = CharMatcher.anyOf("\n\t"); + private final ObjectMapper mapper; public JsonHttpLogFormatter() { @@ -54,7 +64,7 @@ public String format(final Precorrelation precorrelation) throws IO builder.put("uri", request.getRequestUri()); addUnless(builder, "headers", request.getHeaders().asMap(), Map::isEmpty); - addUnless(builder, "body", request.getBodyAsString(), String::isEmpty); + addBody(request, builder); final ImmutableMap content = builder.build(); @@ -71,7 +81,7 @@ public String format(final Correlation correlation) t builder.put("correlation", correlationId); builder.put("status", response.getStatus()); addUnless(builder, "headers", response.getHeaders().asMap(), Map::isEmpty); - addUnless(builder, "body", response.getBodyAsString(), String::isEmpty); + addBody(response, builder); final ImmutableMap content = builder.build(); @@ -86,4 +96,74 @@ private static void addUnless(final ImmutableMap.Builder tar } } + private void addBody(final HttpMessage request, final ImmutableMap.Builder builder) throws IOException { + final String body = request.getBodyAsString(); + + if (isJson(request.getContentType())) { + builder.put("body", new JsonBody(compactJson(body))); + } else { + addUnless(builder, "body", request.getBodyAsString(), String::isEmpty); + } + } + + private boolean isJson(final String contentType) { + if (contentType.isEmpty()) { + return false; + } + + final MediaType mediaType = MediaType.parse(contentType); + + final boolean isJson = mediaType.is(APPLICATION_JSON); + + final boolean isApplication = mediaType.is(MediaType.ANY_APPLICATION_TYPE); + final boolean isCustomJson = mediaType.subtype().endsWith("+json"); + + return isJson || isApplication && isCustomJson; + } + + private String compactJson(final String json) throws IOException { + if (isAlreadyCompacted(json)) { + return json; + } + + final StringWriter output = new StringWriter(); + final JsonFactory factory = mapper.getFactory(); + final JsonParser parser = factory.createParser(json); + + final JsonGenerator generator = factory.createGenerator(output); + + // see http://stackoverflow.com/questions/17354150/8-branches-for-try-with-resources-jacoco-coverage-possible + //noinspection TryFinallyCanBeTryWithResources - jacoco can't handle try-with correctly + try { + while (parser.nextToken() != null) { + generator.copyCurrentEvent(parser); + } + } finally { + generator.close(); + } + + return output.toString(); + } + + // this wouldn't catch spaces in json, but that's ok for our use case here + private boolean isAlreadyCompacted(final String json) { + return PRETTY_PRINT.matchesNoneOf(json); + } + + private static final class JsonBody { + + private final String json; + + private JsonBody(final String json) { + this.json = json; + } + + @JsonRawValue + @JsonValue + public String getJson() { + return json; + } + + } + } diff --git a/logbook-core/src/test/java/org/zalando/logbook/JsonHttpLogFormatterTest.java b/logbook-core/src/test/java/org/zalando/logbook/JsonHttpLogFormatterTest.java index e79ffef3e..2e2ebdd27 100644 --- a/logbook-core/src/test/java/org/zalando/logbook/JsonHttpLogFormatterTest.java +++ b/logbook-core/src/test/java/org/zalando/logbook/JsonHttpLogFormatterTest.java @@ -28,10 +28,12 @@ import static com.jayway.jsonassert.JsonAssert.with; import static java.util.Collections.singletonList; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertThat; public final class JsonHttpLogFormatterTest { @@ -43,8 +45,9 @@ public void shouldLogRequest() throws IOException { final HttpRequest request = MockHttpRequest.builder() .requestUri("/test") .header("Accept", "application/json") - .header("Content-Type", "text/plain") - .body("Hello, world!") + .header("Date", "Tue, 15 Nov 1994 08:12:31 GMT") + .contentType("application/xml") + .body("test") .build(); final String json = unit.format(new SimplePrecorrelation<>(correlationId, request)); @@ -56,8 +59,8 @@ public void shouldLogRequest() throws IOException { .assertThat("$.uri", is("/test")) .assertThat("$.headers.*", hasSize(2)) .assertThat("$.headers['Accept']", is(singletonList("application/json"))) - .assertThat("$.headers['Content-Type']", is(singletonList("text/plain"))) - .assertThat("$.body", is("Hello, world!")); + .assertThat("$.headers['Date']", is(singletonList("Tue, 15 Nov 1994 08:12:31 GMT"))) + .assertThat("$.body", is("test")); } @Test @@ -87,13 +90,69 @@ public void shouldLogRequestWithoutBody() throws IOException { .assertThat("$", not(hasKey("body"))); } + @Test + public void shouldEmbedJsonRequestBodyAsIs() throws IOException { + final String correlationId = "5478b8da-6d87-11e5-a80f-10ddb1ee7671"; + final HttpRequest request = MockHttpRequest.builder() + .contentType("application/json") + .body("{\"name\":\"Bob\"}") + .build(); + + final String json = unit.format(new SimplePrecorrelation<>(correlationId, request)); + + with(json) + .assertThat("$.body.name", is("Bob")); + } + + @Test + public void shouldCompactEmbeddedJsonRequestBody() throws IOException { + final String correlationId = "5478b8da-6d87-11e5-a80f-10ddb1ee7671"; + final HttpRequest request = MockHttpRequest.builder() + .contentType("application/json") + .body("{\n \"name\": \"Bob\"\n}") + .build(); + + final String json = unit.format(new SimplePrecorrelation<>(correlationId, request)); + + assertThat(json, containsString("{\"name\":\"Bob\"}")); + } + + @Test + public void shouldEmbedCustomJsonRequestBodyAsIs() throws IOException { + final String correlationId = "5478b8da-6d87-11e5-a80f-10ddb1ee7671"; + final HttpRequest request = MockHttpRequest.builder() + .contentType("application/custom+json") + .body("{\"name\":\"Bob\"}") + .build(); + + final String json = unit.format(new SimplePrecorrelation<>(correlationId, request)); + + with(json) + .assertThat("$.body.name", is("Bob")); + } + + @Test + public void shouldNotEmbedCustomTextJsonRequestBodyAsIs() throws IOException { + final String correlationId = "5478b8da-6d87-11e5-a80f-10ddb1ee7671"; + final HttpRequest request = MockHttpRequest.builder() + .contentType("text/custom+json") + .body("{\"name\":\"Bob\"}") + .build(); + + final String json = unit.format(new SimplePrecorrelation<>(correlationId, request)); + + with(json) + .assertThat("$.body", is("{\"name\":\"Bob\"}")); + } + @Test public void shouldLogResponse() throws IOException { final String correlationId = "53de2640-677d-11e5-bc84-10ddb1ee7671"; final HttpRequest request = MockHttpRequest.create(); final HttpResponse response = MockHttpResponse.builder() - .header("Content-Type", "application/json") - .body("{\"success\":true}") + .header("Date", "Tue, 15 Nov 1994 08:12:31 GMT") + .contentType("application/xml") + .body("true") .build(); final String json = unit.format(new SimpleCorrelation<>(correlationId, request, response)); @@ -102,8 +161,8 @@ public void shouldLogResponse() throws IOException { .assertThat("$.correlation", is("53de2640-677d-11e5-bc84-10ddb1ee7671")) .assertThat("$.status", is(200)) .assertThat("$.headers.*", hasSize(1)) - .assertThat("$.headers['Content-Type']", is(singletonList("application/json"))) - .assertThat("$.body", is("{\"success\":true}")); + .assertThat("$.headers['Date']", is(singletonList("Tue, 15 Nov 1994 08:12:31 GMT"))) + .assertThat("$.body", is("true")); } @Test @@ -133,4 +192,63 @@ public void shouldLogResponseWithoutBody() throws IOException { .assertThat("$", not(hasKey("body"))); } + @Test + public void shouldEmbedJsonResponseBodyAsIs() throws IOException { + final String correlationId = "5478b8da-6d87-11e5-a80f-10ddb1ee7671"; + final HttpRequest request = MockHttpRequest.create(); + final HttpResponse response = MockHttpResponse.builder() + .contentType("application/json") + .body("{\"name\":\"Bob\"}") + .build(); + + final String json = unit.format(new SimpleCorrelation<>(correlationId, request, response)); + + with(json) + .assertThat("$.body.name", is("Bob")); + } + + @Test + public void shouldCompactEmbeddedJsonResponseBody() throws IOException { + final String correlationId = "5478b8da-6d87-11e5-a80f-10ddb1ee7671"; + final HttpRequest request = MockHttpRequest.create(); + final HttpResponse response = MockHttpResponse.builder() + .contentType("application/json") + .body("{\n \"name\": \"Bob\"\n}") + .build(); + + final String json = unit.format(new SimpleCorrelation<>(correlationId, request, response)); + + assertThat(json, containsString("{\"name\":\"Bob\"}")); + } + + @Test + public void shouldEmbedCustomJsonResponseBodyAsIs() throws IOException { + final String correlationId = "5478b8da-6d87-11e5-a80f-10ddb1ee7671"; + final HttpRequest request = MockHttpRequest.create(); + final HttpResponse response = MockHttpResponse.builder() + .contentType("application/custom+json") + .body("{\"name\":\"Bob\"}") + .build(); + + final String json = unit.format(new SimpleCorrelation<>(correlationId, request, response)); + + with(json) + .assertThat("$.body.name", is("Bob")); + } + + @Test + public void shouldNotEmbedCustomTextJsonResponseBodyAsIs() throws IOException { + final String correlationId = "5478b8da-6d87-11e5-a80f-10ddb1ee7671"; + final HttpRequest request = MockHttpRequest.create(); + final HttpResponse response = MockHttpResponse.builder() + .contentType("text/custom+json") + .body("{\"name\":\"Bob\"}") + .build(); + + final String json = unit.format(new SimpleCorrelation<>(correlationId, request, response)); + + with(json) + .assertThat("$.body", is("{\"name\":\"Bob\"}")); + } + } \ No newline at end of file