Files
netneutrality/src/main/java/me/bobert/netneutrality/http/WebModule.java
T

367 lines
12 KiB
Java

package me.bobert.netneutrality.http;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.google.gson.Gson;
import io.sentry.Breadcrumb;
import io.sentry.Sentry;
import me.bobert.netneutrality.utils.Settings;
import okhttp3.Headers;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class WebModule {
private enum Method {
PING, GET, POST
}
private static final String USER_AGENT = "Net Neutrality/%s (Discord bot, discord.gg/trAqhQ5Wsr)";
private static final Logger log = LoggerFactory.getLogger(WebModule.class);
private static final Map<String, CachedResponse> cache = Collections.synchronizedMap(
new LinkedHashMap<>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CachedResponse> eldest) {
return size() > 50; // Limit cache to 50 entries
}
}
);
private final Method method;
private final String url;
private final long cacheExpirationMillis;
private final OkHttpClient client;
private final Map<String, String> headers;
private final Request request;
private CachedResponse cachedResponse;
private WebModule(Builder builder) {
this.method = builder.method;
this.url = builder.url;
this.client = builder.client;
this.headers = builder.headers;
this.cacheExpirationMillis = builder.timeUnit.toMillis(builder.timeout);
this.request = builder.request;
}
/**
* Invalidates a cached response for the specified URL.
* <p>
* This method removes any cached responses matching the given URL.
*
* @param url the URL of the cached response to invalidate
*/
public static void invalidateCache(String url) {
cache.keySet().removeIf(s -> s.contains(url));
}
/**
* Sends the HTTP request based on the configuration and retrieves the response.
* <p>
* If the request is a GET request and caching is enabled, the response will be cached.
* Cached responses are reused until they expire.
* <p>
* POST requests do not support caching.
*
* @return the {@code WebModule} instance, allowing further interaction with the response
* @throws RuntimeException if the request fails
*/
public WebModule request() {
String cacheKey = createCacheKey(method, url, headers);
if (method == Method.GET && cacheKey != null) {
CachedResponse cachedResponse = cache.get(cacheKey);
if (cachedResponse != null && !cachedResponse.isExpired()) {
log.debug("Retrieving cached response: {}", cacheKey);
this.cachedResponse = cachedResponse;
return this;
}
}
long startTime = System.currentTimeMillis();
try (Response response = client.newCall(request).execute()) {
long elapsedTime = System.currentTimeMillis() - startTime;
Sentry.addBreadcrumb(Breadcrumb.http(url, method.name(), response.code()));
String body = response.body() != null ? response.body().string() : null;
this.cachedResponse = new CachedResponse(
response.code(),
response.headers(),
elapsedTime,
body,
System.currentTimeMillis() + cacheExpirationMillis,
null
);
} catch (IOException e) {
long elapsedTime = System.currentTimeMillis() - startTime;
log.error("Request failed: {}", e.getMessage(), e);
Sentry.captureException(e);
this.cachedResponse = new CachedResponse(
0, // Status code 0 indicates a failure
new Headers.Builder().build(),
elapsedTime,
null,
System.currentTimeMillis() + cacheExpirationMillis,
e.getMessage()
);
}
if (method == Method.GET && cacheKey != null) {
cache.put(cacheKey, this.cachedResponse);
}
return this;
}
private String createCacheKey(Method method, String url, Map<String, String> headers) {
if (method == Method.POST) {
return null; // no cache for post requests
}
StringBuilder keyBuilder = new StringBuilder(method.name()).append(":").append(url);
if (headers != null) {
headers.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> keyBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append(";"));
}
return keyBuilder.toString();
}
public JSONObject getAsObject() {
return cachedResponse.getBody()
.map(JSONObject::new)
.orElseThrow(() -> new IllegalStateException("No body available for this response"));
}
public JSONArray getAsArray() {
return cachedResponse.getBody()
.map(JSONArray::new)
.orElseThrow(() -> new IllegalStateException("No body available for this response"));
}
public String getRawData() {
return cachedResponse.getBody().orElse(null);
}
public boolean isSuccess() {
return cachedResponse != null && cachedResponse.getStatusCode() >= 200 && cachedResponse.getStatusCode() < 400;
}
public static class Builder {
private Request request;
private final String url;
private Method method = Method.GET;
private long timeout = 5;
private TimeUnit timeUnit = TimeUnit.MINUTES;
private OkHttpClient client;
private long connectTimeout;
private long readTimeout;
private long writeTimeout;
private final Map<String, String> headers = new HashMap<>();
private final Map<String, String> params = new HashMap<>();
/**
* Creates a new {@code Builder} instance for the specified URL.
*
* @param url the URL for the HTTP request
*/
public Builder(String url) {
this.url = url;
}
public Builder ping() {
this.method = Method.PING;
return this;
}
/**
* Configures the request to use the HTTP GET method.
*
* @return the current {@code Builder} instance for chaining
*/
public Builder get() {
this.method = Method.GET;
return this;
}
/**
* Configures the request to use the HTTP POST method and sets the request parameters.
*
* @param params a map of key-value pairs to include in the POST request body
* @return the current {@code Builder} instance for chaining
*/
public Builder post(Map<String, String> params) {
this.params.putAll(params);
this.method = Method.POST;
return this;
}
/**
* Adds a custom header to the request.
*
* @param key the name of the header
* @param value the value of the header
* @return the current {@code Builder} instance for chaining
*/
public Builder addHeader(String key, String value) {
this.headers.put(key, value);
return this;
}
/**
* Configures caching for the request.
*
* @param timeout the cache expiration duration
* @param timeUnit the unit of time for the cache expiration
* @return the current {@code Builder} instance for chaining
*/
public Builder cache(long timeout, TimeUnit timeUnit) {
this.timeout = timeout;
this.timeUnit = timeUnit;
return this;
}
public Builder timeout(long timeout, TimeUnit timeUnit) {
this.connectTimeout = timeUnit.toMillis(timeout);
this.readTimeout = timeUnit.toMillis(timeout);
this.writeTimeout = timeUnit.toMillis(timeout);
return this;
}
/**
* Sets the connection timeout for the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder connectTimeout(long timeout, TimeUnit timeUnit) {
this.connectTimeout = timeUnit.toMillis(timeout);
return this;
}
/**
* Sets the read timeout for the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder readTimeout(long timeout, TimeUnit timeUnit) {
this.readTimeout = timeUnit.toMillis(timeout);
return this;
}
/**
* Sets the write timeout for the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder writeTimeout(long timeout, TimeUnit timeUnit) {
this.writeTimeout = timeUnit.toMillis(timeout);
return this;
}
private Request createRequest() {
Request.Builder builder = new Request.Builder().url(url);
builder.addHeader("User-Agent", String.format(USER_AGENT, Settings.getVersion()));
headers.forEach(builder::addHeader);
if (method == Method.PING) {
builder.head();
}
if (method == Method.GET) {
builder.get();
}
if (method == Method.POST && !params.isEmpty()) {
RequestBody body = RequestBody.create(new Gson().toJson(params), MediaType.parse("application/json; charset=utf-8"));
builder.post(body);
}
return builder.build();
}
/**
* Builds and returns a configured {@link WebModule} instance.
*
* @return a new {@code WebModule} instance
*/
public WebModule build() {
this.client = new OkHttpClient.Builder()
.connectTimeout(connectTimeout, TimeUnit.MILLISECONDS)
.writeTimeout(writeTimeout, TimeUnit.MILLISECONDS)
.readTimeout(readTimeout, TimeUnit.MILLISECONDS)
.build();
this.request = createRequest();
return new WebModule(this);
}
}
private static class CachedResponse {
private final int statusCode;
private final Headers headers;
private final long elapsedTime;
private final String body;
private final long expirationTime;
private final String errorMessage;
public CachedResponse(int statusCode, Headers headers, long elapsedTime, String body, long expirationTime, String errorMessage) {
this.statusCode = statusCode;
this.headers = headers;
this.elapsedTime = elapsedTime;
this.body = body;
this.expirationTime = expirationTime;
this.errorMessage = errorMessage;
}
public boolean isExpired() {
return System.currentTimeMillis() > expirationTime;
}
public int getStatusCode() {
return statusCode;
}
public Headers getHeaders() {
return headers;
}
public long getElapsedTime() {
return elapsedTime;
}
public Optional<String> getBody() {
return Optional.ofNullable(body);
}
public Optional<String> getErrorMessage() {
return Optional.ofNullable(errorMessage);
}
}
}