HttpFetchService.java
package net.sasasin.sreader.service;
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.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import net.sasasin.sreader.config.FeedReaderProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class HttpFetchService {
private static final Pattern CHARSET_PATTERN =
Pattern.compile("charset=([^;]+)", Pattern.CASE_INSENSITIVE);
private final FeedReaderProperties properties;
private final HttpClient client;
@Autowired
public HttpFetchService(FeedReaderProperties properties) {
this(properties, createClient(properties));
}
HttpFetchService(FeedReaderProperties properties, HttpClient client) {
this.properties = properties;
this.client = client;
}
private static HttpClient createClient(FeedReaderProperties properties) {
return HttpClient.newBuilder()
.connectTimeout(properties.http().connectTimeout())
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
public FetchedResource get(URI uri) throws IOException, InterruptedException {
HttpRequest request = baseRequest(uri).GET().timeout(properties.http().readTimeout()).build();
HttpResponse<byte[]> response = sendWithRetry(request);
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException("GET " + uri + " returned HTTP " + response.statusCode());
}
return new FetchedResource(response.uri(), decode(response));
}
public URI resolveRedirect(URI uri) {
try {
HttpRequest request =
baseRequest(uri)
.method("HEAD", HttpRequest.BodyPublishers.noBody())
.timeout(Duration.ofSeconds(Math.max(1, properties.http().readTimeout().toSeconds())))
.build();
HttpResponse<Void> response = client.send(request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() >= 200 && response.statusCode() < 400) {
return response.uri();
}
} catch (IOException | InterruptedException | IllegalArgumentException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
}
return uri;
}
private HttpRequest.Builder baseRequest(URI uri) {
return HttpRequest.newBuilder(uri)
.header("User-Agent", properties.http().userAgent())
.header("Accept", "*/*");
}
private HttpResponse<byte[]> sendWithRetry(HttpRequest request)
throws IOException, InterruptedException {
IOException last = null;
int attempts = Math.max(1, properties.http().retryCount() + 1);
for (int i = 0; i < attempts; i++) {
try {
return client.send(request, HttpResponse.BodyHandlers.ofByteArray());
} catch (IOException e) {
last = e;
}
}
throw last;
}
private String decode(HttpResponse<byte[]> response) {
Charset charset =
response
.headers()
.firstValue("content-type")
.flatMap(this::charsetFromContentType)
.orElse(StandardCharsets.UTF_8);
return new String(response.body(), charset);
}
private Optional<Charset> charsetFromContentType(String contentType) {
Matcher matcher = CHARSET_PATTERN.matcher(contentType);
if (!matcher.find()) {
return Optional.empty();
}
try {
return Optional.of(
Charset.forName(matcher.group(1).trim().replace("\"", "").toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
public record FetchedResource(URI uri, String body) {}
}