Skip to content

Commit 7e087e5

Browse files
Extract Ratpack json body response schemas
1 parent 135e0f0 commit 7e087e5

File tree

11 files changed

+302
-13
lines changed

11 files changed

+302
-13
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package datadog.trace.instrumentation.ratpack;
2+
3+
import static datadog.trace.api.gateway.Events.EVENTS;
4+
5+
import datadog.appsec.api.blocking.BlockingException;
6+
import datadog.trace.advice.ActiveRequestContext;
7+
import datadog.trace.advice.RequiresRequestContext;
8+
import datadog.trace.api.gateway.BlockResponseFunction;
9+
import datadog.trace.api.gateway.CallbackProvider;
10+
import datadog.trace.api.gateway.Flow;
11+
import datadog.trace.api.gateway.RequestContext;
12+
import datadog.trace.api.gateway.RequestContextSlot;
13+
import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
14+
import java.util.function.BiFunction;
15+
import net.bytebuddy.asm.Advice;
16+
import ratpack.jackson.JsonRender;
17+
18+
@RequiresRequestContext(RequestContextSlot.APPSEC)
19+
public class JsonRendererAdvice {
20+
21+
// for now ignore that the parser can be configured to mix in the query string
22+
@Advice.OnMethodEnter(suppress = Throwable.class)
23+
static void after(
24+
@Advice.Argument(1) final JsonRender render,
25+
@ActiveRequestContext final RequestContext reqCtx) {
26+
Object obj = render == null ? null : render.getObject();
27+
if (obj == null) {
28+
return;
29+
}
30+
31+
CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC);
32+
BiFunction<RequestContext, Object, Flow<Void>> callback =
33+
cbp.getCallback(EVENTS.responseBody());
34+
if (callback == null) {
35+
return;
36+
}
37+
Flow<Void> flow = callback.apply(reqCtx, obj);
38+
Flow.Action action = flow.getAction();
39+
if (action instanceof Flow.Action.RequestBlockingAction) {
40+
BlockResponseFunction brf = reqCtx.getBlockResponseFunction();
41+
if (brf != null) {
42+
Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action;
43+
brf.tryCommitBlockingResponse(
44+
reqCtx.getTraceSegment(),
45+
rba.getStatusCode(),
46+
rba.getBlockingContentType(),
47+
rba.getExtraHeaders());
48+
49+
throw new BlockingException("Blocked request (for JsonRenderer/render)");
50+
}
51+
}
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package datadog.trace.instrumentation.ratpack;
2+
3+
import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named;
4+
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
5+
import static net.bytebuddy.matcher.ElementMatchers.takesArgument;
6+
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
7+
8+
import com.google.auto.service.AutoService;
9+
import datadog.trace.agent.tooling.Instrumenter;
10+
import datadog.trace.agent.tooling.InstrumenterModule;
11+
import datadog.trace.agent.tooling.muzzle.Reference;
12+
13+
@AutoService(InstrumenterModule.class)
14+
public class JsonRendererInstrumentation extends InstrumenterModule.AppSec
15+
implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice {
16+
17+
// so it doesn't apply to ratpack < 1.5
18+
private static final Reference FILE_IO = new Reference.Builder("ratpack.file.FileIo").build();
19+
20+
public JsonRendererInstrumentation() {
21+
super("ratpack");
22+
}
23+
24+
@Override
25+
public String instrumentedType() {
26+
return "ratpack.jackson.internal.JsonRenderer";
27+
}
28+
29+
@Override
30+
public Reference[] additionalMuzzleReferences() {
31+
return new Reference[] {FILE_IO};
32+
}
33+
34+
@Override
35+
public void methodAdvice(MethodTransformer transformer) {
36+
transformer.applyAdvice(
37+
isMethod()
38+
.and(named("render"))
39+
.and(takesArguments(2))
40+
.and(takesArgument(0, named("ratpack.handling.Context")))
41+
.and(takesArgument(1, named("ratpack.jackson.JsonRender"))),
42+
packageName + ".JsonRendererAdvice");
43+
}
44+
}

dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackAsyncHttpServerTest.groovy

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import ratpack.exec.Promise
1010
import ratpack.form.Form
1111
import ratpack.groovy.test.embed.GroovyEmbeddedApp
1212
import ratpack.handling.HandlerDecorator
13+
import static ratpack.jackson.Jackson.json
1314

1415
import java.nio.charset.StandardCharsets
1516

@@ -142,7 +143,8 @@ class RatpackAsyncHttpServerTest extends RatpackHttpServerTest {
142143
} then {endpoint ->
143144
controller(endpoint) {
144145
context.parse(Map).then { map ->
145-
response.status(BODY_JSON.status).send('text/plain', "{\"a\":\"${map['a']}\"}")
146+
response.status(BODY_JSON.status)
147+
context.render(json(map))
146148
}
147149
}
148150
}

dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackForkedHttpServerTest.groovy

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ratpack.exec.Promise
77
import ratpack.form.Form
88
import ratpack.groovy.test.embed.GroovyEmbeddedApp
99
import ratpack.handling.HandlerDecorator
10+
import static ratpack.jackson.Jackson.json
1011

1112
import java.nio.charset.StandardCharsets
1213

@@ -105,10 +106,11 @@ class RatpackForkedHttpServerTest extends RatpackHttpServerTest {
105106
all {
106107
Promise.sync {
107108
BODY_JSON
108-
}.fork().then {endpoint ->
109+
}.fork().then { endpoint ->
109110
controller(endpoint) {
110111
context.parse(Map).then { map ->
111-
response.status(BODY_JSON.status).send('text/plain', "{\"a\":\"${map['a']}\"}")
112+
response.status(BODY_JSON.status)
113+
context.render(json(map))
112114
}
113115
}
114116
}

dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackHttpServerTest.groovy

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ class RatpackHttpServerTest extends HttpServerTest<EmbeddedApp> {
7777
true
7878
}
7979

80+
@Override
81+
boolean testResponseBodyJson() {
82+
true
83+
}
84+
8085
@Override
8186
String testPathParam() {
8287
true

dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/SyncRatpackApp.groovy

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ratpack.form.Form
55
import ratpack.groovy.test.embed.GroovyEmbeddedApp
66
import ratpack.handling.HandlerDecorator
77
import ratpack.test.embed.EmbeddedApp
8+
import static ratpack.jackson.Jackson.json
89

910
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_JSON
1011
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_MULTIPART
@@ -46,9 +47,10 @@ enum SyncRatpackApp implements EmbeddedApp {
4647
prefix(CREATED.relativeRawPath()) {
4748
all {
4849
controller(CREATED) {
49-
request.body.then { typedData ->
50+
request.body.then {
51+
typedData ->
5052
response.status(CREATED.status)
51-
.send('text/plain', "${CREATED.body}: ${typedData.text}")
53+
.send('text/plain', "${CREATED.body}: ${typedData.text}")
5254
}
5355
}
5456
}
@@ -61,9 +63,14 @@ enum SyncRatpackApp implements EmbeddedApp {
6163
prefix(BODY_URLENCODED.relativeRawPath()) {
6264
all {
6365
controller(BODY_URLENCODED) {
64-
context.parse(Form).then { form ->
65-
def text = form.findAll { it.key != 'ignore'}
66-
.collectEntries {[it.key, it.value as List]} as String
66+
context.parse(Form).then {
67+
form ->
68+
def text = form.findAll {
69+
it.key != 'ignore'
70+
}
71+
.collectEntries {
72+
[it.key, it.value as List]
73+
} as String
6774
response.status(BODY_URLENCODED.status).send('text/plain', text)
6875
}
6976
}
@@ -72,8 +79,11 @@ enum SyncRatpackApp implements EmbeddedApp {
7279
prefix(BODY_MULTIPART.relativeRawPath()) {
7380
all {
7481
controller(BODY_MULTIPART) {
75-
context.parse(Form).then { form ->
76-
def text = form.collectEntries {[it.key, it.value as List]} as String
82+
context.parse(Form).then {
83+
form ->
84+
def text = form.collectEntries {
85+
[it.key, it.value as List]
86+
} as String
7787
response.status(BODY_MULTIPART.status).send('text/plain', text)
7888
}
7989
}
@@ -82,8 +92,11 @@ enum SyncRatpackApp implements EmbeddedApp {
8292
prefix(BODY_JSON.relativeRawPath()) {
8393
all {
8494
controller(BODY_JSON) {
85-
context.parse(Map).then { map ->
86-
response.status(BODY_JSON.status).send('text/plain', "{\"a\":\"${map['a']}\"}")
95+
context.parse(Map).then {
96+
map -> {
97+
response.status(BODY_JSON.status)
98+
context.render(json(map))
99+
}
87100
}
88101
}
89102
}

dd-java-agent/testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2503,7 +2503,12 @@ abstract class HttpServerTest<SERVER> extends WithHttpServer<SERVER> {
25032503

25042504
final BiFunction<RequestContext, Object, Flow<Void>> responseBodyObjectCb =
25052505
({ RequestContext rqCtxt, Object obj ->
2506-
String body = obj.toString()
2506+
String body
2507+
if (obj instanceof Map) {
2508+
body = JsonOutput.toJson(obj as Map)
2509+
} else {
2510+
body = obj.toString()
2511+
}
25072512
Context context = rqCtxt.getData(RequestContextSlot.APPSEC)
25082513
if (context.responseBodyTag) {
25092514
rqCtxt.traceSegment.setTagTop('response.body', body)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
plugins {
2+
id "com.gradleup.shadow"
3+
}
4+
5+
apply from: "$rootDir/gradle/java.gradle"
6+
7+
jar {
8+
manifest {
9+
attributes('Main-Class': 'datadog.smoketest.ratpack.RatpackApp')
10+
}
11+
}
12+
dependencies {
13+
implementation 'io.ratpack:ratpack-core:1.5.0'
14+
15+
testImplementation project(':dd-smoke-tests')
16+
testImplementation project(':dd-smoke-tests:appsec')
17+
}
18+
19+
tasks.withType(Test).configureEach {
20+
dependsOn "shadowJar"
21+
jvmArgs "-Ddatadog.smoketest.ratpack.shadowJar.path=${tasks.shadowJar.archiveFile.get()}"
22+
}
23+
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package datadog.smoketest.ratpack;
2+
3+
import static ratpack.jackson.Jackson.json;
4+
5+
import com.fasterxml.jackson.databind.JsonNode;
6+
import ratpack.server.RatpackServer;
7+
8+
public class RatpackApp {
9+
10+
public static void main(String[] args) throws Exception {
11+
int port = Integer.parseInt(System.getProperty("ratpack.http.port", "8080"));
12+
RatpackServer.start(
13+
server ->
14+
server
15+
.serverConfig(config -> config.port(port))
16+
.handlers(
17+
chain ->
18+
chain
19+
.path(
20+
"api_security/sampling/:status_code",
21+
ctx -> {
22+
ctx.getResponse()
23+
.status(
24+
Integer.parseInt(ctx.getPathTokens().get("status_code")))
25+
.send("EXECUTED");
26+
})
27+
.path(
28+
"api_security/response",
29+
ctx ->
30+
ctx.parse(JsonNode.class)
31+
.then(
32+
node -> {
33+
ctx.getResponse().status(200);
34+
ctx.render(json(node));
35+
}))
36+
.all(
37+
ctx -> {
38+
ctx.getResponse().status(404).send("Not Found");
39+
})));
40+
}
41+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest
2+
import datadog.trace.agent.test.utils.OkHttpUtils
3+
import groovy.json.JsonOutput
4+
import okhttp3.MediaType
5+
import okhttp3.Request
6+
import okhttp3.RequestBody
7+
import okhttp3.Response
8+
9+
class AppSecRatpackSmokeTest extends AbstractAppSecServerSmokeTest {
10+
11+
@Override
12+
ProcessBuilder createProcessBuilder() {
13+
String ratpackUberJar = System.getProperty("datadog.smoketest.ratpack.shadowJar.path")
14+
15+
List<String> command = new ArrayList<>()
16+
command.add(javaPath())
17+
command.addAll(defaultJavaProperties)
18+
command.addAll(defaultAppSecProperties)
19+
command.addAll((String[]) [
20+
"-Ddd.writer.type=MultiWriter:TraceStructureWriter:${output.getAbsolutePath()},DDAgentWriter",
21+
"-Dratpack.http.port=${httpPort}",
22+
"-jar",
23+
ratpackUberJar
24+
])
25+
ProcessBuilder processBuilder = new ProcessBuilder(command)
26+
processBuilder.directory(new File(buildDirectory))
27+
}
28+
29+
@Override
30+
File createTemporaryFile() {
31+
return new File("${buildDirectory}/tmp/trace-structure-ratpack.out")
32+
}
33+
34+
void 'API Security samples only one request per endpoint'() {
35+
given:
36+
def url = "http://localhost:${httpPort}/api_security/sampling/200?test=value"
37+
def client = OkHttpUtils.clientBuilder().build()
38+
def request = new Request.Builder()
39+
.url(url)
40+
.addHeader('X-My-Header', "value")
41+
.get()
42+
.build()
43+
44+
when:
45+
List<Response> responses = (1..3).collect {
46+
client.newCall(request).execute()
47+
}
48+
49+
then:
50+
responses.each {
51+
assert it.code() == 200
52+
}
53+
waitForTraceCount(3)
54+
def spans = rootSpans.toList().toSorted { it.span.duration }
55+
spans.size() == 3
56+
def sampledSpans = spans.findAll {
57+
it.meta.keySet().any {
58+
it.startsWith('_dd.appsec.s.req.')
59+
}
60+
}
61+
sampledSpans.size() == 1
62+
def span = sampledSpans[0]
63+
span.meta.containsKey('_dd.appsec.s.req.query')
64+
span.meta.containsKey('_dd.appsec.s.req.params')
65+
span.meta.containsKey('_dd.appsec.s.req.headers')
66+
}
67+
68+
void 'test response schema extraction'() {
69+
given:
70+
def url = "http://localhost:${httpPort}/api_security/response"
71+
def client = OkHttpUtils.clientBuilder().build()
72+
def body = [
73+
source: 'AppSecRatpackSmokeTest',
74+
tests : [
75+
[
76+
name : 'API Security samples only one request per endpoint',
77+
status: 'SUCCESS'
78+
],
79+
[
80+
name : 'test response schema extraction',
81+
status: 'FAILED'
82+
]
83+
]
84+
]
85+
def request = new Request.Builder()
86+
.url(url)
87+
.post(RequestBody.create(MediaType.get('application/json'), JsonOutput.toJson(body)))
88+
.build()
89+
90+
when:
91+
final response = client.newCall(request).execute()
92+
waitForTraceCount(1)
93+
94+
then:
95+
response.code() == 200
96+
def span = rootSpans.first()
97+
span.meta.containsKey('_dd.appsec.s.res.headers')
98+
span.meta.containsKey('_dd.appsec.s.res.body')
99+
}
100+
}

0 commit comments

Comments
 (0)