Skip to content

Commit 9076e15

Browse files
author
wlanboy
committed
Redirect-Chain Support
1 parent cd1ea43 commit 9076e15

3 files changed

Lines changed: 144 additions & 41 deletions

File tree

src/main/java/com/wlanboy/javahttpclient/client/ClientService.java

Lines changed: 109 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
import java.net.http.HttpResponse;
1111
import java.net.http.HttpResponse.BodyHandlers;
1212
import java.time.Duration;
13+
import java.util.ArrayList;
14+
import java.util.LinkedHashMap;
15+
import java.util.List;
16+
import java.util.Map;
1317
import java.util.Set;
1418

1519
import org.slf4j.Logger;
@@ -26,66 +30,69 @@ public class ClientService {
2630
private static final Set<String> BAD_HEADERS = Set.of(
2731
"host", "content-length", "connection", "accept-encoding", "upgrade"
2832
);
33+
private static final Set<Integer> REDIRECT_CODES = Set.of(301, 302, 303, 307, 308);
34+
private static final int MAX_REDIRECTS = 10;
35+
2936
private final HttpClient client;
3037

3138
public ClientService() {
3239
client = HttpClient.newBuilder()
3340
.version(Version.HTTP_2)
34-
.followRedirects(Redirect.NORMAL)
41+
.followRedirects(Redirect.NEVER)
3542
.connectTimeout(Duration.ofSeconds(10))
3643
.build();
3744
}
3845

3946
public ResponseEntity<String> sendRequest(JavaHttpRequest requestData, HttpHeaders incomingHeaders) {
4047
try {
41-
boolean hasBody = requestData.body() != null && !requestData.body().isBlank();
42-
43-
HttpRequest.BodyPublisher bodyPublisher = hasBody
44-
? HttpRequest.BodyPublishers.ofString(requestData.body())
45-
: HttpRequest.BodyPublishers.noBody();
46-
47-
HttpRequest.Builder builder = HttpRequest.newBuilder()
48-
.uri(URI.create(requestData.url()))
49-
.timeout(Duration.ofSeconds(30))
50-
.method(requestData.method().name(), bodyPublisher);
51-
52-
if (hasBody && requestData.contentType() != null && !requestData.contentType().isBlank()) {
53-
builder.header("Content-Type", requestData.contentType());
54-
} else if (hasBody) {
55-
builder.header("Content-Type", "application/json");
56-
}
57-
58-
if (requestData.copyHeaders() && incomingHeaders != null) {
59-
incomingHeaders.forEach((key, value) -> {
60-
if (!BAD_HEADERS.contains(key.toLowerCase()) && !value.isEmpty()) {
61-
try {
62-
builder.header(key, value.get(0));
63-
} catch (IllegalArgumentException e) {
64-
logger.warn("Header {} ist geschützt und wurde übersprungen", key);
65-
}
66-
}
67-
});
48+
List<Map<String, Object>> redirectChain = new ArrayList<>();
49+
URI currentUri = URI.create(requestData.url());
50+
String currentMethod = requestData.method().name();
51+
String currentBody = requestData.body();
52+
53+
// Erster Request mit allen Headern
54+
HttpResponse<String> response = client.send(
55+
buildRequest(currentUri, currentMethod, currentBody, requestData, incomingHeaders),
56+
BodyHandlers.ofString());
57+
58+
// Redirects manuell verfolgen
59+
while (REDIRECT_CODES.contains(response.statusCode()) && redirectChain.size() < MAX_REDIRECTS) {
60+
String location = response.headers().firstValue("location").orElse(null);
61+
if (location == null) break;
62+
63+
URI nextUri = currentUri.resolve(location);
64+
65+
Map<String, Object> step = new LinkedHashMap<>();
66+
step.put("from", currentUri.toString());
67+
step.put("status", response.statusCode());
68+
step.put("to", nextUri.toString());
69+
step.put("proto", response.version() == Version.HTTP_2 ? "HTTP/2" : "HTTP/1.1");
70+
redirectChain.add(step);
71+
72+
currentUri = nextUri;
73+
// 307/308: Methode + Body beibehalten; alle anderen → GET ohne Body
74+
if (response.statusCode() != 307 && response.statusCode() != 308) {
75+
currentMethod = "GET";
76+
currentBody = null;
77+
}
78+
79+
// Folge-Requests ohne originale Browser-Header (kein Auth-Leak)
80+
response = client.send(
81+
buildRequest(currentUri, currentMethod, currentBody, null, null),
82+
BodyHandlers.ofString());
6883
}
6984

70-
if (requestData.customHeaders() != null) {
71-
requestData.customHeaders().forEach((key, value) -> {
72-
if (key != null && !key.isBlank() && !BAD_HEADERS.contains(key.toLowerCase())) {
73-
builder.header(key, value);
74-
}
75-
});
76-
}
77-
78-
HttpResponse<String> response = client.send(builder.build(), BodyHandlers.ofString());
79-
8085
HttpHeaders responseHeaders = new HttpHeaders();
8186
response.headers().map().forEach(responseHeaders::addAll);
82-
8387
String protocolVersion = response.version() == Version.HTTP_2 ? "HTTP/2" : "HTTP/1.1";
8488

8589
return ResponseEntity.status(response.statusCode())
8690
.headers(h -> {
8791
h.addAll(responseHeaders);
8892
h.set("X-Protocol-Version", protocolVersion);
93+
if (!redirectChain.isEmpty()) {
94+
h.set("X-Redirect-Chain", serializeChain(redirectChain));
95+
}
8996
})
9097
.body(response.body());
9198

@@ -103,11 +110,72 @@ public ResponseEntity<String> sendRequest(JavaHttpRequest requestData, HttpHeade
103110
}
104111
}
105112

113+
private HttpRequest buildRequest(URI uri, String method, String body,
114+
JavaHttpRequest requestData, HttpHeaders incomingHeaders) {
115+
116+
boolean hasBody = body != null && !body.isBlank()
117+
&& !method.equals("GET") && !method.equals("HEAD") && !method.equals("OPTIONS");
118+
119+
HttpRequest.BodyPublisher bodyPublisher = hasBody
120+
? HttpRequest.BodyPublishers.ofString(body)
121+
: HttpRequest.BodyPublishers.noBody();
122+
123+
HttpRequest.Builder builder = HttpRequest.newBuilder()
124+
.uri(uri)
125+
.timeout(Duration.ofSeconds(30))
126+
.method(method, bodyPublisher);
127+
128+
if (hasBody && requestData != null) {
129+
String ct = requestData.contentType();
130+
builder.header("Content-Type", ct != null && !ct.isBlank() ? ct : "application/json");
131+
}
132+
133+
if (requestData != null && requestData.copyHeaders() && incomingHeaders != null) {
134+
incomingHeaders.forEach((key, value) -> {
135+
if (!BAD_HEADERS.contains(key.toLowerCase()) && !value.isEmpty()) {
136+
try {
137+
builder.header(key, value.get(0));
138+
} catch (IllegalArgumentException e) {
139+
logger.warn("Header {} ist geschützt und wurde übersprungen", key);
140+
}
141+
}
142+
});
143+
}
144+
145+
if (requestData != null && requestData.customHeaders() != null) {
146+
requestData.customHeaders().forEach((key, value) -> {
147+
if (key != null && !key.isBlank() && !BAD_HEADERS.contains(key.toLowerCase())) {
148+
builder.header(key, value);
149+
}
150+
});
151+
}
152+
153+
return builder.build();
154+
}
155+
156+
private String serializeChain(List<Map<String, Object>> chain) {
157+
StringBuilder sb = new StringBuilder("[");
158+
for (int i = 0; i < chain.size(); i++) {
159+
if (i > 0) sb.append(",");
160+
Map<String, Object> step = chain.get(i);
161+
sb.append("{")
162+
.append("\"from\":\"").append(escapeJson(step.get("from").toString())).append("\",")
163+
.append("\"status\":").append(step.get("status")).append(",")
164+
.append("\"to\":\"").append(escapeJson(step.get("to").toString())).append("\",")
165+
.append("\"proto\":\"").append(step.get("proto")).append("\"")
166+
.append("}");
167+
}
168+
return sb.append("]").toString();
169+
}
170+
171+
private String escapeJson(String s) {
172+
return s.replace("\\", "\\\\").replace("\"", "\\\"");
173+
}
174+
106175
private String formatErrorResponse(Exception e) {
107176
String summary = "Unbekannter Fehler";
108177
String detail = e.getMessage() != null ? e.getMessage() : "Keine Nachricht";
109178

110-
// Spezifische K8s/Netzwerk-Szenarien
111179
if (e instanceof java.net.UnknownHostException) {
112180
summary = "DNS Fehler: Host nicht gefunden. (Service-Name korrekt? Namespace vergessen?)";
113181
} else if (e instanceof java.net.ConnectException) {
@@ -123,4 +191,4 @@ private String formatErrorResponse(Exception e) {
123191

124192
return String.format("%s\nDetails: %s\n---STACKTRACE---\n%s", summary, detail, sw.toString());
125193
}
126-
}
194+
}

src/main/resources/public/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
<span id="responseTime" class="ms-3 text-muted small fw-bold"></span>
9393
</div>
9494
</div>
95+
<div id="redirectChain" style="display:none;" class="mb-3"></div>
9596

9697
<div id="errorBox" class="alert alert-danger shadow-sm border-0" style="display:none;">
9798
<div class="d-flex">

src/main/resources/public/js/http-client.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ document.addEventListener('DOMContentLoaded', () => {
1010
const statusBadge = document.getElementById('statusBadge');
1111
const protocolBadge = document.getElementById('protocolBadge');
1212
const responseTimeText = document.getElementById('responseTime');
13+
const redirectChainDiv = document.getElementById('redirectChain');
1314
const stacktraceArea = document.getElementById('stacktraceArea');
1415
const toggleStackBtn = document.getElementById('toggleStackBtn');
1516

@@ -77,8 +78,10 @@ document.addEventListener('DOMContentLoaded', () => {
7778
const duration = Date.now() - startTime;
7879
const data = await response.text();
7980
const protocol = response.headers.get('x-protocol-version') ?? '';
81+
const redirectChainHeader = response.headers.get('x-redirect-chain');
8082

8183
updateResponseMetadata(response.status, duration, protocol);
84+
renderRedirectChain(redirectChainHeader);
8285
addToHistory(payload, response.status, duration, data);
8386

8487
if (response.status === 502 && data.includes("---STACKTRACE---")) {
@@ -127,6 +130,37 @@ document.addEventListener('DOMContentLoaded', () => {
127130
responseOutput.innerText = "Sende Request an Cluster...";
128131
stacktraceArea.style.display = 'none';
129132
if (toggleStackBtn) toggleStackBtn.textContent = 'Stacktrace Details';
133+
if (redirectChainDiv) redirectChainDiv.style.display = 'none';
134+
}
135+
136+
function renderRedirectChain(chainHeader) {
137+
if (!redirectChainDiv) return;
138+
if (!chainHeader) { redirectChainDiv.style.display = 'none'; return; }
139+
140+
let steps;
141+
try { steps = JSON.parse(chainHeader); } catch (_) { return; }
142+
if (!steps.length) { redirectChainDiv.style.display = 'none'; return; }
143+
144+
const statusColor = (s) => s < 400 ? 'bg-warning text-dark' : 'bg-danger';
145+
146+
const stepsHtml = steps.map((step, i) => `
147+
<div class="d-flex align-items-center flex-wrap gap-1">
148+
${i === 0 ? `<span class="badge bg-dark font-monospace x-small text-truncate" style="max-width:220px;" title="${step.from}">${step.from}</span>` : ''}
149+
<i class="bi bi-arrow-right text-muted"></i>
150+
<span class="badge ${statusColor(step.status)}">${step.status}</span>
151+
<span class="badge bg-secondary x-small">${step.proto}</span>
152+
<i class="bi bi-arrow-right text-muted"></i>
153+
<span class="badge bg-dark font-monospace x-small text-truncate" style="max-width:220px;" title="${step.to}">${step.to}</span>
154+
</div>`).join('<div class="my-1"></div>');
155+
156+
redirectChainDiv.innerHTML = `
157+
<div class="border rounded p-2 bg-light">
158+
<div class="x-small fw-bold text-uppercase text-muted mb-2">
159+
<i class="bi bi-signpost-split me-1"></i>Redirect-Chain (${steps.length} Hop${steps.length > 1 ? 's' : ''})
160+
</div>
161+
${stepsHtml}
162+
</div>`;
163+
redirectChainDiv.style.display = 'block';
130164
}
131165

132166
function updateResponseMetadata(status, duration, protocol = '') {

0 commit comments

Comments
 (0)