Skip to content
Open
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
35 changes: 33 additions & 2 deletions api/src/main/java/io/grpc/HttpConnectProxiedSocketAddress.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
import com.google.common.base.Objects;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import javax.annotation.Nullable;

/**
Expand All @@ -33,6 +36,8 @@ public final class HttpConnectProxiedSocketAddress extends ProxiedSocketAddress

private final SocketAddress proxyAddress;
private final InetSocketAddress targetAddress;
@SuppressWarnings("serial")
private final Map<String, String> headers;
@Nullable
private final String username;
@Nullable
Expand All @@ -41,6 +46,7 @@ public final class HttpConnectProxiedSocketAddress extends ProxiedSocketAddress
private HttpConnectProxiedSocketAddress(
SocketAddress proxyAddress,
InetSocketAddress targetAddress,
Map<String, String> headers,
@Nullable String username,
@Nullable String password) {
checkNotNull(proxyAddress, "proxyAddress");
Expand All @@ -53,6 +59,7 @@ private HttpConnectProxiedSocketAddress(
}
this.proxyAddress = proxyAddress;
this.targetAddress = targetAddress;
this.headers = headers;
this.username = username;
this.password = password;
}
Expand Down Expand Up @@ -87,6 +94,14 @@ public InetSocketAddress getTargetAddress() {
return targetAddress;
}

/**
* Returns the custom HTTP headers to be sent during the HTTP CONNECT handshake.
*/
@ExperimentalApi("https://github.com/grpc/grpc-java/issues/12479")
public Map<String, String> getHeaders() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add @ExperimentalApi here and to the setter. We add it to all new APIs. This seems easy to stabilize.

You can look around for some examples, but basically you create a tracking issue and use the URL in the annotation.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! Added @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12479") to both getHeaders() and setHeaders().

Used #12479 as tracking issue.

return headers;
}

