Skip to content

Commit a5556bf

Browse files
Made JsonPathBodyFilter mergeable (zalando#1030)
1 parent 35f138b commit a5556bf

File tree

6 files changed

+342
-122
lines changed

6 files changed

+342
-122
lines changed

README.md

+78-10
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,14 @@ respectively (in conjunction with `ForwardingHttpRequest`/`ForwardingHttpRespons
223223
You can configure filters like this:
224224

225225
```java
226+
import static org.zalando.logbook.HeaderFilters.authorization;
227+
import static org.zalando.logbook.HeaderFilters.eachHeader;
228+
import static org.zalando.logbook.QueryFilters.accessToken;
229+
import static org.zalando.logbook.QueryFilters.replaceQuery;
230+
226231
Logbook logbook = Logbook.builder()
227-
.requestFilter(replaceBody(contentType("audio/*"), "mmh mmh mmh mmh"))
228-
.responseFilter(replaceBody(contentType("*/*-stream"), "It just keeps going and going..."))
232+
.requestFilter(RequestFilters.replaceBody(contentType("audio/*"), "mmh mmh mmh mmh"))
233+
.responseFilter(ResponseFilters.replaceBody(contentType("*/*-stream"), "It just keeps going and going..."))
229234
.queryFilter(accessToken())
230235
.queryFilter(replaceQuery("password", "<secret>"))
231236
.headerFilter(authorization())
@@ -237,20 +242,83 @@ You can configure as many filters as you want - they will run consecutively.
237242

238243
##### JsonPath body filtering (experimental)
239244

240-
You can now use [json path](https://github.com/json-path/JsonPath) filtering in HTTP logging. Here are some examples:
245+
You can apply [JSON Path](https://github.com/json-path/JsonPath) filtering to JSON bodies.
246+
Here are some examples:
241247

242248
```java
249+
import static org.zalando.logbook.json.JsonPathBodyFilters.jsonPath;
250+
import static java.util.regex.Pattern.compile;
251+
243252
Logbook logbook = Logbook.builder()
244-
.bodyFilter(JsonPathBodyFilters.jsonPath("$.password").delete()) // 1
245-
.bodyFilter(JsonPathBodyFilters.jsonPath("$.password").replace("XXX")) // 2
246-
.bodyFilter(JsonPathBodyFilters.jsonPath("$.cardNumber").replace("(\\d{6})\\d+(\\d{4})"), "$1********$2") // 3
253+
.bodyFilter(jsonPath("$.password").delete())
254+
.bodyFilter(jsonPath("$.active").replace("unknown"))
255+
.bodyFilter(jsonPath("$.address").replace("X"))
256+
.bodyFilter(jsonPath("$.name").replace(compile("^(\\w).+"), "$1."))
257+
.bodyFilter(jsonPath("$.friends.*.name").replace(compile("^(\\w).+"), "$1."))
258+
.bodyFilter(jsonPath("$.grades.*").replace(1.0))
247259
.build();
248260
```
249261

250-
Explanation:
251-
1. Just deletes all JsonPath matches.
252-
2. Replaces all JsonPath matches with given value. Strings, numbers and booleans are supported.
253-
3. Replaces all JsonPath matches using `java.util.regex.Matcher#replaceAll`. This can be usually used when you need some sort of masking. In this example Logbook will replace string `"5131456812321545"` with `"513145******1545"`.
262+
Take a look at the following example, before and after filtering was applied:
263+
264+
<details>
265+
<summary>Before</summary>
266+
267+
```json
268+
{
269+
"id": 1,
270+
"name": "Alice",
271+
"password": "s3cr3t",
272+
"active": true,
273+
"address": "Anhalter Straße 17 13, 67278 Bockenheim an der Weinstraße",
274+
"friends": [
275+
{
276+
"id": 2,
277+
"name": "Bob"
278+
},
279+
{
280+
"id": 3,
281+
"name": "Charlie"
282+
}
283+
],
284+
"grades": {
285+
"Math": 1.0,
286+
"English": 2.2,
287+
"Science": 1.9,
288+
"PE": 4.0
289+
}
290+
}
291+
```
292+
</details>
293+
294+
<details>
295+
<summary>After</summary>
296+
297+
```json
298+
{
299+
"id": 1,
300+
"name": "Alice",
301+
"active": "unknown",
302+
"address": "XXX",
303+
"friends": [
304+
{
305+
"id": 2,
306+
"name": "B."
307+
},
308+
{
309+
"id": 3,
310+
"name": "C."
311+
}
312+
],
313+
"grades": {
314+
"Math": 1.0,
315+
"English": 1.0,
316+
"Science": 1.0,
317+
"PE": 1.0
318+
}
319+
}
320+
```
321+
</details>
254322

255323
#### Correlation
256324

logbook-jmh/src/main/java/org/zalando/logbook/json/JsonPathBodyFilterBenchmark.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.openjdk.jmh.runner.options.OptionsBuilder;
88

99
import java.util.concurrent.TimeUnit;
10+
import java.util.regex.Pattern;
1011

1112
import static org.zalando.logbook.json.JsonPathBodyFilters.jsonPath;
1213

@@ -21,7 +22,7 @@ public class JsonPathBodyFilterBenchmark {
2122

2223
@Benchmark
2324
public void replaceStringDynamicallyBenchmark() {
24-
jsonPath("$.test").replace("(\\d{6})\\d+(\\d{4})", "$1********$2")
25+
jsonPath("$.test").replace(Pattern.compile("(\\d{6})\\d+(\\d{4})"), "$1********$2")
2526
.filter(CONTENT_TYPE, BODY);
2627
}
2728

Original file line numberDiff line numberDiff line change
@@ -1,88 +1,147 @@
11
package org.zalando.logbook.json;
22

3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.node.BooleanNode;
5+
import com.fasterxml.jackson.databind.node.DoubleNode;
6+
import com.fasterxml.jackson.databind.node.TextNode;
37
import com.jayway.jsonpath.Configuration;
48
import com.jayway.jsonpath.DocumentContext;
59
import com.jayway.jsonpath.JsonPath;
6-
import com.jayway.jsonpath.Option;
7-
import lombok.AccessLevel;
10+
import com.jayway.jsonpath.ParseContext;
11+
import com.jayway.jsonpath.spi.json.JacksonJsonNodeJsonProvider;
12+
import lombok.AllArgsConstructor;
813
import lombok.NoArgsConstructor;
914
import lombok.RequiredArgsConstructor;
1015
import org.apiguardian.api.API;
1116
import org.zalando.logbook.BodyFilter;
1217

1318
import javax.annotation.Nullable;
14-
import java.util.List;
15-
import java.util.Optional;
16-
import java.util.function.Function;
19+
import java.util.Arrays;
20+
import java.util.Collection;
1721
import java.util.regex.Matcher;
1822
import java.util.regex.Pattern;
1923

20-
@API(status = API.Status.EXPERIMENTAL)
21-
@NoArgsConstructor(access = AccessLevel.PRIVATE)
22-
public final class JsonPathBodyFilters {
24+
import static com.jayway.jsonpath.JsonPath.compile;
25+
import static lombok.AccessLevel.PRIVATE;
26+
import static org.apiguardian.api.API.Status.EXPERIMENTAL;
27+
import static org.zalando.logbook.json.JsonMediaType.JSON;
2328

24-
private static final Configuration conf = Configuration.builder().options(Option.AS_PATH_LIST).build();
29+
@API(status = EXPERIMENTAL)
30+
@NoArgsConstructor(access = PRIVATE)
31+
public final class JsonPathBodyFilters {
2532

26-
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
33+
@RequiredArgsConstructor(access = PRIVATE)
2734
public static final class JsonPathBodyFilterBuilder {
28-
private final JsonPath jsonPath;
35+
36+
private final JsonPath path;
2937

3038
public BodyFilter delete() {
31-
return (JsonBodyFilter) documentContext -> documentContext.delete(jsonPath).jsonString();
39+
return filter(context -> context.delete(path));
40+
}
41+
42+
public BodyFilter replace(final String replacement) {
43+
return replace(new TextNode(replacement));
3244
}
3345

34-
public BodyFilter replace(String replacement) {
35-
return replaceInternal(replacement);
46+
public BodyFilter replace(final Boolean replacement) {
47+
return replace(BooleanNode.valueOf(replacement));
3648
}
3749

38-
public BodyFilter replace(Number replacement) {
39-
return replaceInternal(replacement);
50+
public BodyFilter replace(final Double replacement) {
51+
return replace(new DoubleNode(replacement));
4052
}
4153

42-
public BodyFilter replace(Boolean replacement) {
43-
return replaceInternal(replacement);
54+
public BodyFilter replace(final JsonNode replacement) {
55+
return filter(context -> context.set(path, replacement));
4456
}
4557

46-
public BodyFilter replace(String matchRegex, String replacementRegex) {
47-
Pattern pattern = Pattern.compile(matchRegex);
48-
return (JsonBodyFilter) documentContext -> {
49-
final List<String> paths = documentContext.read(jsonPath);
50-
final Object document = documentContext.json();
51-
paths.forEach(path -> {
52-
final Object node = JsonPath.compile(path).read(document);
53-
replaceWithRegex(node, pattern, replacementRegex)
54-
.ifPresent(s -> documentContext.set(path, s));
55-
});
56-
return documentContext.jsonString();
57-
};
58+
public BodyFilter replace(
59+
final Pattern pattern, final String replacement) {
60+
61+
return filter(context -> context.map(path, (node, config) -> {
62+
final Matcher matcher = pattern.matcher(node.toString());
63+
64+
if (matcher.find()) {
65+
return new TextNode(matcher.replaceAll(replacement));
66+
} else {
67+
return node;
68+
}
69+
}));
5870
}
5971

60-
private BodyFilter replaceInternal(Object replacement) {
61-
return (JsonBodyFilter) documentContext -> documentContext.set(jsonPath, replacement).jsonString();
72+
}
73+
74+
private static JsonPathBodyFilter filter(final Operation operation) {
75+
return new JsonPathBodyFilter(operation);
76+
}
77+
78+
@AllArgsConstructor
79+
private static class JsonPathBodyFilter implements BodyFilter {
80+
81+
private static final ParseContext CONTEXT = JsonPath.using(
82+
Configuration.builder()
83+
.jsonProvider(new JacksonJsonNodeJsonProvider())
84+
.build());
85+
86+
private final Operation operation;
87+
88+
@Override
89+
public String filter(
90+
@Nullable final String contentType, final String body) {
91+
92+
if (JSON.test(contentType)) {
93+
final DocumentContext original = CONTEXT.parse(body);
94+
return operation.filter(original).jsonString();
95+
}
96+
97+
return body;
6298
}
6399

64-
private Optional<String> replaceWithRegex(Object node, Pattern pattern, String replacementRegex) {
65-
String stringRepresentation = node.toString();
66-
Matcher matcher = pattern.matcher(stringRepresentation);
67-
if (matcher.find()) {
68-
return Optional.of(matcher.replaceAll(replacementRegex));
100+
@Nullable
101+
@Override
102+
public BodyFilter tryMerge(final BodyFilter next) {
103+
if (next instanceof JsonPathBodyFilter) {
104+
final JsonPathBodyFilter filter = (JsonPathBodyFilter) next;
105+
return new JsonPathBodyFilter(
106+
Operation.composite(operation, filter.operation));
69107
}
70-
return Optional.empty();
108+
return BodyFilter.super.tryMerge(next);
109+
}
110+
111+
}
112+
113+
@FunctionalInterface
114+
private interface Operation {
115+
DocumentContext filter(DocumentContext context);
116+
117+
static Operation composite(final Operation... operations) {
118+
return composite(Arrays.asList(operations));
71119
}
72120

121+
static Operation composite(final Collection<Operation> operations) {
122+
return new CompositeOperation(operations);
123+
}
73124
}
74125

75-
private interface JsonBodyFilter extends BodyFilter, Function<DocumentContext, String> {
126+
@AllArgsConstructor
127+
private static final class CompositeOperation implements Operation {
128+
129+
private final Collection<Operation> operations;
130+
76131
@Override
77-
default String filter(@Nullable String contentType, String body) {
78-
if (JsonMediaType.JSON.test(contentType)) {
79-
return apply(JsonPath.using(conf).parse(body));
132+
public DocumentContext filter(final DocumentContext context) {
133+
DocumentContext result = context;
134+
135+
for (final Operation operation : operations) {
136+
result = operation.filter(result);
80137
}
81-
return body;
138+
139+
return result;
82140
}
83141
}
84142

85-
public static JsonPathBodyFilterBuilder jsonPath(String jsonPath) {
86-
return new JsonPathBodyFilterBuilder(JsonPath.compile(jsonPath));
143+
public static JsonPathBodyFilterBuilder jsonPath(final String jsonPath) {
144+
return new JsonPathBodyFilterBuilder(compile(jsonPath));
87145
}
146+
88147
}

0 commit comments

Comments
 (0)