Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import jakarta.enterprise.inject.spi.BeanManager;
import jakarta.enterprise.inject.spi.CDI;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
Expand Down Expand Up @@ -533,6 +534,28 @@ public CompletableFuture<Void> getAsyncSSE(
}
}

/**
* Resolves an HTTP {@code Location} header value against the original request URI.
*
* <p>Per RFC 9110 10.2.2 (and RFC 7231 before it) a {@code Location} may be a relative
* reference such as {@code /collect}; RFC 3986 5 reference resolution turns it into the
* effective target URI. An already-absolute {@code Location} is returned unchanged. A
* malformed base or location falls back to the raw value so any existing handling still
* applies.
*
* @param requestUri the original (absolute) request URI the redirect responded to
* @param location the raw {@code Location} header value
* @return the absolute redirect target
*/
// Package-private for direct unit testing of RFC 3986 reference resolution.
static String resolveRedirectLocation(String requestUri, String location) {
try {
return URI.create(requestUri).resolve(location).toString();
} catch (IllegalArgumentException e) {
return location;
}
}

private class VertxPostBuilder extends VertxBuilder<PostBuilder> implements A2AHttpClient.PostBuilder {

private String body = "";
Expand Down Expand Up @@ -571,12 +594,15 @@ public A2AHttpResponse post() throws IOException, InterruptedException {
int statusCode = response.status();
String location = response.headers().firstValue("Location");
if (location != null) {
// RFC 9110 10.2.2 permits a relative Location (e.g. /collect); resolve it
// against the original request URI per RFC 3986 5 before following.
String resolvedLocation = resolveRedirectLocation(url, location);
if (statusCode == 301 || statusCode == 302 || statusCode == 303) {
// RFC 7231: 301/302/303 redirect POST as GET; 307/308 would preserve the method
return executeSyncRequest(webClient.getAbs(location), headers, null);
return executeSyncRequest(webClient.getAbs(resolvedLocation), headers, null);
} else if (statusCode == 307 || statusCode == 308) {
// RFC 7538: 307/308 must repeat the request with the original method and body
return executeSyncRequest(webClient.postAbs(location), headers, bodyBuffer);
return executeSyncRequest(webClient.postAbs(resolvedLocation), headers, bodyBuffer);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,58 @@
package org.a2aproject.sdk.client.http.vertx;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;
import static org.mockserver.verify.VerificationTimes.exactly;

import org.a2aproject.sdk.client.http.A2AHttpClient;
import org.a2aproject.sdk.client.http.A2AHttpResponse;
import org.a2aproject.sdk.client.http.AbstractA2AHttpClientRedirectTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockserver.integration.ClientAndServer;

public class VertxA2AHttpClientRedirectTest extends AbstractA2AHttpClientRedirectTest {

private ClientAndServer relativeServer;

@Override
protected A2AHttpClient createClient() {
return new VertxA2AHttpClient();
}

@AfterEach
public void stopRelativeServer() {
if (relativeServer != null) {
relativeServer.stop();
}
}

/**
* A relative {@code Location} value (permitted by RFC 9110 10.2.2, resolved per RFC 3986 5)
* must be resolved against the original request URI and followed, rather than handed verbatim
* to an absolute request where it fails. The redirect target is served by the same origin so
* that {@code /collect} only resolves correctly when the base URI is applied.
*
* <p>Regression test for a2aproject/a2a-java#1142.
*/
@Test
public void synchronousPostFollowsRelativeRedirectLocation() throws Exception {
relativeServer = ClientAndServer.startClientAndServer(0);
relativeServer.when(request().withMethod("POST").withPath("/agent"))
.respond(response().withStatusCode(302).withHeader("Location", "/collect"));
relativeServer.when(request().withPath("/collect"))
.respond(response().withStatusCode(200).withBody("redirected"));

A2AHttpResponse result = createClient().createPost()
.url("http://127.0.0.1:" + relativeServer.getLocalPort() + "/agent")
.addHeader("X-API-Key", "FULCRUM-SYNTHETIC-RELATIVE")
.body("{}")
.followRedirects(true)
.post();

assertEquals(200, result.status(),
"Synchronous POST must resolve and follow a relative Location against the request URI");
relativeServer.verify(request().withPath("/collect"), exactly(1));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.a2aproject.sdk.client.http.vertx;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

/**
* Unit tests for {@link VertxA2AHttpClient#resolveRedirectLocation(String, String)}, the RFC 3986
* section 5 reference resolution applied to a redirect {@code Location} before the redirected
* request is built.
*
* <p>Covers a2aproject/a2a-java#1142: a relative {@code Location} must resolve against the original
* request URI rather than be handed verbatim to an absolute request.
*/
public class VertxA2AHttpClientResolveRedirectLocationTest {

@Test
public void absolutePathResolvesAgainstOrigin() {
assertEquals(
"http://host:3000/collect",
VertxA2AHttpClient.resolveRedirectLocation("http://host:3000/rpc/send", "/collect"));
}

@Test
public void relativePathResolvesAgainstCurrentDirectory() {
assertEquals(
"http://host:3000/rpc/collect",
VertxA2AHttpClient.resolveRedirectLocation("http://host:3000/rpc/send", "collect"));
}

@Test
public void relativePathWithQueryIsResolved() {
assertEquals(
"http://host:3000/a/c?x=1",
VertxA2AHttpClient.resolveRedirectLocation("http://host:3000/a/b", "c?x=1"));
}

@Test
public void dotSegmentsAreResolved() {
assertEquals(
"http://host:3000/a/d",
VertxA2AHttpClient.resolveRedirectLocation("http://host:3000/a/b/c", "../d"));
}

@Test
public void absoluteLocationIsReturnedUnchanged() {
assertEquals(
"https://elsewhere.example/xyz",
VertxA2AHttpClient.resolveRedirectLocation(
"http://host:3000/rpc", "https://elsewhere.example/xyz"));
}

@Test
public void protocolRelativeLocationKeepsScheme() {
assertEquals(
"https://elsewhere.example/p",
VertxA2AHttpClient.resolveRedirectLocation(
"https://host/rpc", "//elsewhere.example/p"));
}

@Test
public void malformedLocationFallsBackToRawValue() {
// A space is illegal in a URI reference, so URI.create throws and the raw
// Location is returned so any existing downstream handling still applies.
String raw = "/pa th";
assertEquals(raw, VertxA2AHttpClient.resolveRedirectLocation("http://host/rpc", raw));
}

@Test
public void malformedBaseFallsBackToRawLocation() {
String location = "/collect";
assertEquals(
location, VertxA2AHttpClient.resolveRedirectLocation("ht tp://host/rpc", location));
}
}
Loading