From 82b0a7391b17f51c90995fb199f2413dd713f3f4 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Sun, 9 Aug 2026 13:57:37 +0200 Subject: [PATCH 1/5] Instrument ring jetty servlet --- agent/build.gradle | 1 + .../main/java/dev/aikido/agent/Wrappers.java | 6 +- .../ring/RingJettyServletWrapper.java | 105 ++++++++++++++++++ .../wrappers/ring/RingRequestBodyWrapper.java | 98 ++++++++++++++++ .../agent_api/context/RingContextObject.java | 49 ++++++++ 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java create mode 100644 agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java create mode 100644 agent_api/src/main/java/dev/aikido/agent_api/context/RingContextObject.java diff --git a/agent/build.gradle b/agent/build.gradle index 8e4b4453..f7826db0 100644 --- a/agent/build.gradle +++ b/agent/build.gradle @@ -12,6 +12,7 @@ dependencies { compileOnly 'io.projectreactor.netty:reactor-netty-http:1.2.1' // For Spring Webflux compileOnly 'io.javalin:javalin:6.4.0' compileOnly 'org.springframework:spring-web:5.3.20' + compileOnly 'com.google.code.gson:gson:2.11.0' // already bundled via agent_api at runtime } shadowJar { diff --git a/agent/src/main/java/dev/aikido/agent/Wrappers.java b/agent/src/main/java/dev/aikido/agent/Wrappers.java index a71c13b2..745186aa 100644 --- a/agent/src/main/java/dev/aikido/agent/Wrappers.java +++ b/agent/src/main/java/dev/aikido/agent/Wrappers.java @@ -5,6 +5,8 @@ import dev.aikido.agent.wrappers.file.FileConstructorSingleArgumentWrapper; import dev.aikido.agent.wrappers.javalin.*; import dev.aikido.agent.wrappers.jdbc.*; +import dev.aikido.agent.wrappers.ring.RingJettyServletWrapper; +import dev.aikido.agent.wrappers.ring.RingRequestBodyWrapper; import dev.aikido.agent.wrappers.spring.SpringMVCJavaxWrapper; import dev.aikido.agent.wrappers.spring.SpringWebfluxWrapper; import dev.aikido.agent.wrappers.spring.SpringControllerWrapper; @@ -42,6 +44,8 @@ private Wrappers() {} new JavalinDataWrapper(), new JavalinContextClearWrapper(), new SQLiteWrapper(), - new HyperSQLWrapper() + new HyperSQLWrapper(), + new RingJettyServletWrapper(), + new RingRequestBodyWrapper() ); } diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java new file mode 100644 index 00000000..418cc4b7 --- /dev/null +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java @@ -0,0 +1,105 @@ +package dev.aikido.agent.wrappers.ring; + +import dev.aikido.agent.wrappers.Wrapper; +import dev.aikido.agent_api.collectors.WebRequestCollector; +import dev.aikido.agent_api.collectors.WebResponseCollector; +import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.context.RingContextObject; +import dev.aikido.agent_api.helpers.logging.LogManager; +import dev.aikido.agent_api.helpers.logging.Logger; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +import java.lang.reflect.Executable; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; + +import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC; +import static net.bytebuddy.matcher.ElementMatchers.*; + +/** + * Populates request context for Ring apps served through ring-jetty-adapter (Jetty 12/EE9), + * which dispatches requests through a subclass of ServletHandler overriding doHandle(...). + * Body capture lives in RingRequestBodyWrapper instead: Clojure's proxy-generated doHandle + * boxes its arguments for delegation, so reassigning the request argument here never reaches + * Ring's own handler. + */ +public class RingJettyServletWrapper implements Wrapper { + public static final Logger logger = LogManager.getLogger(RingJettyServletWrapper.class); + + @Override + public String getName() { + return RingJettyAdvice.class.getName(); + } + + @Override + public ElementMatcher getMatcher() { + return named("doHandle").and(takesArguments(4)); + } + + @Override + public ElementMatcher getTypeMatcher() { + return hasSuperType(named("org.eclipse.jetty.ee9.servlet.ServletHandler")); + } + + public static class RingJettyAdvice { + public record SkipOnWrapper(HttpServletResponse response) {} + + @Advice.OnMethodEnter(skipOn = SkipOnWrapper.class, suppress = Throwable.class) + public static Object interceptOnEnter( + @Advice.Origin Executable method, + @Advice.Argument(value = 2, typing = DYNAMIC, optional = true) HttpServletRequest request, + @Advice.Argument(value = 3, typing = DYNAMIC, optional = true) HttpServletResponse response) throws Throwable { + if (request == null) { + return response; + } + + HashMap> headersMap = new HashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames != null && headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + headersMap.put(headerName, request.getHeaders(headerName)); + } + + HashMap> cookiesMap = new HashMap<>(); + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (!cookiesMap.containsKey(cookie.getName())) { + cookiesMap.put(cookie.getName(), new ArrayList<>()); + } + cookiesMap.get(cookie.getName()).add(cookie.getValue()); + } + } + + ContextObject contextObject = new RingContextObject( + request.getMethod(), request.getRequestURL(), request.getRemoteAddr(), + request.getParameterMap(), cookiesMap, headersMap, request.getQueryString() + ); + + WebRequestCollector.Res res = WebRequestCollector.report(contextObject); + if (res != null) { + logger.trace("Writing a new response"); + response.setStatus(res.status()); + response.setContentType("text/plain"); + response.getWriter().write(res.msg()); + return new SkipOnWrapper(response); + } + return response; + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void interceptOnExit(@Advice.Enter Object response) { + if (response instanceof HttpServletResponse httpServletResponse) { + WebResponseCollector.report(httpServletResponse.getStatus()); + } + } + } +} diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java new file mode 100644 index 00000000..580e00f4 --- /dev/null +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java @@ -0,0 +1,98 @@ +package dev.aikido.agent.wrappers.ring; + +import com.google.gson.Gson; +import dev.aikido.agent.wrappers.Wrapper; +import dev.aikido.agent_api.context.Context; +import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.helpers.logging.LogManager; +import dev.aikido.agent_api.helpers.logging.Logger; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC; +import static net.bytebuddy.matcher.ElementMatchers.*; + +/** + * Captures the request body for Jetty EE9's HttpServletRequest and attaches it (best-effort + * JSON parsed) to the ContextObject RingJettyServletWrapper already stored for this thread. + * Ring calls getInputStream() once per request, so draining and replacing it here is safe. + */ +public class RingRequestBodyWrapper implements Wrapper { + public static final Logger logger = LogManager.getLogger(RingRequestBodyWrapper.class); + + @Override + public String getName() { + return RingRequestBodyAdvice.class.getName(); + } + + @Override + public ElementMatcher getMatcher() { + return named("getInputStream").and(takesArguments(0)); + } + + @Override + public ElementMatcher getTypeMatcher() { + return nameContains("org.eclipse.jetty.ee9.nested") + .and(isSubTypeOf(HttpServletRequest.class)); + } + + // Must be public: Advice bytecode is inlined into the instrumented class itself, which + // can't access a private nested class from a different top-level class (IllegalAccessError). + public static class BufferedServletInputStream extends ServletInputStream { + private final ByteArrayInputStream backing; + + public BufferedServletInputStream(byte[] body) { + this.backing = new ByteArrayInputStream(body); + } + + @Override + public boolean isFinished() { + return backing.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + } + + @Override + public int read() { + return backing.read(); + } + } + + public static class RingRequestBodyAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void interceptOnExit( + @Advice.Return(readOnly = false, typing = DYNAMIC) ServletInputStream returnValue) throws Throwable { + if (returnValue == null) { + return; + } + byte[] bodyBytes = returnValue.readAllBytes(); + + ContextObject ctx = Context.get(); + if (ctx != null && bodyBytes.length > 0) { + try { + Object parsedBody = new Gson().fromJson(new String(bodyBytes, StandardCharsets.UTF_8), Object.class); + ctx.setBody(parsedBody); + } catch (Throwable t) { + logger.debug("RingRequestBodyWrapper failed to parse JSON body: %s", t.getMessage()); + } + } + + returnValue = new BufferedServletInputStream(bodyBytes); + } + } +} diff --git a/agent_api/src/main/java/dev/aikido/agent_api/context/RingContextObject.java b/agent_api/src/main/java/dev/aikido/agent_api/context/RingContextObject.java new file mode 100644 index 00000000..3c6d8d2e --- /dev/null +++ b/agent_api/src/main/java/dev/aikido/agent_api/context/RingContextObject.java @@ -0,0 +1,49 @@ +package dev.aikido.agent_api.context; + +import java.util.*; + +import static dev.aikido.agent_api.helpers.net.ProxyForwardedParser.getIpFromRequest; +import static dev.aikido.agent_api.helpers.url.BuildRouteFromUrl.buildRouteFromUrl; + +public class RingContextObject extends ContextObject { + public RingContextObject( + String method, StringBuffer url, String rawIp, Map queryParams, + HashMap> cookies, HashMap> headers, String queryString + ) { + this.method = method; + if (url != null) { + this.url = url.toString(); + if (queryString != null && !queryString.isEmpty()) { + this.url = this.url + "?" + queryString; + } + } + this.query = extractQueryParameters(queryParams); + this.cookies = cookies; + this.headers = extractHeaders(headers); + this.route = buildRouteFromUrl(this.url); + this.remoteAddress = getIpFromRequest(rawIp, this.headers); + this.source = "Ring"; + this.redirectStartNodes = new ArrayList<>(); + } + + private static HashMap> extractHeaders(HashMap> headers) { + HashMap> extractedHeaders = new HashMap<>(); + for (Map.Entry> entry : headers.entrySet()) { + List values = new ArrayList<>(); + Enumeration valuesEnum = entry.getValue(); + while (valuesEnum.hasMoreElements()) { + values.add(valuesEnum.nextElement()); + } + extractedHeaders.put(entry.getKey().toLowerCase(), values); + } + return extractedHeaders; + } + + private static HashMap> extractQueryParameters(Map parameterMap) { + HashMap> query = new HashMap<>(); + for (Map.Entry entry : parameterMap.entrySet()) { + query.put(entry.getKey(), Arrays.asList(entry.getValue())); + } + return query; + } +} From 7886b9d663050cc930f9f7cf7b3b6effd6d56003 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Sun, 9 Aug 2026 14:14:57 +0200 Subject: [PATCH 2/5] Add e2e test for ring clojure apps --- .github/workflows/end2end.yml | 7 ++++++ end2end/ring_clojure_postgres.py | 10 ++++++++ sample-apps/RingClojurePostgres/Makefile | 24 ++++++++++++++++++- sample-apps/RingClojurePostgres/project.clj | 6 +++-- .../src/RingClojurePostgres/core.clj | 3 ++- 5 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 end2end/ring_clojure_postgres.py diff --git a/.github/workflows/end2end.yml b/.github/workflows/end2end.yml index 66c9dac2..071e5660 100644 --- a/.github/workflows/end2end.yml +++ b/.github/workflows/end2end.yml @@ -49,6 +49,7 @@ jobs: - { name: JavalinMySQLKotlin, test_file: end2end/javalin_mysql_kotlin.py, db: mysql_database } - { name: SpringBoot2.7Postgres, test_file: end2end/spring_boot_2.7_postgres.py, db: postgres_database } - { name: SpringBootHyperSQL, test_file: end2end/spring_boot_hypersql.py, db: "" } + - { name: RingClojurePostgres, test_file: end2end/ring_clojure_postgres.py, db: postgres_database } java-version: [17, 18, 19, 20, 21, 24, 25] distribution: ['adopt', 'corretto', 'oracle'] exclude: @@ -82,6 +83,12 @@ jobs: - name: Install Python dependencies run: python -m pip install -r end2end/requirements.txt + - name: Install Leiningen + if: matrix.app.name == 'RingClojurePostgres' + run: | + curl -sSL -o /usr/local/bin/lein https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein + chmod +x /usr/local/bin/lein + - name: Build Application (ensures cache) working-directory: ./sample-apps/${{ matrix.app.name }} run: | diff --git a/end2end/ring_clojure_postgres.py b/end2end/ring_clojure_postgres.py new file mode 100644 index 00000000..73aba7b6 --- /dev/null +++ b/end2end/ring_clojure_postgres.py @@ -0,0 +1,10 @@ +from utils import App, Request + +ring_clojure_postgres_app = App(8102) + +ring_clojure_postgres_app.add_payload("sql", + safe_request=Request("/api/create", body={"name": "Bobby"}), + unsafe_request=Request("/api/create", body={"name": "Malicious Pet', 'Gru from the Minions') -- "}) +) + +ring_clojure_postgres_app.test_all_payloads() diff --git a/sample-apps/RingClojurePostgres/Makefile b/sample-apps/RingClojurePostgres/Makefile index 79f31544..02ee1ef6 100644 --- a/sample-apps/RingClojurePostgres/Makefile +++ b/sample-apps/RingClojurePostgres/Makefile @@ -1,12 +1,34 @@ # Define variables JAVA_AGENT = ../../dist/agent.jar +.PHONY: all +all: build + +.PHONY: build +build: + @echo "Resolving dependencies..." + lein deps + # Run the application with the Java agent .PHONY: run -run: +run: build @echo "Running RingClojurePostgres with Zen (http://localhost:8102)" AIKIDO_TOKEN="token" \ AIKIDO_REALTIME_ENDPOINT="http://localhost:5000/realtime" \ AIKIDO_ENDPOINT="http://localhost:5000" \ AIKIDO_BLOCK=1 \ + AIKIDO_AGENT_JAR="$(JAVA_AGENT)" \ + PORT=8102 \ lein run + +# Run the application without Zen +.PHONY: runWithoutZen +runWithoutZen: build + @echo "Running RingClojurePostgres without Zen (http://localhost:8103)" + AIKIDO_TOKEN="random-invalid-token" \ + PORT=8103 \ + lein run + +.PHONY: clean +clean: + lein clean diff --git a/sample-apps/RingClojurePostgres/project.clj b/sample-apps/RingClojurePostgres/project.clj index caaba8ad..4a7e52cc 100644 --- a/sample-apps/RingClojurePostgres/project.clj +++ b/sample-apps/RingClojurePostgres/project.clj @@ -1,12 +1,14 @@ (defproject RingClojurePostgres "0.1.0-SNAPSHOT" :dependencies [[org.clojure/clojure "1.11.1"] - [ring/ring-core "1.7.1"] - [ring/ring-jetty-adapter "1.7.1"] + [ring/ring-core "1.15.3"] + [ring/ring-jetty-adapter "1.15.3"] + [org.eclipse.jetty.toolchain/jetty-jakarta-servlet-api "5.0.2"] [clj-http "3.12.3"] [org.clojure/java.jdbc "0.7.12"] [org.postgresql/postgresql "42.2.20"] [ring/ring-json "0.5.1"]] :main ^:skip-aot RingClojurePostgres.core :target-path "target/%s" + :jvm-opts ~(if-let [agent (System/getenv "AIKIDO_AGENT_JAR")] [(str "-javaagent:" agent)] []) :profiles {:uberjar {:aot :all :jvm-opts ["-Dclojure.compiler.direct-linking=true"]}}) diff --git a/sample-apps/RingClojurePostgres/src/RingClojurePostgres/core.clj b/sample-apps/RingClojurePostgres/src/RingClojurePostgres/core.clj index 9b16e755..371e610a 100644 --- a/sample-apps/RingClojurePostgres/src/RingClojurePostgres/core.clj +++ b/sample-apps/RingClojurePostgres/src/RingClojurePostgres/core.clj @@ -91,4 +91,5 @@ (defn -main [& args] - (run-jetty app {:port 8102 :join? false})) + (let [port (if-let [p (System/getenv "PORT")] (Integer/parseInt p) 8102)] + (run-jetty app {:port port :join? false}))) From 575c1ff1d6a9c0fba250bafe9b4d54ad18fab2de Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 10 Aug 2026 11:25:50 +0200 Subject: [PATCH 3/5] Fix crash --- .../dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java index 580e00f4..0a621da2 100644 --- a/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java @@ -8,7 +8,6 @@ import dev.aikido.agent_api.helpers.logging.Logger; import jakarta.servlet.ReadListener; import jakarta.servlet.ServletInputStream; -import jakarta.servlet.http.HttpServletRequest; import net.bytebuddy.asm.Advice; import net.bytebuddy.description.method.MethodDescription; import net.bytebuddy.description.type.TypeDescription; @@ -41,7 +40,7 @@ public ElementMatcher getMatcher() { @Override public ElementMatcher getTypeMatcher() { return nameContains("org.eclipse.jetty.ee9.nested") - .and(isSubTypeOf(HttpServletRequest.class)); + .and(hasSuperType(named("jakarta.servlet.http.HttpServletRequest"))); } // Must be public: Advice bytecode is inlined into the instrumented class itself, which From 5c0771d3c62c04660becde634f8ed5a43d9cf4c1 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 10 Aug 2026 13:09:08 +0200 Subject: [PATCH 4/5] Strip comments --- agent/build.gradle | 2 +- .../agent/wrappers/ring/RingJettyServletWrapper.java | 7 ------- .../aikido/agent/wrappers/ring/RingRequestBodyWrapper.java | 7 ------- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/agent/build.gradle b/agent/build.gradle index f7826db0..84eeaf0c 100644 --- a/agent/build.gradle +++ b/agent/build.gradle @@ -12,7 +12,7 @@ dependencies { compileOnly 'io.projectreactor.netty:reactor-netty-http:1.2.1' // For Spring Webflux compileOnly 'io.javalin:javalin:6.4.0' compileOnly 'org.springframework:spring-web:5.3.20' - compileOnly 'com.google.code.gson:gson:2.11.0' // already bundled via agent_api at runtime + compileOnly 'com.google.code.gson:gson:2.11.0' } shadowJar { diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java index 418cc4b7..33a45245 100644 --- a/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java @@ -24,13 +24,6 @@ import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC; import static net.bytebuddy.matcher.ElementMatchers.*; -/** - * Populates request context for Ring apps served through ring-jetty-adapter (Jetty 12/EE9), - * which dispatches requests through a subclass of ServletHandler overriding doHandle(...). - * Body capture lives in RingRequestBodyWrapper instead: Clojure's proxy-generated doHandle - * boxes its arguments for delegation, so reassigning the request argument here never reaches - * Ring's own handler. - */ public class RingJettyServletWrapper implements Wrapper { public static final Logger logger = LogManager.getLogger(RingJettyServletWrapper.class); diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java index 0a621da2..dbddf1c8 100644 --- a/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java @@ -19,11 +19,6 @@ import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC; import static net.bytebuddy.matcher.ElementMatchers.*; -/** - * Captures the request body for Jetty EE9's HttpServletRequest and attaches it (best-effort - * JSON parsed) to the ContextObject RingJettyServletWrapper already stored for this thread. - * Ring calls getInputStream() once per request, so draining and replacing it here is safe. - */ public class RingRequestBodyWrapper implements Wrapper { public static final Logger logger = LogManager.getLogger(RingRequestBodyWrapper.class); @@ -43,8 +38,6 @@ public ElementMatcher getTypeMatcher() { .and(hasSuperType(named("jakarta.servlet.http.HttpServletRequest"))); } - // Must be public: Advice bytecode is inlined into the instrumented class itself, which - // can't access a private nested class from a different top-level class (IllegalAccessError). public static class BufferedServletInputStream extends ServletInputStream { private final ByteArrayInputStream backing; From 2f16be71d36a1c7d6c8a0850a1bfdfe223828163 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 10 Aug 2026 13:19:44 +0200 Subject: [PATCH 5/5] Add test for ring context --- .../java/context/RingContextObjectTest.java | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 agent_api/src/test/java/context/RingContextObjectTest.java diff --git a/agent_api/src/test/java/context/RingContextObjectTest.java b/agent_api/src/test/java/context/RingContextObjectTest.java new file mode 100644 index 00000000..93dba4b9 --- /dev/null +++ b/agent_api/src/test/java/context/RingContextObjectTest.java @@ -0,0 +1,88 @@ +package context; + +import dev.aikido.agent_api.context.RingContextObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class RingContextObjectTest { + + private RingContextObject contextObject; + + @BeforeEach + void setUp() { + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/test"), "192.168.1.1", Map.of(), new HashMap<>(), new HashMap<>(), null + ); + } + + @Test + void testConstructor() { + assertEquals("GET", contextObject.getMethod()); + assertEquals("http://localhost/test", contextObject.getUrl()); + assertEquals("192.168.1.1", contextObject.getRemoteAddress()); + assertEquals("Ring", contextObject.getSource()); + } + + @Test + void testGetRouteWithSlash() { + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/test"), "192.168.1.1", Map.of(), new HashMap<>(), new HashMap<>(), "a=b" + ); + + assertEquals("http://localhost/test?a=b", contextObject.getUrl()); + assertEquals("/test", contextObject.getRoute()); + } + + @Test + void testGetRouteWithNumbers() { + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/api/dog/28632"), "192.168.1.1", Map.of(), new HashMap<>(), new HashMap<>(), "" + ); + + assertEquals("http://localhost/api/dog/28632", contextObject.getUrl()); + assertEquals("/api/dog/:number", contextObject.getRoute()); + } + + @Test + void testQueryParametersExtraction() { + Map queryParams = new HashMap<>(); + queryParams.put("param1", new String[]{"value1"}); + + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/test"), "192.168.1.1", queryParams, new HashMap<>(), new HashMap<>(), null + ); + + assertEquals(1, contextObject.getQuery().size()); + assertEquals("value1", contextObject.getQuery().get("param1").get(0)); + } + + @Test + void testCookiesExtraction() { + HashMap> cookies = new HashMap<>(); + cookies.put("sessionId", List.of("abc123")); + + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/test"), "192.168.1.1", Map.of(), cookies, new HashMap<>(), null + ); + + assertEquals(1, contextObject.getCookies().size()); + assertEquals("abc123", contextObject.getCookies().get("sessionId").get(0)); + } + + @Test + void testHeadersExtraction() { + Vector contentTypeValues = new Vector<>(List.of("application/json")); + HashMap> headers = new HashMap<>(); + headers.put("Content-Type", contentTypeValues.elements()); + + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/test"), "192.168.1.1", Map.of(), new HashMap<>(), headers, null + ); + + assertEquals("application/json", contextObject.getHeader("content-type")); + } +}