Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.env
*.log
*.db
*.iml
*.tgz
Expand Down
18 changes: 18 additions & 0 deletions .run/Application.run.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Application" type="Application" factoryName="Application" nameIsGenerated="true">
<option name="envFilePaths">
<option value="$PROJECT_DIR$/webmention-service/.env" />
</option>
<option name="MAIN_CLASS_NAME" value="no.clueless.webmention.service.Application" />
<module name="webmention-service" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="no.clueless.webmention.service.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>
35 changes: 35 additions & 0 deletions interceptable-http-client/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>no.clueless</groupId>
<artifactId>webmention-parent</artifactId>
<version>0.0.1-alpha.5-SNAPSHOT</version>
</parent>

<artifactId>interceptable-http-client</artifactId>

<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package no.clueless.interceptable_http_client;

import org.jetbrains.annotations.NotNull;

import java.net.http.HttpClient;
import java.net.http.HttpRequest;

public interface HttpRequestInterceptor {
HttpRequest intercept(@NotNull HttpRequest originalRequest, @NotNull HttpClient httpClient);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package no.clueless.interceptable_http_client;

import org.jetbrains.annotations.NotNull;

import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.util.*;

public class HttpRequestInterceptors implements Iterable<HttpRequestInterceptor> {
@NotNull
private final LinkedHashSet<HttpRequestInterceptor> httpRequestInterceptors;

public HttpRequestInterceptors() {
this.httpRequestInterceptors = new LinkedHashSet<>();
}

@NotNull
public HttpRequestInterceptors add(@NotNull HttpRequestInterceptor httpRequestInterceptor) {
httpRequestInterceptors.add(httpRequestInterceptor);
return this;
}

public HttpRequest intercept(@NotNull HttpRequest request, @NotNull HttpClient httpClient) {
for(var interceptor : httpRequestInterceptors) {
request = interceptor.intercept(request, httpClient);
}
return request;
}

@Override
public @NotNull Iterator<HttpRequestInterceptor> iterator() {
return httpRequestInterceptors.iterator();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package no.clueless.interceptable_http_client;

import org.jetbrains.annotations.NotNull;

import java.net.http.HttpResponse;

public interface HttpResponseInterceptor {
void intercept(@NotNull HttpResponse<?> response);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package no.clueless.interceptable_http_client;

import org.jetbrains.annotations.NotNull;

import java.net.http.HttpResponse;
import java.util.Iterator;
import java.util.LinkedHashSet;

public class HttpResponseInterceptors implements Iterable<HttpResponseInterceptor> {
@NotNull
private final LinkedHashSet<HttpResponseInterceptor> httpResponseInterceptors = new LinkedHashSet<>();

@NotNull
public HttpResponseInterceptors add(@NotNull HttpResponseInterceptor httpResponseInterceptor) {
httpResponseInterceptors.add(httpResponseInterceptor);
return this;
}

public void intercept(@NotNull HttpResponse<?> httpResponse) {
for(var interceptor : httpResponseInterceptors) {
interceptor.intercept(httpResponse);
}
}

@Override
public @NotNull Iterator<HttpResponseInterceptor> iterator() {
return httpResponseInterceptors.iterator();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package no.clueless.interceptable_http_client;

import org.jetbrains.annotations.NotNull;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import java.io.IOException;
import java.net.Authenticator;
import java.net.CookieHandler;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;

public class InterceptableHttpClient extends HttpClient {
@NotNull
private final HttpClient delegate;
@NotNull
private final HttpRequestInterceptors httpRequestInterceptors = new HttpRequestInterceptors();
@NotNull
private final HttpResponseInterceptors httpResponseInterceptors = new HttpResponseInterceptors();

public InterceptableHttpClient(@NotNull HttpClient delegate) {
this.delegate = delegate;
}

@NotNull
public InterceptableHttpClient addInterceptor(@NotNull HttpRequestInterceptor httpRequestInterceptor) {
httpRequestInterceptors.add(httpRequestInterceptor);
return this;
}

@NotNull
public InterceptableHttpClient addInterceptor(@NotNull HttpResponseInterceptor httpResponseInterceptor) {
httpResponseInterceptors.add(httpResponseInterceptor);
return this;
}

@Override
public Optional<CookieHandler> cookieHandler() {
return delegate.cookieHandler();
}

@Override
public Optional<Duration> connectTimeout() {
return delegate.connectTimeout();
}

@Override
public Redirect followRedirects() {
return delegate.followRedirects();
}

@Override
public Optional<ProxySelector> proxy() {
return delegate.proxy();
}

@Override
public SSLContext sslContext() {
return delegate.sslContext();
}

@Override
public SSLParameters sslParameters() {
return delegate.sslParameters();
}

@Override
public Optional<Authenticator> authenticator() {
return delegate.authenticator();
}

@Override
public Version version() {
return delegate.version();
}

@Override
public Optional<Executor> executor() {
return delegate.executor();
}

@Override
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException, InterruptedException {
request = httpRequestInterceptors.intercept(request, delegate);
var response = delegate.send(request, responseBodyHandler);
httpResponseInterceptors.intercept(response);
return response;
}

@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
request = httpRequestInterceptors.intercept(request, delegate);
return delegate.sendAsync(request, responseBodyHandler);
}

@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
request = httpRequestInterceptors.intercept(request, delegate);
return delegate.sendAsync(request, responseBodyHandler, pushPromiseHandler);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package no.clueless.interceptable_http_client;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.ZonedDateTime;
import java.util.Optional;
import java.util.function.Supplier;

public class OAuth2ClientCredentialsHttpRequestInterceptor implements HttpRequestInterceptor {
private final int priority;
@NotNull
private final Supplier<Credentials> credentialsProvider;
@NotNull
private final String tokenUrl;
@NotNull
private final Supplier<JsonMapper> jsonMapperProvider;

@Nullable
private ZonedDateTime lastTokenFetch;
@Nullable
private TokenResponse tokenResponse;

public OAuth2ClientCredentialsHttpRequestInterceptor(
int priority,
@NotNull Supplier<Credentials> credentialsProvider,
@NotNull String tokenUrl,
@NotNull Supplier<JsonMapper> jsonMapperProvider
) {
if (priority < 0) {
throw new IllegalArgumentException("priority must be >= 0");
}
this.priority = priority;
this.credentialsProvider = credentialsProvider;
this.tokenUrl = tokenUrl;
this.jsonMapperProvider = jsonMapperProvider;
}

@Override
public HttpRequest intercept(@NotNull HttpRequest originalRequest, @NotNull HttpClient httpClient) {
if (tokenResponse == null || lastTokenFetch == null || ZonedDateTime.now().isAfter(lastTokenFetch.plusSeconds(tokenResponse.expiresIn - 10))) {
var credentials = credentialsProvider.get();
var httpRequest = HttpRequest.newBuilder()
.uri(URI.create(tokenUrl))
.POST(HttpRequest.BodyPublishers.ofString("grant_type=client_credentials&client_id=" + credentials.clientId + "&client_secret=" + credentials.clientSecret))
.build();

HttpResponse<String> httpResponse;
try {
httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
} catch (ConnectException e) {
throw new RuntimeException("Failed to connect to " + tokenUrl, e);
} catch (IOException | InterruptedException e) {
throw new RuntimeException("HTTP request to " + tokenUrl + " failed", e);
}

if (httpResponse.statusCode() != 200) {
throw new RuntimeException("Failed to fetch access token: " + httpResponse.body());
}

try {
tokenResponse = jsonMapperProvider.get().readValue(httpResponse.body(), TokenResponse.class);
lastTokenFetch = ZonedDateTime.now();
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to parse access token response", e);
}
}

if (tokenResponse == null) {
throw new IllegalStateException("Failed to fetch access token");
}

return HttpRequest.newBuilder(originalRequest, (name, value) -> true)
.header("Authorization", "Bearer " + tokenResponse.accessToken)
.build();
}

public record Credentials(@NotNull String clientId, @NotNull String clientSecret) {
@NotNull
public static Credentials fromEnvironment(@NotNull String clientIdKey, @NotNull String clientSecretKey) {
var clientId = Optional.ofNullable(System.getenv(clientIdKey)).filter(s -> !s.isBlank()).orElseThrow(() -> new IllegalArgumentException("Missing environment variable: " + clientIdKey));
var clientSecret = Optional.ofNullable(System.getenv(clientSecretKey)).filter(s -> !s.isBlank()).orElseThrow(() -> new IllegalArgumentException("Missing environment variable: " + clientSecretKey));
return new Credentials(clientId, clientSecret);
}
}

private record TokenResponse(
@NotNull @JsonProperty("access_token") String accessToken,
@NotNull @JsonProperty("token_type") String tokenType,
@JsonProperty("expires_in") long expiresIn,
@Nullable @JsonProperty("scope") String scope
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package no.clueless.interceptable_http_client;

import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.http.HttpClient;
import java.net.http.HttpRequest;

public class Slf4jHttpRequestInterceptor implements HttpRequestInterceptor {
private static final Logger log = LoggerFactory.getLogger(Slf4jHttpRequestInterceptor.class);

@Override
public HttpRequest intercept(@NotNull HttpRequest originalRequest, @NotNull HttpClient httpClient) {
log.debug("Sending request: {}", originalRequest);
return originalRequest;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package no.clueless.interceptable_http_client;

import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.http.HttpResponse;

public class Slf4jHttpResponseInterceptor implements HttpResponseInterceptor {
private static final Logger log = LoggerFactory.getLogger(Slf4jHttpResponseInterceptor.class);

@Override
public void intercept(@NotNull HttpResponse<?> response) {
if(log.isDebugEnabled()) {
log.debug("Received response: {}", response);
}
}
}
Loading