diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
new file mode 100644
index 0000000..642c7cc
--- /dev/null
+++ b/.github/workflows/pull-request.yml
@@ -0,0 +1,34 @@
+name: Test
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ java-version: ['17', '21']
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: ${{ matrix.java-version }}
+ cache: maven
+
+ - name: Setup Node (for contract mock server)
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20.x
+
+ - name: Fetch contract mock server
+ run: curl -fsSL -o mock-server.mjs https://raw.githubusercontent.com/prerender/integration-contract/main/mock-server.mjs
+
+ - name: Run tests
+ run: mvn --batch-mode --no-transfer-progress test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ff81f0b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+target/
+*.iml
+.idea/
+.vscode/
+mock-server.mjs
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f93d383
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Prerender
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7aca8c7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,111 @@
+# prerender-java
+
+Jakarta Servlet Filter for [Prerender.io](https://prerender.io). Intercepts requests from bots and crawlers and serves prerendered HTML, so your JavaScript-rendered app is fully indexable by search engines and social media scrapers.
+
+Compatible with any **Jakarta EE** application server — Tomcat 10+, Jetty 11+, Spring Boot 3+, Quarkus, Micronaut.
+
+Requires **Java 17+**.
+
+## Installation
+
+### Maven
+
+```xml
+
+ io.prerender
+ prerender-java
+ 1.0.0
+
+```
+
+### Gradle
+
+```groovy
+implementation 'io.prerender:prerender-java:1.0.0'
+```
+
+## Setup
+
+### Option 1: Environment variables (recommended)
+
+```bash
+export PRERENDER_TOKEN=your-token
+```
+
+Register the filter in `web.xml`:
+
+```xml
+
+ PrerenderFilter
+ io.prerender.PrerenderFilter
+
+
+ PrerenderFilter
+ /*
+
+```
+
+### Option 2: web.xml init-params
+
+```xml
+
+ PrerenderFilter
+ io.prerender.PrerenderFilter
+
+ prerenderToken
+ your-token
+
+
+
+ PrerenderFilter
+ /*
+
+```
+
+### Spring Boot
+
+```java
+@Bean
+public FilterRegistrationBean prerenderFilter() {
+ FilterRegistrationBean registration = new FilterRegistrationBean<>();
+ registration.setFilter(new PrerenderFilter());
+ registration.addUrlPatterns("/*");
+ registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
+ return registration;
+}
+```
+
+Set `PRERENDER_TOKEN` as an environment variable before starting the app.
+
+## Settings
+
+| Setting | Init-param | Env var | Default |
+|---------|------------|---------|---------|
+| Token | `prerenderToken` | `PRERENDER_TOKEN` | none |
+| Service URL | `prerenderServiceUrl` | `PRERENDER_SERVICE_URL` | `https://service.prerender.io/` |
+
+Init-params take precedence over environment variables.
+
+## Self-hosted Prerender
+
+```bash
+export PRERENDER_SERVICE_URL=http://your-prerender-server:3000
+```
+
+## How it works
+
+Requests are prerendered when **all** of the following are true:
+
+- The HTTP method is `GET`
+- The `User-Agent` matches a known bot/crawler (Googlebot, Bingbot, Twitterbot, GPTBot, ClaudeBot, etc.)
+ — OR the URL contains `_escaped_fragment_`
+ — OR the `X-Bufferbot` header is present
+- The URL does not end with a static asset extension (`.js`, `.css`, `.png`, etc.)
+
+Everything else passes through to your normal servlet chain.
+
+If the Prerender service is unreachable, the filter falls back gracefully and serves the normal response.
+
+## License
+
+MIT
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..1ac460d
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,84 @@
+
+
+ 4.0.0
+
+ io.prerender
+ prerender-java
+ 1.0.0
+ jar
+
+ prerender-java
+ Jakarta Servlet Filter for prerendering JavaScript-rendered pages via Prerender.io
+ https://github.com/prerender/integrations
+
+
+
+ MIT License
+ https://opensource.org/licenses/MIT
+
+
+
+
+ scm:git:git@github.com:prerender/integrations.git
+ scm:git:git@github.com:prerender/integrations.git
+ https://github.com/prerender/integrations
+
+
+
+ 17
+ 17
+ UTF-8
+
+
+
+
+ jakarta.servlet
+ jakarta.servlet-api
+ 6.0.0
+ provided
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.10.2
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.11.0
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ 5.11.0
+ test
+
+
+ org.wiremock
+ wiremock
+ 3.5.4
+ test
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.17.0
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+
+
diff --git a/src/main/java/io/prerender/PrerenderConfig.java b/src/main/java/io/prerender/PrerenderConfig.java
new file mode 100644
index 0000000..5a85c97
--- /dev/null
+++ b/src/main/java/io/prerender/PrerenderConfig.java
@@ -0,0 +1,59 @@
+package io.prerender;
+
+import java.util.List;
+
+class PrerenderConfig {
+
+ static final List CRAWLER_USER_AGENTS = List.of(
+ "googlebot", "yahoo", "bingbot", "baiduspider",
+ "facebookexternalhit", "twitterbot", "rogerbot", "linkedinbot",
+ "embedly", "quora link preview", "showyoubot", "outbrain",
+ "pinterest", "slackbot", "w3c_validator", "perplexity",
+ "oai-searchbot", "chatgpt-user", "gptbot", "claudebot", "amazonbot"
+ );
+
+ static final List EXTENSIONS_TO_IGNORE = List.of(
+ ".js", ".css", ".xml", ".less", ".png", ".jpg", ".jpeg", ".gif",
+ ".pdf", ".doc", ".txt", ".ico", ".rss", ".zip", ".mp3", ".rar",
+ ".exe", ".wmv", ".avi", ".ppt", ".mpg", ".mpeg", ".tif", ".wav",
+ ".mov", ".psd", ".ai", ".xls", ".mp4", ".m4a", ".swf", ".dat",
+ ".dmg", ".iso", ".flv", ".m4v", ".torrent", ".ttf", ".woff", ".svg"
+ );
+
+ private static final String DEFAULT_SERVICE_URL = "https://service.prerender.io/";
+
+ private final String token;
+ private final String serviceUrl;
+
+ PrerenderConfig(String token, String serviceUrl) {
+ this.token = token;
+ this.serviceUrl = (serviceUrl != null && !serviceUrl.isBlank())
+ ? serviceUrl
+ : DEFAULT_SERVICE_URL;
+ }
+
+ static PrerenderConfig fromInitParams(String initToken, String initServiceUrl) {
+ return new PrerenderConfig(
+ resolve(initToken, "PRERENDER_TOKEN"),
+ resolve(initServiceUrl, "PRERENDER_SERVICE_URL")
+ );
+ }
+
+ private static String resolve(String initParam, String envVar) {
+ return (initParam != null && !initParam.isBlank()) ? initParam : System.getenv(envVar);
+ }
+
+ String getToken() { return token; }
+
+ String getServiceUrl() { return serviceUrl; }
+
+ static boolean isBot(String userAgent) {
+ String ua = userAgent.toLowerCase();
+ return CRAWLER_USER_AGENTS.stream().anyMatch(ua::contains);
+ }
+
+ static boolean isStaticAsset(String path) {
+ String lower = path.toLowerCase();
+ return EXTENSIONS_TO_IGNORE.stream().anyMatch(lower::endsWith);
+ }
+}
diff --git a/src/main/java/io/prerender/PrerenderFilter.java b/src/main/java/io/prerender/PrerenderFilter.java
new file mode 100644
index 0000000..c46846a
--- /dev/null
+++ b/src/main/java/io/prerender/PrerenderFilter.java
@@ -0,0 +1,111 @@
+package io.prerender;
+
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.FilterConfig;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.UUID;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+public class PrerenderFilter implements Filter {
+
+ public static final String VERSION = "1.0.0";
+
+ private static final Logger logger = Logger.getLogger(PrerenderFilter.class.getName());
+
+ private HttpClient httpClient;
+ private PrerenderConfig config;
+
+ public PrerenderFilter() {}
+
+ PrerenderFilter(HttpClient httpClient, PrerenderConfig config) {
+ this.httpClient = httpClient;
+ this.config = config;
+ }
+
+ @Override
+ public void init(FilterConfig filterConfig) {
+ this.httpClient = HttpClient.newHttpClient();
+ this.config = PrerenderConfig.fromInitParams(
+ filterConfig.getInitParameter("prerenderToken"),
+ filterConfig.getInitParameter("prerenderServiceUrl")
+ );
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+ HttpServletRequest httpReq = (HttpServletRequest) request;
+ HttpServletResponse httpRes = (HttpServletResponse) response;
+
+ if (!shouldPrerender(httpReq)) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ try {
+ sendPrerendered(httpReq, httpRes);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ chain.doFilter(request, response);
+ } catch (IOException e) {
+ logger.log(Level.WARNING, "Prerender service unreachable, falling back", e);
+ chain.doFilter(request, response);
+ }
+ }
+
+ @Override
+ public void destroy() {}
+
+ private boolean shouldPrerender(HttpServletRequest request) {
+ if (!"GET".equalsIgnoreCase(request.getMethod())) return false;
+ if (PrerenderConfig.isStaticAsset(request.getRequestURI())) return false;
+ if (request.getParameter("_escaped_fragment_") != null) return true;
+ if (request.getHeader("X-Bufferbot") != null) return true;
+ String ua = request.getHeader("User-Agent");
+ return ua != null && !ua.isBlank() && PrerenderConfig.isBot(ua);
+ }
+
+ private void sendPrerendered(HttpServletRequest request, HttpServletResponse response)
+ throws IOException, InterruptedException {
+ HttpResponse prerenderResponse = httpClient.send(
+ buildPrerenderRequest(buildApiUrl(request), request.getHeader("User-Agent")),
+ HttpResponse.BodyHandlers.ofString()
+ );
+ response.setStatus(prerenderResponse.statusCode());
+ response.getWriter().write(prerenderResponse.body());
+ }
+
+ private String buildApiUrl(HttpServletRequest request) {
+ String serviceUrl = config.getServiceUrl();
+ if (!serviceUrl.endsWith("/")) serviceUrl += "/";
+ String url = request.getRequestURL().toString();
+ String qs = request.getQueryString();
+ return serviceUrl + (qs != null && !qs.isBlank() ? url + "?" + qs : url);
+ }
+
+ private HttpRequest buildPrerenderRequest(String apiUrl, String userAgent) {
+ HttpRequest.Builder builder = HttpRequest.newBuilder()
+ .uri(URI.create(apiUrl))
+ .header("User-Agent", userAgent != null ? userAgent : "")
+ .GET();
+ if (config.getToken() != null && !config.getToken().isBlank()) {
+ builder.header("X-Prerender-Token", config.getToken());
+ }
+ builder.header("X-Prerender-Int-Type", "Java");
+ builder.header("X-Prerender-Int-Version", VERSION);
+ builder.header("X-Prerender-Request-Id", UUID.randomUUID().toString());
+ return builder.build();
+ }
+}
diff --git a/src/test/java/io/prerender/PrerenderFilterContractTest.java b/src/test/java/io/prerender/PrerenderFilterContractTest.java
new file mode 100644
index 0000000..4282ab8
--- /dev/null
+++ b/src/test/java/io/prerender/PrerenderFilterContractTest.java
@@ -0,0 +1,213 @@
+package io.prerender;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Contract tests against the shared mock server.
+ * Spec: https://github.com/prerender/integration-contract
+ *
+ * CI fetches mock-server.mjs into the repo root; locally:
+ * curl -fsSL -o mock-server.mjs https://raw.githubusercontent.com/prerender/integration-contract/main/mock-server.mjs
+ */
+@ExtendWith(MockitoExtension.class)
+class PrerenderFilterContractTest {
+
+ private static final String BOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1)";
+ private static final String BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36";
+ private static final String TOKEN = "test-token-abc123";
+ private static final Pattern UUID_V4 = Pattern.compile(
+ "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ private static Process mockProcess;
+ private static String mockUrl;
+ private static final HttpClient httpClient = HttpClient.newHttpClient();
+ private static final ObjectMapper mapper = new ObjectMapper();
+
+ @Mock private HttpServletRequest request;
+ @Mock private HttpServletResponse response;
+ @Mock private FilterChain chain;
+
+ private StringWriter responseWriter;
+ private PrerenderFilter filter;
+
+ @BeforeAll
+ static void startMock() throws Exception {
+ Path mockPath = Paths.get(System.getProperty(
+ "mockServerPath",
+ System.getenv().getOrDefault("MOCK_SERVER_PATH", "mock-server.mjs")
+ ));
+ if (!Files.exists(mockPath)) {
+ throw new IllegalStateException(
+ "mock-server.mjs not found at " + mockPath.toAbsolutePath()
+ + "; fetch it via curl from prerender/integration-contract"
+ );
+ }
+ int port;
+ try (ServerSocket s = new ServerSocket(0)) {
+ port = s.getLocalPort();
+ }
+ ProcessBuilder pb = new ProcessBuilder("node", mockPath.toString())
+ .redirectErrorStream(true);
+ pb.environment().put("PORT", String.valueOf(port));
+ mockProcess = pb.start();
+ mockUrl = "http://127.0.0.1:" + port;
+ waitForHealth();
+ }
+
+ @AfterAll
+ static void stopMock() {
+ if (mockProcess != null) mockProcess.destroy();
+ }
+
+ @BeforeEach
+ void resetAndBuildFilter() throws Exception {
+ httpClient.send(
+ HttpRequest.newBuilder(URI.create(mockUrl + "/__reset")).POST(HttpRequest.BodyPublishers.noBody()).build(),
+ HttpResponse.BodyHandlers.discarding()
+ );
+ responseWriter = new StringWriter();
+ lenient().when(response.getWriter()).thenReturn(new PrintWriter(responseWriter));
+ }
+
+ private void useToken(String token) {
+ filter = new PrerenderFilter(HttpClient.newHttpClient(), new PrerenderConfig(token, mockUrl));
+ }
+
+ private static void waitForHealth() throws Exception {
+ for (int i = 0; i < 50; i++) {
+ try {
+ HttpResponse r = httpClient.send(
+ HttpRequest.newBuilder(URI.create(mockUrl + "/__health")).GET().build(),
+ HttpResponse.BodyHandlers.ofString()
+ );
+ if (r.statusCode() == 200) return;
+ } catch (Exception ignored) {}
+ Thread.sleep(100);
+ }
+ throw new IllegalStateException("mock server at " + mockUrl + " did not become ready");
+ }
+
+ private JsonNode recordedRequests() throws Exception {
+ HttpResponse r = httpClient.send(
+ HttpRequest.newBuilder(URI.create(mockUrl + "/__requests")).GET().build(),
+ HttpResponse.BodyHandlers.ofString()
+ );
+ return mapper.readTree(r.body());
+ }
+
+ private void stubBotRequest(String uri) {
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRequestURI()).thenReturn(uri);
+ when(request.getParameter("_escaped_fragment_")).thenReturn(null);
+ when(request.getHeader("X-Bufferbot")).thenReturn(null);
+ when(request.getHeader("User-Agent")).thenReturn(BOT_UA);
+ when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com" + uri));
+ when(request.getQueryString()).thenReturn(null);
+ }
+
+ @Test
+ void botRequest_emitsOutgoingRequestWithRequiredHeaders() throws Exception {
+ useToken(TOKEN);
+ stubBotRequest("/blog/post-1");
+
+ filter.doFilter(request, response, chain);
+
+ JsonNode recorded = recordedRequests();
+ assertEquals(1, recorded.size(), "exactly one request should reach the mock");
+ JsonNode r = recorded.get(0);
+ assertEquals("GET", r.get("method").asText());
+ assertTrue(r.get("url").asText().endsWith("/blog/post-1"));
+ JsonNode headers = r.get("headers");
+ assertEquals(BOT_UA, headers.get("user-agent").asText());
+ assertEquals(TOKEN, headers.get("x-prerender-token").asText());
+ assertEquals("Java", headers.get("x-prerender-int-type").asText());
+ assertTrue(
+ headers.get("x-prerender-int-version").asText().matches("^\\d+\\.\\d+\\.\\d+.*"),
+ "Int-Version should be semver"
+ );
+ assertTrue(
+ UUID_V4.matcher(headers.get("x-prerender-request-id").asText()).matches(),
+ "Request-Id should be a UUID v4"
+ );
+ }
+
+ @Test
+ void browserRequest_emitsNoOutgoingRequest() throws Exception {
+ useToken(TOKEN);
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRequestURI()).thenReturn("/");
+ when(request.getParameter("_escaped_fragment_")).thenReturn(null);
+ when(request.getHeader("X-Bufferbot")).thenReturn(null);
+ when(request.getHeader("User-Agent")).thenReturn(BROWSER_UA);
+
+ filter.doFilter(request, response, chain);
+
+ assertEquals(0, recordedRequests().size());
+ }
+
+ @Test
+ void staticAsset_emitsNoOutgoingRequest() throws Exception {
+ useToken(TOKEN);
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRequestURI()).thenReturn("/styles.css");
+
+ filter.doFilter(request, response, chain);
+
+ assertEquals(0, recordedRequests().size());
+ }
+
+ @Test
+ void tokenOmitted_whenUnconfigured() throws Exception {
+ useToken(null);
+ stubBotRequest("/");
+
+ filter.doFilter(request, response, chain);
+
+ JsonNode headers = recordedRequests().get(0).get("headers");
+ assertFalse(headers.has("x-prerender-token"), "X-Prerender-Token must not be sent when unconfigured");
+ }
+
+ @Test
+ void requestId_isUniquePerOutgoingRequest() throws Exception {
+ useToken(TOKEN);
+ stubBotRequest("/");
+
+ filter.doFilter(request, response, chain);
+ filter.doFilter(request, response, chain);
+
+ JsonNode recorded = recordedRequests();
+ assertEquals(2, recorded.size());
+ assertNotEquals(
+ recorded.get(0).get("headers").get("x-prerender-request-id").asText(),
+ recorded.get(1).get("headers").get("x-prerender-request-id").asText()
+ );
+ }
+}
diff --git a/src/test/java/io/prerender/PrerenderFilterTest.java b/src/test/java/io/prerender/PrerenderFilterTest.java
new file mode 100644
index 0000000..3cd41ff
--- /dev/null
+++ b/src/test/java/io/prerender/PrerenderFilterTest.java
@@ -0,0 +1,163 @@
+package io.prerender;
+
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.net.http.HttpClient;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class PrerenderFilterTest {
+
+ private static final String BOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1)";
+ private static final String BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36";
+ private static final String PRERENDERED_HTML = "prerendered";
+
+ @RegisterExtension
+ static WireMockExtension wireMock = WireMockExtension.newInstance()
+ .options(wireMockConfig().dynamicPort())
+ .build();
+
+ @Mock private HttpServletRequest request;
+ @Mock private HttpServletResponse response;
+ @Mock private FilterChain chain;
+
+ private StringWriter responseWriter;
+ private PrerenderFilter filter;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ wireMock.resetAll();
+ responseWriter = new StringWriter();
+ lenient().when(response.getWriter()).thenReturn(new PrintWriter(responseWriter));
+ PrerenderConfig config = new PrerenderConfig(null, "http://localhost:" + wireMock.getPort());
+ filter = new PrerenderFilter(HttpClient.newHttpClient(), config);
+ }
+
+ @Test
+ void browserRequest_passesThrough() throws Exception {
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRequestURI()).thenReturn("/");
+ when(request.getParameter("_escaped_fragment_")).thenReturn(null);
+ when(request.getHeader("X-Bufferbot")).thenReturn(null);
+ when(request.getHeader("User-Agent")).thenReturn(BROWSER_UA);
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(request, response);
+ verify(response, never()).setStatus(anyInt());
+ }
+
+ @Test
+ void botRequest_receivesPrerenderedResponse() throws Exception {
+ wireMock.stubFor(get(anyUrl())
+ .willReturn(aResponse().withStatus(200).withBody(PRERENDERED_HTML)));
+
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRequestURI()).thenReturn("/");
+ when(request.getParameter("_escaped_fragment_")).thenReturn(null);
+ when(request.getHeader("X-Bufferbot")).thenReturn(null);
+ when(request.getHeader("User-Agent")).thenReturn(BOT_UA);
+ when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com/"));
+ when(request.getQueryString()).thenReturn(null);
+
+ filter.doFilter(request, response, chain);
+
+ verify(response).setStatus(200);
+ verify(chain, never()).doFilter(any(), any());
+ assertEquals(PRERENDERED_HTML, responseWriter.toString());
+ }
+
+ @Test
+ void botRequest_staticAsset_passesThrough() throws Exception {
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRequestURI()).thenReturn("/styles.css");
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(request, response);
+ verify(response, never()).setStatus(anyInt());
+ }
+
+ @Test
+ void escapedFragment_triggersPrerender() throws Exception {
+ wireMock.stubFor(get(anyUrl())
+ .willReturn(aResponse().withStatus(200).withBody(PRERENDERED_HTML)));
+
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRequestURI()).thenReturn("/");
+ when(request.getParameter("_escaped_fragment_")).thenReturn("");
+ when(request.getHeader("User-Agent")).thenReturn(BROWSER_UA);
+ when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com/"));
+ when(request.getQueryString()).thenReturn("_escaped_fragment_=");
+
+ filter.doFilter(request, response, chain);
+
+ verify(response).setStatus(200);
+ verify(chain, never()).doFilter(any(), any());
+ }
+
+ @Test
+ void xBufferbot_triggersPrerender() throws Exception {
+ wireMock.stubFor(get(anyUrl())
+ .willReturn(aResponse().withStatus(200).withBody(PRERENDERED_HTML)));
+
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRequestURI()).thenReturn("/");
+ when(request.getParameter("_escaped_fragment_")).thenReturn(null);
+ when(request.getHeader("X-Bufferbot")).thenReturn("true");
+ when(request.getHeader("User-Agent")).thenReturn(BROWSER_UA);
+ when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com/"));
+ when(request.getQueryString()).thenReturn(null);
+
+ filter.doFilter(request, response, chain);
+
+ verify(response).setStatus(200);
+ verify(chain, never()).doFilter(any(), any());
+ }
+
+ @Test
+ void postRequest_passesThrough() throws Exception {
+ when(request.getMethod()).thenReturn("POST");
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(request, response);
+ verify(response, never()).setStatus(anyInt());
+ }
+
+ @Test
+ void networkError_fallsBackToNormalResponse() throws Exception {
+ wireMock.stubFor(get(anyUrl())
+ .willReturn(aResponse().withFault(com.github.tomakehurst.wiremock.http.Fault.CONNECTION_RESET_BY_PEER)));
+
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRequestURI()).thenReturn("/");
+ when(request.getParameter("_escaped_fragment_")).thenReturn(null);
+ when(request.getHeader("X-Bufferbot")).thenReturn(null);
+ when(request.getHeader("User-Agent")).thenReturn(BOT_UA);
+ when(request.getRequestURL()).thenReturn(new StringBuffer("http://example.com/"));
+ when(request.getQueryString()).thenReturn(null);
+
+ filter.doFilter(request, response, chain);
+
+ verify(chain).doFilter(request, response);
+ }
+}