Skip to content

Commit f65127c

Browse files
authored
api: Add RFC 3986 support to DnsNameResolverProvider (#12602)
Accept both absolute (e.g. `dns:///hostname`) and rootless (e.g. `dns:hostname`) paths as specified by https://github.com/grpc/grpc/blob/master/doc/naming.md and matching the behavior of grpc core and grpc-go.
1 parent a535ed7 commit f65127c

File tree

4 files changed

+163
-13
lines changed

4 files changed

+163
-13
lines changed

api/src/main/java/io/grpc/Uri.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,15 @@ public String getPath() {
481481
* <p>Prefer this method over {@link #getPath()} because it preserves the distinction between
482482
* segment separators and literal '/'s within a path segment.
483483
*
484+
* <p>A trailing '/' delimiter in the path results in the empty string as the last element in the
485+
* returned list. For example, <code>file://localhost/foo/bar/</code> has path segments <code>
486+
* ["foo", "bar", ""]</code>
487+
*
488+
* <p>A leading '/' delimiter cannot be detected using this method. For example, both <code>
489+
* dns:example.com</code> and <code>dns:///example.com</code> have the same list of path segments:
490+
* <code>["example.com"]</code>. Use {@link #isPathAbsolute()} or {@link #isPathRootless()} to
491+
* distinguish these cases.
492+
*
484493
* <p>The returned list is immutable.
485494
*/
486495
public List<String> getPathSegments() {
@@ -490,6 +499,44 @@ public List<String> getPathSegments() {
490499
return segmentsBuilder.build();
491500
}
492501

502+
/**
503+
* Returns true iff this URI's path component starts with a path segment (rather than the '/'
504+
* segment delimiter).
505+
*
506+
* <p>The path of an RFC 3986 URI is either empty, absolute (starts with the '/' segment
507+
* delimiter) or rootless (starts with a path segment). For example, <code>tel:+1-206-555-1212
508+
* </code>, <code>mailto:[email protected]</code> and <code>urn:isbn:978-1492082798</code> all have
509+
* rootless paths. <code>mailto:%2Fdev%[email protected]</code> is also rootless because its
510+
* percent-encoded slashes are not segment delimiters but rather part of the first and only path
511+
* segment.
512+
*
513+
* <p>Contrast rootless paths with absolute ones (see {@link #isPathAbsolute()}.
514+
*/
515+
public boolean isPathRootless() {
516+
return !path.isEmpty() && !path.startsWith("/");
517+
}
518+
519+
/**
520+
* Returns true iff this URI's path component starts with the '/' segment delimiter (rather than a
521+
* path segment).
522+
*
523+
* <p>The path of an RFC 3986 URI is either empty, absolute (starts with the '/' segment
524+
* delimiter) or rootless (starts with a path segment). For example, <code>file:///resume.txt
525+
* </code>, <code>file:/resume.txt</code> and <code>file://localhost/</code> all have absolute
526+
* paths while <code>tel:+1-206-555-1212</code>'s path is not absolute. <code>
527+
* mailto:%2Fdev%[email protected]</code> is also not absolute because its percent-encoded
528+
* slashes are not segment delimiters but rather part of the first and only path segment.
529+
*
530+
* <p>Contrast absolute paths with rootless ones (see {@link #isPathRootless()}.
531+
*
532+
* <p>NB: The term "absolute" has two different meanings in RFC 3986 which are easily confused.
533+
* This method tests for a property of this URI's path component. Contrast with {@link
534+
* #isAbsolute()} which tests the URI itself for a different property.
535+
*/
536+
public boolean isPathAbsolute() {
537+
return path.startsWith("/");
538+
}
539+
493540
/**
494541
* Returns the path component of this URI in its originally parsed, possibly percent-encoded form.
495542
*/

api/src/test/java/io/grpc/UriTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ public void parse_allComponents() throws URISyntaxException {
4646
assertThat(uri.getFragment()).isEqualTo("fragment");
4747
assertThat(uri.toString()).isEqualTo("scheme://user@host:0443/path?query#fragment");
4848
assertThat(uri.isAbsolute()).isFalse(); // Has a fragment.
49+
assertThat(uri.isPathAbsolute()).isTrue();
50+
assertThat(uri.isPathRootless()).isFalse();
4951
}
5052

5153
@Test
@@ -127,6 +129,8 @@ public void parse_emptyPathWithAuthority() throws URISyntaxException {
127129
assertThat(uri.getFragment()).isNull();
128130
assertThat(uri.toString()).isEqualTo("scheme://authority");
129131
assertThat(uri.isAbsolute()).isTrue();
132+
assertThat(uri.isPathAbsolute()).isFalse();
133+
assertThat(uri.isPathRootless()).isFalse();
130134
}
131135

132136
@Test
@@ -139,6 +143,8 @@ public void parse_rootless() throws URISyntaxException {
139143
assertThat(uri.getFragment()).isNull();
140144
assertThat(uri.toString()).isEqualTo("mailto:[email protected]?subject=raise");
141145
assertThat(uri.isAbsolute()).isTrue();
146+
assertThat(uri.isPathAbsolute()).isFalse();
147+
assertThat(uri.isPathRootless()).isTrue();
142148
}
143149

144150
@Test
@@ -151,6 +157,8 @@ public void parse_emptyPath() throws URISyntaxException {
151157
assertThat(uri.getFragment()).isNull();
152158
assertThat(uri.toString()).isEqualTo("scheme:");
153159
assertThat(uri.isAbsolute()).isTrue();
160+
assertThat(uri.isPathAbsolute()).isFalse();
161+
assertThat(uri.isPathRootless()).isFalse();
154162
}
155163

156164
@Test
@@ -348,12 +356,34 @@ public void parse_onePathSegment_trailingSlash() throws URISyntaxException {
348356
assertThat(uri.getPathSegments()).containsExactly("foo", "");
349357
}
350358

359+
@Test
360+
public void parse_onePathSegment_rootless() throws URISyntaxException {
361+
Uri uri = Uri.create("dns:www.example.com");
362+
assertThat(uri.getPathSegments()).containsExactly("www.example.com");
363+
assertThat(uri.isPathAbsolute()).isFalse();
364+
assertThat(uri.isPathRootless()).isTrue();
365+
}
366+
351367
@Test
352368
public void parse_twoPathSegments() throws URISyntaxException {
353369
Uri uri = Uri.create("file:/foo/bar");
354370
assertThat(uri.getPathSegments()).containsExactly("foo", "bar");
355371
}
356372

373+
@Test
374+
public void parse_twoPathSegments_rootless() throws URISyntaxException {
375+
Uri uri = Uri.create("file:foo/bar");
376+
assertThat(uri.getPathSegments()).containsExactly("foo", "bar");
377+
}
378+
379+
@Test
380+
public void parse_percentEncodedPathSegment_rootless() throws URISyntaxException {
381+
Uri uri = Uri.create("mailto:%2Fdev%[email protected]");
382+
assertThat(uri.getPathSegments()).containsExactly("/dev/[email protected]");
383+
assertThat(uri.isPathAbsolute()).isFalse();
384+
assertThat(uri.isPathRootless()).isTrue();
385+
}
386+
357387
@Test
358388
public void toString_percentEncoding() throws URISyntaxException {
359389
Uri uri =

core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,25 +21,30 @@
2121
import io.grpc.InternalServiceProviders;
2222
import io.grpc.NameResolver;
2323
import io.grpc.NameResolverProvider;
24+
import io.grpc.Uri;
2425
import java.net.InetSocketAddress;
2526
import java.net.SocketAddress;
2627
import java.net.URI;
2728
import java.util.Collection;
2829
import java.util.Collections;
30+
import java.util.List;
2931

3032
/**
3133
* A provider for {@link DnsNameResolver}.
3234
*
3335
* <p>It resolves a target URI whose scheme is {@code "dns"}. The (optional) authority of the target
34-
* URI is reserved for the address of alternative DNS server (not implemented yet). The path of the
35-
* target URI, excluding the leading slash {@code '/'}, is treated as the host name and the optional
36-
* port to be resolved by DNS. Example target URIs:
36+
* URI is reserved for the address of alternative DNS server (not implemented yet). The first path
37+
* segment of the hierarchical target URI is interpreted as an RFC 2396 "server-based" authority and
38+
* used as the "service authority" of the resulting {@link NameResolver}. The "host" part of this
39+
* authority is the name to be resolved by DNS. The "port" part of this authority (if present) will
40+
* become the port number for all {@link InetSocketAddress} produced by this resolver. For example:
3741
*
3842
* <ul>
3943
* <li>{@code "dns:///foo.googleapis.com:8080"} (using default DNS)</li>
4044
* <li>{@code "dns://8.8.8.8/foo.googleapis.com:8080"} (using alternative DNS (not implemented
4145
* yet))</li>
42-
* <li>{@code "dns:///foo.googleapis.com"} (without port)</li>
46+
* <li>{@code "dns:///foo.googleapis.com"} (output addresses will have port {@link
47+
* NameResolver.Args#getDefaultPort()})</li>
4348
* </ul>
4449
*/
4550
public final class DnsNameResolverProvider extends NameResolverProvider {
@@ -51,6 +56,7 @@ public final class DnsNameResolverProvider extends NameResolverProvider {
5156

5257
@Override
5358
public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) {
59+
// TODO(jdcormie): Remove once RFC 3986 migration is complete.
5460
if (SCHEME.equals(targetUri.getScheme())) {
5561
String targetPath = Preconditions.checkNotNull(targetUri.getPath(), "targetPath");
5662
Preconditions.checkArgument(targetPath.startsWith("/"),
@@ -68,6 +74,25 @@ public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) {
6874
}
6975
}
7076

77+
@Override
78+
public NameResolver newNameResolver(Uri targetUri, final NameResolver.Args args) {
79+
if (SCHEME.equals(targetUri.getScheme())) {
80+
List<String> pathSegments = targetUri.getPathSegments();
81+
Preconditions.checkArgument(!pathSegments.isEmpty(),
82+
"expected 1 path segment in target %s but found %s", targetUri, pathSegments);
83+
String domainNameToResolve = pathSegments.get(0);
84+
return new DnsNameResolver(
85+
targetUri.getAuthority(),
86+
domainNameToResolve,
87+
args,
88+
GrpcUtil.SHARED_CHANNEL_EXECUTOR,
89+
Stopwatch.createUnstarted(),
90+
IS_ANDROID);
91+
} else {
92+
return null;
93+
}
94+
}
95+
7196
@Override
7297
public String getDefaultScheme() {
7398
return SCHEME;

core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,36 @@
1616

1717
package io.grpc.internal;
1818

19-
import static org.junit.Assert.assertNull;
20-
import static org.junit.Assert.assertSame;
19+
import static com.google.common.truth.Truth.assertThat;
20+
import static com.google.common.truth.TruthJUnit.assume;
2121
import static org.junit.Assert.assertTrue;
2222
import static org.mockito.Mockito.mock;
2323

2424
import io.grpc.ChannelLogger;
2525
import io.grpc.NameResolver;
2626
import io.grpc.NameResolver.ServiceConfigParser;
2727
import io.grpc.SynchronizationContext;
28+
import io.grpc.Uri;
2829
import java.net.URI;
30+
import java.util.Arrays;
2931
import org.junit.Test;
3032
import org.junit.runner.RunWith;
31-
import org.junit.runners.JUnit4;
33+
import org.junit.runners.Parameterized;
34+
import org.junit.runners.Parameterized.Parameter;
35+
import org.junit.runners.Parameterized.Parameters;
3236

3337
/** Unit tests for {@link DnsNameResolverProvider}. */
34-
@RunWith(JUnit4.class)
38+
@RunWith(Parameterized.class)
3539
public class DnsNameResolverProviderTest {
3640
private final FakeClock fakeClock = new FakeClock();
3741

42+
@Parameters(name = "enableRfc3986UrisParam={0}")
43+
public static Iterable<Object[]> data() {
44+
return Arrays.asList(new Object[][] {{true}, {false}});
45+
}
46+
47+
@Parameter public boolean enableRfc3986UrisParam;
48+
3849
private final SynchronizationContext syncContext = new SynchronizationContext(
3950
new Thread.UncaughtExceptionHandler() {
4051
@Override
@@ -59,10 +70,47 @@ public void isAvailable() {
5970
}
6071

6172
@Test
62-
public void newNameResolver() {
63-
assertSame(DnsNameResolver.class,
64-
provider.newNameResolver(URI.create("dns:///localhost:443"), args).getClass());
65-
assertNull(
66-
provider.newNameResolver(URI.create("notdns:///localhost:443"), args));
73+
public void newNameResolver_acceptsHostAndPort() {
74+
NameResolver nameResolver = newNameResolver("dns:///localhost:443", args);
75+
assertThat(nameResolver).isNotNull();
76+
assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class);
77+
assertThat(nameResolver.getServiceAuthority()).isEqualTo("localhost:443");
78+
}
79+
80+
@Test
81+
public void newNameResolver_acceptsRootless() {
82+
assume().that(enableRfc3986UrisParam).isTrue();
83+
NameResolver nameResolver = newNameResolver("dns:localhost:443", args);
84+
assertThat(nameResolver).isNotNull();
85+
assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class);
86+
assertThat(nameResolver.getServiceAuthority()).isEqualTo("localhost:443");
87+
}
88+
89+
@Test
90+
public void newNameResolver_rejectsNonDnsScheme() {
91+
NameResolver nameResolver = newNameResolver("notdns:///localhost:443", args);
92+
assertThat(nameResolver).isNull();
93+
}
94+
95+
@Test
96+
public void newNameResolver_toleratesTrailingPathSegments() {
97+
NameResolver nameResolver = newNameResolver("dns:///foo.googleapis.com/ig/nor/ed", args);
98+
assertThat(nameResolver).isNotNull();
99+
assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class);
100+
assertThat(nameResolver.getServiceAuthority()).isEqualTo("foo.googleapis.com");
101+
}
102+
103+
@Test
104+
public void newNameResolver_toleratesAuthority() {
105+
NameResolver nameResolver = newNameResolver("dns://8.8.8.8/foo.googleapis.com", args);
106+
assertThat(nameResolver).isNotNull();
107+
assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class);
108+
assertThat(nameResolver.getServiceAuthority()).isEqualTo("foo.googleapis.com");
109+
}
110+
111+
private NameResolver newNameResolver(String uriString, NameResolver.Args args) {
112+
return enableRfc3986UrisParam
113+
? provider.newNameResolver(Uri.create(uriString), args)
114+
: provider.newNameResolver(URI.create(uriString), args);
67115
}
68116
}

0 commit comments

Comments
 (0)