From 01f1fdf1886406ec541365edacaba98b0cdf8538 Mon Sep 17 00:00:00 2001 From: JFronny Date: Wed, 3 Apr 2024 17:28:49 +0200 Subject: [PATCH] feat: break apart HttpClient --- .../commons/http/client/HttpClient.java | 290 +----------------- .../jfronny/commons/http/client/Method.java | 9 + .../commons/http/client/RequestBuilder.java | 265 ++++++++++++++++ .../http/client/ResponseHandlingMode.java | 5 + 4 files changed, 292 insertions(+), 277 deletions(-) create mode 100644 commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/Method.java create mode 100644 commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/RequestBuilder.java create mode 100644 commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/ResponseHandlingMode.java diff --git a/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/HttpClient.java b/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/HttpClient.java index 9b0bde0..93235c3 100644 --- a/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/HttpClient.java +++ b/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/HttpClient.java @@ -1,25 +1,12 @@ package io.gitlab.jfronny.commons.http.client; -import io.gitlab.jfronny.commons.data.Either; -import io.gitlab.jfronny.commons.serialize.Serializer; - -import java.io.*; -import java.lang.reflect.Type; import java.net.*; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.net.http.HttpTimeoutException; -import java.nio.charset.StandardCharsets; import java.util.*; -import java.util.function.Predicate; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.stream.Stream; public class HttpClient { - private static String userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"; - private static final String PROXY_AUTH; - private static final java.net.http.HttpClient CLIENT; + protected static String userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"; + protected static final String PROXY_AUTH; + protected static final java.net.http.HttpClient CLIENT; static { // Enables HTTPS proxying @@ -53,274 +40,23 @@ public class HttpClient { userAgent = hostname; } - private enum Method { - GET, - POST, - PUT, - PATCH, - DELETE + public static RequestBuilder get(String url) throws URISyntaxException { + return new RequestBuilder(Method.GET, url); } - private enum ResponseHandlingMode { - IGNORE_ALL, HANDLE_REDIRECTS, HANDLE_ALL + public static RequestBuilder post(String url) throws URISyntaxException { + return new RequestBuilder(Method.POST, url); } - public static class Request { - private static final Predicate CURSEFORGE_API = Pattern.compile("(?:http(s)?://)?addons-ecs\\.forgesvc\\.net/api/+").asMatchPredicate(); - private final String url; - private final HttpRequest.Builder builder; - private Method method; - private int sent = 0; - private int retryAfterDefault = 5000; - private int retryAfterMax = 15000; - private int retryLimit = 3; - private ResponseHandlingMode responseHandlingMode = ResponseHandlingMode.HANDLE_ALL; - private List retryExceptions = null; - - private Request(Method method, String url) throws URISyntaxException { - this.url = url.replace(" ", "%20"); - this.builder = HttpRequest.newBuilder() - .uri(new URI(this.url)); - this.method = method; - userAgent(userAgent); - } - - public Request bearer(String token) { - builder.header("Authorization", "Bearer " + token); - - return this; - } - - public Request header(String name, String value) { - builder.header(name, value); - return this; - } - - public Request setHeader(String name, String value) { - builder.setHeader(name, value); - return this; - } - - public Request ignoreAll() { - responseHandlingMode = ResponseHandlingMode.IGNORE_ALL; - return this; - } - - public Request handleRedirects() { - responseHandlingMode = ResponseHandlingMode.HANDLE_REDIRECTS; - return this; - } - - public Request configureRetryAfter(int defaultDelay, int maxDelay) { - if (defaultDelay < 1) throw new IllegalArgumentException("defaultDelay must be greater than zero"); - if (maxDelay < defaultDelay) throw new IllegalArgumentException("maxDelay must be greater than or equal to defaultDelay"); - retryAfterDefault = defaultDelay; - retryAfterMax = maxDelay; - return this; - } - - public Request setRetryLimit(int limit) { - if (limit < 0) throw new IllegalArgumentException("limit must be greater than or zero"); - retryLimit = limit; - return this; - } - - public Request userAgent(String value) { - return setHeader("User-Agent", value); - } - - public Request bodyString(String string) { - builder.header("Content-Type", "text/plain"); - builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string)); - method = null; - - return this; - } - - public Request bodyForm(String string) { - builder.header("Content-Type", "application/x-www-form-urlencoded"); - builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string)); - method = null; - - return this; - } - - public Request bodyForm(Map entries) { - return bodyForm(entries.entrySet() - .stream() - .map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + '=' + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) - .collect(Collectors.joining("&"))); - } - - public Request bodyJson(String string) { - builder.header("Content-Type", "application/json"); - builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string)); - method = null; - - return this; - } - - public Request bodySerialized(Object object) throws IOException { - Serializer serializer = Serializer.getInstance(); - builder.header("Content-Type", serializer.getFormatMime()); - builder.method(method.name(), HttpRequest.BodyPublishers.ofString(serializer.serialize(object))); - method = null; - - return this; - } - - private HttpResponse _sendResponse(String accept, HttpResponse.BodyHandler responseBodyHandler) throws IOException { - sent++; - if (sent > retryLimit) { - IOException e = new IOException("Attempted to reconnect/redirect " + sent + " times, which is more than the permitted " + retryLimit + ". Stopping"); - if (retryExceptions != null) for (Exception ex : retryExceptions) e.addSuppressed(ex); - throw e; - } - builder.header("Accept", accept); - if (method != null) builder.method(method.name(), HttpRequest.BodyPublishers.noBody()); - if (PROXY_AUTH != null) builder.header("Proxy-Authorization", PROXY_AUTH); - - HttpResponse res; - try { - res = CLIENT.send(builder.build(), responseBodyHandler); - } catch (InterruptedException e) { - throw new IOException("Could not send request", e); - } catch (IOException e) { - String message = e.getMessage(); - if (message != null && message.contains("GOAWAY received")) { - return handleRetryAfter(accept, responseBodyHandler, retryAfterDefault); - } else throw new IOException("Could not send request", e); - } - if (responseHandlingMode == ResponseHandlingMode.IGNORE_ALL) return res; - if (res.statusCode() / 100 == 2) return res; - Optional location = res.headers().firstValue("location"); - Optional retryAfter = res.headers().firstValue("Retry-After").flatMap(s -> { - try { - return Optional.of(Integer.parseInt(s)); - } catch (NumberFormatException e) { - return Optional.empty(); - } - }); - final String exceptionSuffix = " (URL=" + url + ")"; - return switch (res.statusCode()) { - case 429 -> { - // Rate limit - yield handleRetryAfter(accept, responseBodyHandler, retryAfter.map(s -> s * 1000).orElse(retryAfterDefault)); - } - case 302, 307 -> { - // Redirect - if (location.isPresent() && method == Method.GET) { - try { - yield get(location.get())._sendResponse(accept, responseBodyHandler); - } catch (URISyntaxException e) { - throw new IOException("Could not follow redirect" + exceptionSuffix, e); - } - } - if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res; - throw new IOException("Unexpected redirect: " + res.statusCode() + exceptionSuffix); - } - case 500, 502, 503, 504, 507 -> { - if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res; - // CurseForge serverside error - if (CURSEFORGE_API.test(url)) { - yield handleRetryAfter(accept, responseBodyHandler, Math.min(1000, retryAfterMax)); - } - throw new IOException("Unexpected serverside error: " + res.statusCode() + exceptionSuffix); - } - case 404 -> { - if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res; - throw new FileNotFoundException("Didn't find anything under that url" + exceptionSuffix); - } - default -> { - if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res; - throw new IOException("Unexpected return method: " + res.statusCode() + exceptionSuffix); - } - }; - } - - private HttpResponse handleRetryAfter(String accept, HttpResponse.BodyHandler responseBodyHandler, int millis) throws IOException { - if (millis > retryAfterMax) throw new HttpTimeoutException("Wait time specified by Retry-After is too long: " + millis); - try { - Thread.sleep(millis); - } catch (InterruptedException e) { - throw new IOException("Could not sleep before resending request" + e); - } - return this._sendResponse(accept, responseBodyHandler); - } - - private T unwrap(HttpResponse response) throws IOException { - return response.body(); - } - - public void send() throws IOException { - unwrap(sendResponse()); - } - - public HttpResponse sendResponse() throws IOException { - return _sendResponse("*/*", HttpResponse.BodyHandlers.discarding()); - } - - public InputStream sendInputStream() throws IOException { - return unwrap(sendInputStreamResponse()); - } - - public HttpResponse sendInputStreamResponse() throws IOException { - return _sendResponse("*/*", HttpResponse.BodyHandlers.ofInputStream()); - } - - public Reader sendReader() throws IOException { - return unwrap(sendReaderResponse()); - } - - public HttpResponse sendReaderResponse() throws IOException { - return _sendResponse("*/*", ReaderHandler.of()); - } - - public String sendString() throws IOException { - return unwrap(sendStringResponse()); - } - - public HttpResponse sendStringResponse() throws IOException { - return _sendResponse("*/*", HttpResponse.BodyHandlers.ofString()); - } - - public Stream sendLines() throws IOException { - return unwrap(sendLinesResponse()); - } - - public HttpResponse> sendLinesResponse() throws IOException { - return _sendResponse("*/*", HttpResponse.BodyHandlers.ofLines()); - } - - public T sendSerialized(Type type) throws IOException { - Either tmp = unwrap(sendSerializedResponse(type)); - if (tmp == null) return null; - if (tmp.isLeft()) return tmp.left(); - throw new IOException("Could not deserialize", tmp.right()); - } - - public HttpResponse> sendSerializedResponse(Type type) throws IOException { - return _sendResponse(Serializer.getInstance().getFormatMime(), SerializedHandler.of(Serializer.getInstance(), type)); - } + public static RequestBuilder put(String url) throws URISyntaxException { + return new RequestBuilder(Method.PUT, url); } - public static Request get(String url) throws URISyntaxException { - return new Request(Method.GET, url); + public static RequestBuilder patch(String url) throws URISyntaxException { + return new RequestBuilder(Method.PATCH, url); } - public static Request post(String url) throws URISyntaxException { - return new Request(Method.POST, url); - } - - public static Request put(String url) throws URISyntaxException { - return new Request(Method.PUT, url); - } - - public static Request patch(String url) throws URISyntaxException { - return new Request(Method.PATCH, url); - } - - public static Request delete(String url) throws URISyntaxException { - return new Request(Method.DELETE, url); + public static RequestBuilder delete(String url) throws URISyntaxException { + return new RequestBuilder(Method.DELETE, url); } } diff --git a/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/Method.java b/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/Method.java new file mode 100644 index 0000000..871754e --- /dev/null +++ b/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/Method.java @@ -0,0 +1,9 @@ +package io.gitlab.jfronny.commons.http.client; + +enum Method { + GET, + POST, + PUT, + PATCH, + DELETE +} diff --git a/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/RequestBuilder.java b/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/RequestBuilder.java new file mode 100644 index 0000000..a19e0a4 --- /dev/null +++ b/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/RequestBuilder.java @@ -0,0 +1,265 @@ +package io.gitlab.jfronny.commons.http.client; + +import io.gitlab.jfronny.commons.data.Either; +import io.gitlab.jfronny.commons.serialize.Serializer; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.lang.reflect.Type; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class RequestBuilder { + private static final Predicate CURSEFORGE_API = Pattern.compile("(?:http(s)?://)?addons-ecs\\.forgesvc\\.net/api/+").asMatchPredicate(); + private final String url; + private final HttpRequest.Builder builder; + private Method method; + private int sent = 0; + private int retryAfterDefault = 5000; + private int retryAfterMax = 15000; + private int retryLimit = 3; + private ResponseHandlingMode responseHandlingMode = ResponseHandlingMode.HANDLE_ALL; + private List retryExceptions = null; + + protected RequestBuilder(Method method, String url) throws URISyntaxException { + this.url = url.replace(" ", "%20"); + this.builder = HttpRequest.newBuilder() + .uri(new URI(this.url)); + this.method = method; + userAgent(HttpClient.userAgent); + } + + public RequestBuilder bearer(String token) { + builder.header("Authorization", "Bearer " + token); + + return this; + } + + public RequestBuilder header(String name, String value) { + builder.header(name, value); + return this; + } + + public RequestBuilder setHeader(String name, String value) { + builder.setHeader(name, value); + return this; + } + + public RequestBuilder ignoreAll() { + responseHandlingMode = ResponseHandlingMode.IGNORE_ALL; + return this; + } + + public RequestBuilder handleRedirects() { + responseHandlingMode = ResponseHandlingMode.HANDLE_REDIRECTS; + return this; + } + + public RequestBuilder configureRetryAfter(int defaultDelay, int maxDelay) { + if (defaultDelay < 1) throw new IllegalArgumentException("defaultDelay must be greater than zero"); + if (maxDelay < defaultDelay) + throw new IllegalArgumentException("maxDelay must be greater than or equal to defaultDelay"); + retryAfterDefault = defaultDelay; + retryAfterMax = maxDelay; + return this; + } + + public RequestBuilder setRetryLimit(int limit) { + if (limit < 0) throw new IllegalArgumentException("limit must be greater than or zero"); + retryLimit = limit; + return this; + } + + public RequestBuilder userAgent(String value) { + return setHeader("User-Agent", value); + } + + public RequestBuilder bodyString(String string) { + builder.header("Content-Type", "text/plain"); + builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string)); + method = null; + + return this; + } + + public RequestBuilder bodyForm(String string) { + builder.header("Content-Type", "application/x-www-form-urlencoded"); + builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string)); + method = null; + + return this; + } + + public RequestBuilder bodyForm(Map entries) { + return bodyForm(entries.entrySet() + .stream() + .map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + '=' + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&"))); + } + + public RequestBuilder bodyJson(String string) { + builder.header("Content-Type", "application/json"); + builder.method(method.name(), HttpRequest.BodyPublishers.ofString(string)); + method = null; + + return this; + } + + public RequestBuilder bodySerialized(Object object) throws IOException { + Serializer serializer = Serializer.getInstance(); + builder.header("Content-Type", serializer.getFormatMime()); + builder.method(method.name(), HttpRequest.BodyPublishers.ofString(serializer.serialize(object))); + method = null; + + return this; + } + + private HttpResponse _sendResponse(String accept, HttpResponse.BodyHandler responseBodyHandler) throws IOException { + sent++; + if (sent > retryLimit) { + IOException e = new IOException("Attempted to reconnect/redirect " + sent + " times, which is more than the permitted " + retryLimit + ". Stopping"); + if (retryExceptions != null) for (Exception ex : retryExceptions) e.addSuppressed(ex); + throw e; + } + builder.header("Accept", accept); + if (method != null) builder.method(method.name(), HttpRequest.BodyPublishers.noBody()); + if (HttpClient.PROXY_AUTH != null) builder.header("Proxy-Authorization", HttpClient.PROXY_AUTH); + + HttpResponse res; + try { + res = HttpClient.CLIENT.send(builder.build(), responseBodyHandler); + } catch (InterruptedException e) { + throw new IOException("Could not send request", e); + } catch (IOException e) { + String message = e.getMessage(); + if (message != null && message.contains("GOAWAY received")) { + return handleRetryAfter(accept, responseBodyHandler, retryAfterDefault); + } else throw new IOException("Could not send request", e); + } + if (responseHandlingMode == ResponseHandlingMode.IGNORE_ALL) return res; + if (res.statusCode() / 100 == 2) return res; + Optional location = res.headers().firstValue("location"); + Optional retryAfter = res.headers().firstValue("Retry-After").flatMap(s -> { + try { + return Optional.of(Integer.parseInt(s)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + }); + final String exceptionSuffix = " (URL=" + url + ")"; + return switch (res.statusCode()) { + case 429 -> { + // Rate limit + yield handleRetryAfter(accept, responseBodyHandler, retryAfter.map(s -> s * 1000).orElse(retryAfterDefault)); + } + case 302, 307 -> { + // Redirect + if (location.isPresent() && method == Method.GET) { + try { + yield HttpClient.get(location.get())._sendResponse(accept, responseBodyHandler); + } catch (URISyntaxException e) { + throw new IOException("Could not follow redirect" + exceptionSuffix, e); + } + } + if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res; + throw new IOException("Unexpected redirect: " + res.statusCode() + exceptionSuffix); + } + case 500, 502, 503, 504, 507 -> { + if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res; + // CurseForge serverside error + if (CURSEFORGE_API.test(url)) { + yield handleRetryAfter(accept, responseBodyHandler, Math.min(1000, retryAfterMax)); + } + throw new IOException("Unexpected serverside error: " + res.statusCode() + exceptionSuffix); + } + case 404 -> { + if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res; + throw new FileNotFoundException("Didn't find anything under that url" + exceptionSuffix); + } + default -> { + if (responseHandlingMode == ResponseHandlingMode.HANDLE_REDIRECTS) yield res; + throw new IOException("Unexpected return method: " + res.statusCode() + exceptionSuffix); + } + }; + } + + private HttpResponse handleRetryAfter(String accept, HttpResponse.BodyHandler responseBodyHandler, int millis) throws IOException { + if (millis > retryAfterMax) + throw new HttpTimeoutException("Wait time specified by Retry-After is too long: " + millis); + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + throw new IOException("Could not sleep before resending request" + e); + } + return this._sendResponse(accept, responseBodyHandler); + } + + private T unwrap(HttpResponse response) throws IOException { + return response.body(); + } + + public void send() throws IOException { + unwrap(sendResponse()); + } + + public HttpResponse sendResponse() throws IOException { + return _sendResponse("*/*", HttpResponse.BodyHandlers.discarding()); + } + + public InputStream sendInputStream() throws IOException { + return unwrap(sendInputStreamResponse()); + } + + public HttpResponse sendInputStreamResponse() throws IOException { + return _sendResponse("*/*", HttpResponse.BodyHandlers.ofInputStream()); + } + + public Reader sendReader() throws IOException { + return unwrap(sendReaderResponse()); + } + + public HttpResponse sendReaderResponse() throws IOException { + return _sendResponse("*/*", ReaderHandler.of()); + } + + public String sendString() throws IOException { + return unwrap(sendStringResponse()); + } + + public HttpResponse sendStringResponse() throws IOException { + return _sendResponse("*/*", HttpResponse.BodyHandlers.ofString()); + } + + public Stream sendLines() throws IOException { + return unwrap(sendLinesResponse()); + } + + public HttpResponse> sendLinesResponse() throws IOException { + return _sendResponse("*/*", HttpResponse.BodyHandlers.ofLines()); + } + + public T sendSerialized(Type type) throws IOException { + Either tmp = unwrap(sendSerializedResponse(type)); + if (tmp == null) return null; + if (tmp.isLeft()) return tmp.left(); + throw new IOException("Could not deserialize", tmp.right()); + } + + public HttpResponse> sendSerializedResponse(Type type) throws IOException { + return _sendResponse(Serializer.getInstance().getFormatMime(), SerializedHandler.of(Serializer.getInstance(), type)); + } +} diff --git a/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/ResponseHandlingMode.java b/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/ResponseHandlingMode.java new file mode 100644 index 0000000..c957f62 --- /dev/null +++ b/commons-http-client/src/main/java/io/gitlab/jfronny/commons/http/client/ResponseHandlingMode.java @@ -0,0 +1,5 @@ +package io.gitlab.jfronny.commons.http.client; + +enum ResponseHandlingMode { + IGNORE_ALL, HANDLE_REDIRECTS, HANDLE_ALL +}