@Override
public boolean equals(Object o) {
if (!(o instanceof HttpConnectProxiedSocketAddress)) {
Expand All @@ -95,20 +110,22 @@ public boolean equals(Object o) {
HttpConnectProxiedSocketAddress that = (HttpConnectProxiedSocketAddress) o;
return Objects.equal(proxyAddress, that.proxyAddress)
&& Objects.equal(targetAddress, that.targetAddress)
&& Objects.equal(headers, that.headers)
&& Objects.equal(username, that.username)
&& Objects.equal(password, that.password);
}

@Override
public int hashCode() {
return Objects.hashCode(proxyAddress, targetAddress, username, password);
return Objects.hashCode(proxyAddress, targetAddress, username, password, headers);
}

@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("proxyAddr", proxyAddress)
.add("targetAddr", targetAddress)
.add("headers", headers)
.add("username", username)
// Intentionally mask out password
.add("hasPassword", password != null)
Expand All @@ -129,6 +146,7 @@ public static final class Builder {

private SocketAddress proxyAddress;
private InetSocketAddress targetAddress;
private Map<String, String> headers = Collections.emptyMap();
@Nullable
private String username;
@Nullable
Expand All @@ -153,6 +171,18 @@ public Builder setTargetAddress(InetSocketAddress targetAddress) {
return this;
}

/**
* Sets custom HTTP headers to be sent during the HTTP CONNECT handshake. This is an optional
* field. The headers will be sent in addition to any authentication headers (if username and
* password are set).
*/
@ExperimentalApi("https://github.com/grpc/grpc-java/issues/12479")
public Builder setHeaders(Map<String, String> headers) {
this.headers = Collections.unmodifiableMap(
new HashMap<>(checkNotNull(headers, "headers")));
return this;
}

/**
* Sets the username used to connect to the proxy. This is an optional field and can be {@code
* null}.
Expand All @@ -175,7 +205,8 @@ public Builder setPassword(@Nullable String password) {
* Creates an {@code HttpConnectProxiedSocketAddress}.
*/
public HttpConnectProxiedSocketAddress build() {
return new HttpConnectProxiedSocketAddress(proxyAddress, targetAddress, username, password);
return new HttpConnectProxiedSocketAddress(
proxyAddress, targetAddress, headers, username, password);
}
}
}
248 changes: 248 additions & 0 deletions api/src/test/java/io/grpc/HttpConnectProxiedSocketAddressTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/*
* Copyright 2025 The gRPC Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.grpc;

import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertThrows;

import com.google.common.testing.EqualsTester;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

@RunWith(JUnit4.class)
public class HttpConnectProxiedSocketAddressTest {

private final InetSocketAddress proxyAddress =
new InetSocketAddress(InetAddress.getLoopbackAddress(), 8080);
private final InetSocketAddress targetAddress =
InetSocketAddress.createUnresolved("example.com", 443);

@Test
public void buildWithAllFields() {
Map<String, String> headers = new HashMap<>();
headers.put("X-Custom-Header", "custom-value");
headers.put("Proxy-Authorization", "Bearer token");

HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(headers)
.setUsername("user")
.setPassword("pass")
.build();

assertThat(address.getProxyAddress()).isEqualTo(proxyAddress);
assertThat(address.getTargetAddress()).isEqualTo(targetAddress);
assertThat(address.getHeaders()).hasSize(2);
assertThat(address.getHeaders()).containsEntry("X-Custom-Header", "custom-value");
assertThat(address.getHeaders()).containsEntry("Proxy-Authorization", "Bearer token");
assertThat(address.getUsername()).isEqualTo("user");
assertThat(address.getPassword()).isEqualTo("pass");
}

@Test
public void buildWithoutOptionalFields() {
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.build();

assertThat(address.getProxyAddress()).isEqualTo(proxyAddress);
assertThat(address.getTargetAddress()).isEqualTo(targetAddress);
assertThat(address.getHeaders()).isEmpty();
assertThat(address.getUsername()).isNull();
assertThat(address.getPassword()).isNull();
}

@Test
public void buildWithEmptyHeaders() {
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(Collections.emptyMap())
.build();

assertThat(address.getHeaders()).isEmpty();
}

@Test
public void headersAreImmutable() {
Map<String, String> headers = new HashMap<>();
headers.put("key1", "value1");

HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(headers)
.build();

headers.put("key2", "value2");

assertThat(address.getHeaders()).hasSize(1);
assertThat(address.getHeaders()).containsEntry("key1", "value1");
assertThat(address.getHeaders()).doesNotContainKey("key2");
}

@Test
public void returnedHeadersAreUnmodifiable() {
Map<String, String> headers = new HashMap<>();
headers.put("key", "value");

HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(headers)
.build();

assertThrows(UnsupportedOperationException.class,
() -> address.getHeaders().put("newKey", "newValue"));
}

@Test
public void nullHeadersThrowsException() {
assertThrows(NullPointerException.class,
() -> HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(null)
.build());
}

@Test
public void equalsAndHashCode() {
Map<String, String> headers1 = new HashMap<>();
headers1.put("header", "value");

Map<String, String> headers2 = new HashMap<>();
headers2.put("header", "value");

Map<String, String> differentHeaders = new HashMap<>();
differentHeaders.put("different", "header");

new EqualsTester()
.addEqualityGroup(
HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(headers1)
.setUsername("user")
.setPassword("pass")
.build(),
HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(headers2)
.setUsername("user")
.setPassword("pass")
.build())
.addEqualityGroup(
HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(differentHeaders)
.setUsername("user")
.setPassword("pass")
.build())
.addEqualityGroup(
HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.build())
.testEquals();
}

@Test
public void toStringContainsHeaders() {
Map<String, String> headers = new HashMap<>();
headers.put("X-Test", "test-value");

HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(headers)
.setUsername("user")
.setPassword("secret")
.build();

String toString = address.toString();
assertThat(toString).contains("headers");
assertThat(toString).contains("X-Test");
assertThat(toString).contains("hasPassword=true");
assertThat(toString).doesNotContain("secret");
}

@Test
public void toStringWithoutPassword() {
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.build();

String toString = address.toString();
assertThat(toString).contains("hasPassword=false");
}

@Test
public void hashCodeDependsOnHeaders() {
Map<String, String> headers1 = new HashMap<>();
headers1.put("header", "value1");

Map<String, String> headers2 = new HashMap<>();
headers2.put("header", "value2");

HttpConnectProxiedSocketAddress address1 = HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(headers1)
.build();

HttpConnectProxiedSocketAddress address2 = HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(headers2)
.build();

assertNotEquals(address1.hashCode(), address2.hashCode());
}

@Test
public void multipleHeadersSupported() {
Map<String, String> headers = new HashMap<>();
headers.put("X-Header-1", "value1");
headers.put("X-Header-2", "value2");
headers.put("X-Header-3", "value3");

HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
.setProxyAddress(proxyAddress)
.setTargetAddress(targetAddress)
.setHeaders(headers)
.build();

assertThat(address.getHeaders()).hasSize(3);
assertThat(address.getHeaders()).containsEntry("X-Header-1", "value1");
assertThat(address.getHeaders()).containsEntry("X-Header-2", "value2");
assertThat(address.getHeaders()).containsEntry("X-Header-3", "value3");
}
}

1 change: 1 addition & 0 deletions netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,7 @@ public ConnectionClientTransport newClientTransport(
serverAddress = proxiedAddr.getTargetAddress();
localNegotiator = ProtocolNegotiators.httpProxy(
proxiedAddr.getProxyAddress(),
proxiedAddr.getHeaders(),
proxiedAddr.getUsername(),
proxiedAddr.getPassword(),
protocolNegotiator);
Expand Down
Loading