Skip to content

Commit 78ff9dc

Browse files
Merge pull request zalando#166 from zalando/feature/curl
Added curl request formatter
2 parents 4097d3f + 4dfa309 commit 78ff9dc

File tree

5 files changed

+237
-1
lines changed

5 files changed

+237
-1
lines changed

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,26 @@ a JSON response body will **not** be escaped and represented as a string:
247247
}
248248
```
249249

250+
##### cURL
251+
252+
*cURL* is an alternative formatting style, provided by the `CurlHttpLogFormatter` which will render requests as
253+
executable [`cURL`](https://curl.haxx.se/) commands. Unlike JSON, it is primarily designed for humans.
254+
255+
256+
###### Request
257+
258+
```bash
259+
curl -v -X GET 'http://localhost/test' -H 'Accept: application/json'
260+
```
261+
262+
###### Response
263+
264+
See [HTTP](#http) or provide own fallback for responses:
265+
266+
```java
267+
new CurlHttpLogFormatter(new JsonHttpLogFormatter());
268+
```
269+
250270
#### Writing
251271

252272
Writing defines where formatted requests and responses are written to. Logbook comes with three implementations:
@@ -385,7 +405,7 @@ The following tables show the available configuration:
385405
|--------------------------------|------------------------------------------------------------------|-------------------------------|
386406
| `logbook.exclude` | Exclude certain URLs | `[]` |
387407
| `logbook.filter.enabled` | Enable the [`LogbookFilter(s)`](#servlet) | `true` |
388-
| `logbook.format.style` | [Formatting style](#formatting) (`http` or `json`) | `json` |
408+
| `logbook.format.style` | [Formatting style](#formatting) (`http`, `json` or `curl`) | `json` |
389409
| `logbook.obfuscate.headers` | List of header names that need obfuscation | `[Authorization]` |
390410
| `logbook.obfuscate.parameters` | List of parameter names that need obfuscation | `[access_token]` |
391411
| `logbook.write.category` | Changes the category of the [`DefaultHttpLogWriter`](#logger) | `org.zalando.logbook.Logbook` |
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package org.zalando.logbook;
2+
3+
import java.io.IOException;
4+
import java.util.ArrayList;
5+
import java.util.List;
6+
7+
import static java.util.stream.Collectors.joining;
8+
9+
/**
10+
* Formats requests as cURL commands.
11+
*/
12+
public final class CurlHttpLogFormatter implements HttpLogFormatter {
13+
14+
private final HttpLogFormatter fallback;
15+
16+
public CurlHttpLogFormatter() {
17+
this(new DefaultHttpLogFormatter());
18+
}
19+
20+
public CurlHttpLogFormatter(final HttpLogFormatter fallback) {
21+
this.fallback = fallback;
22+
}
23+
24+
@Override
25+
public String format(final Precorrelation<HttpRequest> precorrelation) throws IOException {
26+
final HttpRequest request = precorrelation.getRequest();
27+
final List<String> command = new ArrayList<>();
28+
29+
command.add("curl");
30+
command.add("-v"); // TODO optional?
31+
32+
command.add("-X");
33+
command.add(request.getMethod());
34+
35+
command.add(quote(request.getRequestUri()));
36+
37+
request.getHeaders().forEach((header, values) -> {
38+
values.forEach(value -> {
39+
command.add("-H");
40+
command.add(quote(header + ": " + value));
41+
});
42+
});
43+
44+
final String body = request.getBodyAsString();
45+
46+
if (!body.isEmpty()) {
47+
command.add("--data-binary");
48+
command.add(quote(body));
49+
}
50+
51+
return command.stream().collect(joining(" "));
52+
}
53+
54+
private static String quote(final String s) {
55+
return "'" + escape(s) + "'";
56+
}
57+
58+
private static String escape(final String s) {
59+
return s.replace("'", "\\'");
60+
}
61+
62+
@Override
63+
public String format(final Correlation<HttpRequest, HttpResponse> correlation) throws IOException {
64+
return fallback.format(correlation);
65+
}
66+
67+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package org.zalando.logbook;
2+
3+
import org.junit.Test;
4+
import org.zalando.logbook.DefaultLogbook.SimpleCorrelation;
5+
import org.zalando.logbook.DefaultLogbook.SimplePrecorrelation;
6+
7+
import java.io.IOException;
8+
9+
import static org.hamcrest.Matchers.is;
10+
import static org.junit.Assert.assertThat;
11+
import static org.mockito.Mockito.mock;
12+
import static org.mockito.Mockito.verify;
13+
14+
public final class CurlHttpLogFormatterTest {
15+
16+
@Test
17+
public void shouldLogRequest() throws IOException {
18+
final String correlationId = "c9408eaa-677d-11e5-9457-10ddb1ee7671";
19+
final HttpRequest request = MockHttpRequest.create()
20+
.withProtocolVersion("HTTP/1.0")
21+
.withOrigin(Origin.REMOTE)
22+
.withPath("/test")
23+
.withQuery("limit=1")
24+
.withHeaders(MockHeaders.of(
25+
"Accept", "application/json",
26+
"Content-Type", "text/plain"))
27+
.withBodyAsString("Hello, world!");
28+
29+
final HttpLogFormatter unit = new CurlHttpLogFormatter();
30+
final String curl = unit.format(new SimplePrecorrelation<>(correlationId, request));
31+
32+
assertThat(curl, is("curl -v -X GET 'http://localhost/test?limit=1' -H 'Accept: application/json' -H 'Content-Type: text/plain' --data-binary 'Hello, world!'"));
33+
}
34+
35+
@Test
36+
public void shouldLogRequestWithoutBody() throws IOException {
37+
final String correlationId = "0eae9f6c-6824-11e5-8b0a-10ddb1ee7671";
38+
final HttpRequest request = MockHttpRequest.create()
39+
.withPath("/test")
40+
.withHeaders(MockHeaders.of("Accept", "application/json"));
41+
42+
final HttpLogFormatter unit = new CurlHttpLogFormatter();
43+
final String curl = unit.format(new SimplePrecorrelation<>(correlationId, request));
44+
45+
assertThat(curl, is("curl -v -X GET 'http://localhost/test' -H 'Accept: application/json'"));
46+
}
47+
48+
@Test
49+
public void shouldEscape() throws IOException {
50+
final String correlationId = "c9408eaa-677d-11e5-9457-10ddb1ee7671";
51+
final HttpRequest request = MockHttpRequest.create()
52+
.withProtocolVersion("HTTP/1.0")
53+
.withOrigin(Origin.REMOTE)
54+
.withPath("/test")
55+
.withQuery("char='")
56+
.withHeaders(MockHeaders.of(
57+
"Foo'Bar", "Baz"
58+
))
59+
.withBodyAsString("{\"message\":\"Hello, 'world'!\"}");
60+
61+
final HttpLogFormatter unit = new CurlHttpLogFormatter();
62+
final String curl = unit.format(new SimplePrecorrelation<>(correlationId, request));
63+
64+
assertThat(curl, is("curl -v -X GET 'http://localhost/test?char=\\'' -H 'Foo\\'Bar: Baz' --data-binary '{\"message\":\"Hello, \\'world\\'!\"}'"));
65+
}
66+
67+
@Test
68+
public void shouldDelegateLogResponse() throws IOException {
69+
final HttpLogFormatter fallback = mock(HttpLogFormatter.class);
70+
final HttpLogFormatter unit = new CurlHttpLogFormatter(fallback);
71+
72+
final Correlation<HttpRequest, HttpResponse> correlation = new SimpleCorrelation<>(
73+
"3881ae92-6824-11e5-921b-10ddb1ee7671", MockHttpRequest.create(), MockHttpResponse.create());
74+
75+
unit.format(correlation);
76+
77+
verify(fallback).format(correlation);
78+
}
79+
80+
}

logbook-spring-boot-starter/src/main/java/org/zalando/logbook/spring/LogbookAutoConfiguration.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.zalando.logbook.BodyFilters;
2525
import org.zalando.logbook.ChunkingHttpLogWriter;
2626
import org.zalando.logbook.Conditions;
27+
import org.zalando.logbook.CurlHttpLogFormatter;
2728
import org.zalando.logbook.DefaultHttpLogFormatter;
2829
import org.zalando.logbook.DefaultHttpLogWriter;
2930
import org.zalando.logbook.DefaultHttpLogWriter.Level;
@@ -175,6 +176,13 @@ public HttpLogFormatter httpFormatter() {
175176
return new DefaultHttpLogFormatter();
176177
}
177178

179+
@Bean
180+
@ConditionalOnMissingBean(HttpLogFormatter.class)
181+
@ConditionalOnProperty(name = "logbook.format.style", havingValue = "curl")
182+
public HttpLogFormatter curlFormatter() {
183+
return new CurlHttpLogFormatter();
184+
}
185+
178186
@Bean
179187
@ConditionalOnBean(ObjectMapper.class)
180188
@ConditionalOnMissingBean(HttpLogFormatter.class)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package org.zalando.logbook.spring;
2+
3+
import org.hamcrest.Matcher;
4+
import org.junit.Test;
5+
import org.springframework.beans.factory.annotation.Autowired;
6+
import org.springframework.boot.test.context.SpringBootTest;
7+
import org.springframework.context.annotation.Bean;
8+
import org.springframework.context.annotation.Configuration;
9+
import org.zalando.logbook.HttpLogWriter;
10+
import org.zalando.logbook.Logbook;
11+
import org.zalando.logbook.MockRawHttpRequest;
12+
import org.zalando.logbook.Precorrelation;
13+
14+
import java.io.IOException;
15+
import java.util.function.Function;
16+
17+
import static org.hamcrest.Matchers.containsString;
18+
import static org.hamcrest.Matchers.is;
19+
import static org.hobsoft.hamcrest.compose.ComposeMatchers.hasFeature;
20+
import static org.mockito.ArgumentMatchers.any;
21+
import static org.mockito.Mockito.mock;
22+
import static org.mockito.Mockito.verify;
23+
import static org.mockito.Mockito.when;
24+
import static org.mockito.hamcrest.MockitoHamcrest.argThat;
25+
26+
@SpringBootTest(
27+
classes = {Application.class, FormatStyleCurlTest.TestConfiguration.class},
28+
properties = "logbook.format.style = curl")
29+
public final class FormatStyleCurlTest extends AbstractTest {
30+
31+
@Configuration
32+
public static class TestConfiguration {
33+
34+
@Bean
35+
public HttpLogWriter writer() throws IOException {
36+
final HttpLogWriter writer = mock(HttpLogWriter.class);
37+
when(writer.isActive(any())).thenReturn(true);
38+
return writer;
39+
}
40+
41+
}
42+
43+
@Autowired
44+
private Logbook logbook;
45+
46+
@Autowired
47+
private HttpLogWriter writer;
48+
49+
@Test
50+
public void shouldUseHttpFormatter() throws IOException {
51+
logbook.write(MockRawHttpRequest.create());
52+
53+
verify(writer).writeRequest(argThat(isCurlFormatter()));
54+
}
55+
56+
private Matcher<Precorrelation<String>> isCurlFormatter() {
57+
final Function<Precorrelation<String>, String> getRequest = Precorrelation::getRequest;
58+
return hasFeature("request", getRequest, is("curl -v -X GET 'http://localhost/'"));
59+
}
60+
61+
}

0 commit comments

Comments
 (0)