For reasons described below, this method behaves as if it executes the following (error handling elided):
+ * + *+ * + *return ClientUri.create(URI.create(s));
That, in turn, is equivalent to:
+ * + *+ * + *return ClientUri.create().resolve(URI.create(s));
Internally, {@link ClientUri#create()} creates a new, "empty" {@link ClientUri} instance. The act of creating
+ * a new {@link ClientUri} instance, in turn, internally creates a {@link UriInfo.Builder} that is initialized with
+ * its default values. That {@linkplain UriInfo.Builder builder} is used during {@linkplain ClientUri#resolve(URI)
+ * resolution}. This means that default values like {@linkplain io.helidon.common.uri.URIInfo.BuilderBase#port() the
+ * default value for the port} can, and often do, appear in {@link URI}s {@linkplain
+ * ClientUri#resolve(URI) resolved} from user-supplied {@link URI} instances even though those values were never
+ * specified by the user.
More concretely:
+ * + *To ensure fidelity between these kinds of Helidon WebClient- and {@link ClientUri}-supplied {@link URI}s and + * {@link URI}s supplied (effectively) by users, the {@link DefaultWebClientDiscovery} class needs to ensure {@link + * String} representations of URIs are converted into {@link URI}s using the same effective mechanism that Helidon + * WebClient uses internally. This method accomplishes this task.
+ * + * @param s a valid URI representation; must not be {@code null} + * @return a {@link URI} representing the supplied {@link String} following Helidon conventions; never {@code null} + * @exception NullPointerException if {@code s} is {@code null} + * @exception IllegalArgumentException if {@code s} is not a valid URI representation according to the contract of + * the {@link URI#create(String)} method + * @see ClientUri#create() + * @see ClientUri#create(URI) + * @see ClientUri#resolve(URI) + * @see URI#create(String) + * @see io.helidon.uri.common.uri.UriInfo.BuilderBase#scheme() + * @see io.helidon.uri.common.uri.UriInfo.BuilderBase#host() + * @see io.helidon.uri.common.uri.UriInfo.BuilderBase#port() + * @see io.helidon.uri.common.uri.UriInfo.BuilderBase#path() + */ + private static URI uri(String s) { + return ClientUri.create(URI.create(s)).toUri(); + } + + + /* + * Inner and nested classes. + */ + + + record DiscoveryRequest(String discoveryName, URI defaultUri, String extraPath) { + + static final Pattern KEY_PATTERN = + Pattern.compile("^helidon-discovery-([^\\s]+)-(name|prefix-uri)$"); + + static final Pattern WHITESPACE_AND_COMMAS_PATTERN = Pattern.compile("[\\s,]+"); + + DiscoveryRequest { + requireNonNull(discoveryName, "discoveryName"); + requireNonNull(defaultUri, "defaultUri"); + requireNonNull(extraPath, "extraPath"); + } + + static Optional{@link WebClientDiscovery} instances are normally {@linkplain + * WebClientDiscoveryProvider#create(io.helidon.commonconfig.Config, String) created by} {@link + * WebClientDiscoveryProvider} instances, and are not typically used directly by end users.
+ * + *The specification for the {@link #handle(Chain, WebClientServiceRequest)} method fully specifies the required + * behavior of conforming implementations of this interface, and further describes the implementation-specific behavior + * of {@link WebClientDiscovery} instances returned from invocations of the {@link #create(WebClientDiscoveryConfig)} + * method.
+ * + *Implementations returned by invocations of the {@link #create(WebClientDiscoveryConfig)} method, and any of their + * internal supporting classes, will use {@link java.lang.System.Logger Logger}s {@linkplain + * java.lang.System#getLogger(String) whose names begin with} {@code io.helidon.webclient.discovery.}.
+ * + *Logging output is particularly important to monitor because as a general rule {@linkplain + * io.helidon.discovery.Discovery discovery} integrations must strive to be resilient in the presence of failures.
+ * + * @see #handle(Chain, WebClientServiceRequest) + * @see #builder() + * @see WebClientDiscoveryConfig#builder() + * @see io.helidon.discovery.Discovery + * @see WebClientDiscoveryProvider + * @see WebClientDiscoveryProvider#create(io.helidon.common.config.Config, String) + */ +@RuntimeType.PrototypedBy(WebClientDiscoveryConfig.class) +public interface WebClientDiscovery extends RuntimeType.ApiThe specification of this method is described below, followed by a description of its default + * implementation.
+ * + *Any implementation of this method must:
+ * + *WebClientServiceRequest's ClientUri} with information supplied by the discovered
+ * URI. If this cannot happen for any reason, the implementation must call {@link Chain#proceed(WebClientRequest)
+ * chain.proceed(request)} and return the result.The (default) behavior of this method as implemented in instances of {@link WebClientDiscovery} returned from + * the {@link #create(WebClientDiscoveryConfig)} method is described below. This implementation-specific behavior + * may change in subsequent revisions of this interface and its cooperating classes.
+ * + *Finding and using {@linkplain io.helidon.discovery.Discovery discovery}-related information in the supplied + * {@link WebClientServiceRequest} proceeds as follows, with examples:
+ * + *SERVICE1,
+ * SERVICE2) is split into tokens around commas, ignoring whitespace. Each distinct token is
+ * an id (e.g. SERVICE1 and SERVICE2).TOKEN:helidon-discovery-TOKEN-prefix-uri property exists, then TOKEN
+ * is an id (e.g. SERVICE1 in {@code helidon-discovery-SERVICE1-prefix-uri})
+ * and is added, if not already present, to the id set.helidon-discovery-TOKEN-name property exists, then TOKEN is an
+ * id (e.g. SERVICE1 in {@code helidon-discovery-SERVICE1-name}) and is added,
+ * if not already present, to the id set.ID (e.g. SERVICE1) in the (now non-empty) id
+ * set:helidon-discovery-ID-prefix-uri property (e.g. the
+ * value of the helidon-discovery-SERVICE1-prefix-uri property) is treated as a text
+ * representation of a URI (e.g. http://service1.example.com). If it is not a valid
+ * potential argument to the {@link java.net.URI#create(String)} method, then the id is skipped.http://service1.example.com:80/;
+ * note the explicit port ({@code 80}) and non-empty path that is now {@code /} instead of the empty
+ * string.helidon-discovery-ID-name property (e.g. the value of
+ * the helidon-discovery-SERVICE1-name property) is treated as a discovery name
+ * corresponding to that id. If the value is not present, the discovery name is, instead, the id itself. (For
+ * example purposes we will presume this property assignment is not present. Therefore the discovery name will be
+ * SERVICE1.)ClientUri} against it:http://service1.example.com:80/foo) in {@linkplain java.net.URI#normalize() normal form} is
+ * {@linkplain java.net.URI#relativize(java.net.URI) relativized against} the {@linkplain
+ * java.net.URI#normalize() normalized} prefix URI (e.g.
+ * http://service1.example.com:80/).ClientUri} will always be absolute,
+ * its host and port will always be defined, and its path will never be empty, even if the user did not specify any
+ * of this explicitly.http://service1.example.com:80/), discovery name (e.g. SERVICE1),
+ * and the result of relativization (the remaining raw path, e.g.
+ * foo)).Discovery instance supplied} by the
+ * {@linkplain #prototype() prototype} is used to {@linkplain io.helidon.discovery.Discovery#uris(String,
+ * java.net.URI) issue a discovery request using the discovery name (e.g. SERVICE1) and
+ * prefix URI (e.g. http://service1.example.com:80/)}. The first of the URIs returned is
+ * the discovered URI; see {@link io.helidon.discovery.Discovery#uris(String, java.net.URI)} for more
+ * details. For this example, presume the discovered URI is, e.g.,
+ * http://23.192.228.84:80/v1/.foo) against the discovered URI (e.g.
+ * http://23.192.228.84:80/v1/), yielding, e.g.,
+ * http://23.192.228.84:80/v1/foo, and then {@linkplain java.net.URI#getRawPath() acquiring its raw
+ * path} (e.g. /v1/foo).http),
+ * {@linkplain java.net.URI#getHost() host} (e.g. 23.192.228.84), and {@linkplain
+ * java.net.URI#getPort() port} (e.g. 80) of the discovered URI (e.g.
+ * http://23.192.228.84:80/v1) are installed on the {@linkplain WebClientServiceRequest#uri()
+ * original ClientUri} using its {@link io.helidon.webclient.api.ClientUri#scheme(String)
+ * scheme(String)}, {@link io.helidon.webclient.api.ClientUri#host(String) host(String)} and {@link
+ * io.helidon.webclient.api.ClientUri#port(int) port(int)} methods./v1/foo) is installed on the {@linkplain
+ * WebClientServiceRequest#uri() original ClientUri} using its {@link
+ * io.helidon.webclient.api.ClientUri#path(String) path(String)} method.http://23.192.228.84:80/v1/foo).
+ *
+ * Finally, including when any error is encountered, {@link Chain#proceed(WebClientServiceRequest) + * chain.proceed(request)} is invoked and the result is returned.
+ * + *A minimal example of (YAML) configuration might look like this:
+ * + *+ * + *client: # see the Helidon Configuration Reference for WebClient + * properties: + * helidon-discovery-myservice-prefix-uri: "http://example.com" + * services: + * discovery:
In this example, original {@link io.helidon.webclient.api.ClientUri ClientUri}s beginning with
+ * http://example.com:80/ (note the added/explicit port and path) will be subject to discovery, using
+ * myservice as the discovery name; all others will be passed through with no discovery-related
+ * action being taken.
A more explicit example of (YAML) configuration might look like this:
+ * + *+ * + *client: # see the Helidon Configuration Reference for WebClient + * properties: + * helidon-discovery-ids: "service1, service2" + * helidon-discovery-service1-name: "ServiceOne" + * helidon-discovery-service1-prefix-uri: "http://s1.example.com/" + * helidon-discovery-service2-name: "ServiceTwo" + * helidon-discovery-service2-prefix-uri: "http://s2.example.com/" + * services: + * discovery:
In this example, original {@link io.helidon.webclient.api.ClientUri ClientUri}s beginning with
+ * http://s1.example.com:80/ (note the added/explicit port) will be subject to discovery, using
+ * ServiceOne as the discovery name, and original {@link io.helidon.webclient.api.ClientUri
+ * ClientUri}s beginning with http://s2.example.com:80/ (note the added/explicit port) will be
+ * subject to discovery, using ServiceTwo as the discovery name. All others will be passed
+ * through with no discovery-related action being taken.
WebClientService
+ * implementation} ({@code discovery} by default).
+ *
+ * The default implementation of this method returns {@code discovery}.
+ * + * @return {@code discovery} when invoked + * @see WebClientService#type() + */ + @Override // WebClientService (NamedService) + default String type() { + return "discovery"; + } + + + /* + * Static methods. + */ + + + /** + * A convenience method that returns the result of invoking the {@link WebClientDiscoveryConfig#builder()} method. + * + * @return a non-{@code null} {@link WebClientDiscoveryConfig.Builder} + * @see WebClientDiscoveryConfig#builder() + * @see Helidon Builder + */ + static WebClientDiscoveryConfig.Builder builder() { + return WebClientDiscoveryConfig.builder(); + } + + /** + * A convenience method that calls the {@link #builder()} method, {@linkplain + * WebClientDiscoveryConfig.Builder#update(Consumer) updates it} using the supplied {@link Consumer}, invokes its + * {@link WebClientDiscoveryConfig.Builder#build() build()} method, and returns the result. + * + * @param consumer a {@link Consumer} of {@link WebClientDiscoveryConfig.Builder} instances; must not be {@code null} + * @return a new {@link WebClientDiscovery} implementation; never {@code null} + * @exception NullPointerException if {@code consumer} is {@code null} + * @see #builder() + * @see WebClientDiscoveryConfig.Builder#update(Consumer) + * @see WebClientDiscoveryConfig.Builder#build() + * @see Helidon Builder + */ + static WebClientDiscovery create(ConsumerThis method is most commonly called by the {@link WebClientDiscoveryConfig.Builder#build()} method, which + * supplies it with the return value of an invocation of its {@link + * WebClientDiscoveryConfig.Builder#buildPrototype()} method.
+ * + * @param prototype a {@link WebClientDiscoveryConfig}; must not be {@code null} + * @return a new {@link WebClientDiscovery} implementation + * @exception NullPointerException if {@code prototype} is {@code null} + * @see WebClientDiscoveryConfig.Builder#build() + * @see WebClientDiscoveryConfig.Builder#buildPrototype() + * @see Helidon Builder + */ + static WebClientDiscovery create(WebClientDiscoveryConfig prototype) { + return new DefaultWebClientDiscovery(prototype); + } + +} diff --git a/webclient/discovery/src/main/java/io/helidon/webclient/discovery/WebClientDiscoveryConfigBlueprint.java b/webclient/discovery/src/main/java/io/helidon/webclient/discovery/WebClientDiscoveryConfigBlueprint.java new file mode 100644 index 00000000000..45ec1854e00 --- /dev/null +++ b/webclient/discovery/src/main/java/io/helidon/webclient/discovery/WebClientDiscoveryConfigBlueprint.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * 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.helidon.webclient.discovery; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.discovery.Discovery; + +/** + * A {@linkplain Prototype.Api prototype} and {@linkplain Prototype.Factory factory} for {@link WebClientDiscovery} + * instances. + * + * @see WebClientDiscovery + * @see WebClientDiscovery#create(WebClientDiscoveryConfig) + * @see Helidon Builder + */ +@Prototype.Blueprint +@Prototype.Configured +interface WebClientDiscoveryConfigBlueprint extends Prototype.FactoryInstances of this class are normally used only by Helidon internals.
+ * + * @see #create(Config, String) + * @see WebClientDiscovery + * @see WebClientDiscoveryConfig#builder() + */ +public final class WebClientDiscoveryProvider implements WebClientServiceProvider { + + /** + * Creates a new {@link WebClientDiscoveryProvider}. + */ + public WebClientDiscoveryProvider() { + super(); + } + + /** + * Returns {@code discovery} when invoked. + * + * @return {@code discovery} when invoked + */ + @Override // WebClientServiceProvider + public String configKey() { + return "discovery"; + } + + /** + * {@linkplain WebClientDiscoveryConfig#builder() Builds}, {@linkplain + * WebClientDiscoveryConfig.BuilderBase#config(io.helidon.config.Config) configures}, and returns a new {@link + * WebClientDiscovery} that will cause any affiliated {@link io.helidon.webclient.api.WebClient} to automatically + * use a {@link io.helidon.discovery.Discovery} client to {@linkplain io.helidon.discovery.Discovery#uris(String, + * java.net.URI) discover} URIs. + * + * @param config a {@link Config} + * @param name a service name; normally {@code discovery} + * @return a new, non-{@code null} {@link WebClientDiscovery} implementation enabling discovery for its affiliated + * {@link io.helidon.webclient.api.WebClient} instance + * @exception NullPointerException if {@code config} or {@code name} is {@code null} + * @see WebClientDiscovery#handle(io.helidon.webclient.spi.WebClientService.Chain, + * io.helidon.webclient.api.WebClientServiceRequest) + * @see WebClientDiscoveryConfig#builder() + */ + @Override // WebClientServiceProvider + @SuppressWarnings("removal") // Config is deprecated for removal, but our contract (WebClientServiceProvider) mandates its use + public WebClientDiscovery create(Config config, String name) { + return WebClientDiscoveryConfig.builder() + .config(config) + .name(name) // after config(config) so that the name argument is always honored + .build(); + } + +} diff --git a/webclient/discovery/src/main/java/io/helidon/webclient/discovery/package-info.java b/webclient/discovery/src/main/java/io/helidon/webclient/discovery/package-info.java new file mode 100644 index 00000000000..3bd94a3b16d --- /dev/null +++ b/webclient/discovery/src/main/java/io/helidon/webclient/discovery/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * 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. + */ + +/** + * Provides classes and interfaces that integrate {@linkplain io.helidon.discovery.Discovery discovery} features into + * {@linkplain io.helidon.webclient.api.WebClient webclients}. + * + * @see io.helidon.webclient.discovery.WebClientDiscoveryProvider + * @see io.helidon.webclient.discovery.WebClientDiscovery + * @see io.helidon.webclient.discovery.WebClientDiscoveryConfig + * @see io.helidon.discovery.Discovery + */ +package io.helidon.webclient.discovery; diff --git a/webclient/discovery/src/main/java/module-info.java b/webclient/discovery/src/main/java/module-info.java new file mode 100644 index 00000000000..c117d76e4f9 --- /dev/null +++ b/webclient/discovery/src/main/java/module-info.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * 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. + */ + +/** + * Provides {@linkplain io.helidon.discovery.Discovery discovery} support for Helidon {@linkplain + * io.helidon.webclient.api.WebClient WebClient}. + * + * @see io.helidon.webclient.discovery.WebClientDiscoveryProvider + */ +module io.helidon.webclient.discovery { + + requires transitive io.helidon.discovery; + requires transitive io.helidon.webclient.api; + exports io.helidon.webclient.discovery; + + provides io.helidon.webclient.spi.WebClientServiceProvider + with io.helidon.webclient.discovery.WebClientDiscoveryProvider; + +} diff --git a/webclient/discovery/src/test/java/io/helidon/webclient/discovery/ClientUriTest.java b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/ClientUriTest.java new file mode 100644 index 00000000000..09140117830 --- /dev/null +++ b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/ClientUriTest.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * 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.helidon.webclient.discovery; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.List; + +import io.helidon.common.parameters.Parameters; +import io.helidon.common.uri.UriPath; +import io.helidon.common.uri.UriPathSegment; +import io.helidon.webclient.api.ClientUri; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.isEmptyString; +import static org.hamcrest.Matchers.nullValue; + +class ClientUriTest { + + private ClientUriTest() { + super(); + } + + @Test + void testResolve() { + ClientUri c = ClientUri.create(); + assertThat(c.scheme(), is("http")); + assertThat(c.host(), is("localhost")); // ! + assertThat(c.port(), is(80)); // ! + assertThat(c.path().path(), is("/")); // ! + c.resolve(URI.create("path")); + assertThat(c.scheme(), is("http")); + assertThat(c.host(), is("localhost")); + assertThat(c.port(), is(80)); + assertThat(c.path().path(), is("/path")); + } + + @Test + void testPathAndQueryBehavior() { + ClientUri c = ClientUri.create(URI.create("http://example.com:81/a/b?c=d")); + assertThat(c.path().rawPath(), is("/a/b")); + assertThat(c.query().rawValue(), is("c=d")); + c.path("/e/f"); + assertThat(c.path().rawPath(), is("/e/f")); + assertThat(c.query().rawValue(), is("c=d")); + c.path("/g/h?i=j"); + assertThat(c.path().rawPath(), is("/g/h")); + assertThat(c.query().rawValue(), is("c=d&i=j")); // ! + } + + @Test + void testEmptyAndNullPathConcerns() throws URISyntaxException { + URI uri = URI.create("http://example.com"); // note: no trailing slash + assertThat(uri.getRawPath(), isEmptyString()); // interesting; a documented "deviation" according to URI javadocs + + ClientUri c = ClientUri.create(uri); + assertThat(c.path().rawPath(), is("/")); // ! + + c = ClientUri.create(); + c.path(""); + c.resolve(uri); + assertThat(c.path().rawPath(), is("/")); // ! + + uri = URI.create("http://example.com/a/b"); + assertThat(uri.getRawPath(), is("/a/b")); // not a/b + + c = ClientUri.create(uri); + assertThat(c.path().rawPath(), is(uri.getRawPath())); + + uri = new URI("http", + null, // user info + "example.com", + -1, + null, // path; will become "" internally because this is a hierarchical URI + null, // query + null); // fragment + assertThat(uri.getRawPath(), isEmptyString()); // ! + URI uri2 = new URI("http", + null, // user info + "example.com", + -1, + "", // path; could have been (equivalently) null + null, // query + null); // fragment + assertThat(uri2.getRawPath(), isEmptyString()); + assertThat(uri, is(uri2)); + } + + @Test + void testEmpty() { + // ClientUri#create() documentation says it creates an "empty" ClientUri. Let's see what that means. + ClientUri emptyClientUri = ClientUri.create(); + assertThat(emptyClientUri.scheme(), is("http")); // ! + assertThat(emptyClientUri.host(), is("localhost")); // ! + assertThat(emptyClientUri.port(), is(80)); // ! + assertThat(emptyClientUri.path().rawPath(), is("/")); // ! + } + + @Test + void testAuthorityless() { + ClientUri c = ClientUri.create(URI.create("frob:///boo://bash")); + assertThat(c.scheme(), is("frob")); + assertThat(c.host(), is("localhost")); // ! + assertThat(c.port(), is(80)); // ! + assertThat(c.path().path(), is("/boo:/bash")); // ? + assertThat(c.path().rawPath(), is("/boo://bash")); + } + + @Test + void testOpaqueUris() { + URI uri = URI.create("a:b:c"); + assertThat(uri.getScheme(), is("a")); + assertThat(uri.getSchemeSpecificPart(), is("b:c")); + assertThat(uri.getHost(), is(nullValue())); + assertThat(uri.getPort(), is(-1)); + assertThat(uri.getAuthority(), is(nullValue())); + assertThat(uri.getRawPath(), is(nullValue())); + + ClientUri c = ClientUri.create(uri); + assertThat(c.scheme(), is("a")); + assertThat(c.host(), is("localhost")); // ! + assertThat(c.port(), is(80)); // ! + assertThat(c.authority(), is("localhost:80")); // ! + assertThat(c.path().rawPath(), is("/")); // ! note that b:c has disappeared + + uri = c.toUri(); + assertThat(uri.getScheme(), is("a")); + assertThat(uri.getSchemeSpecificPart(), is("//localhost:80/")); // ! + } + + @Test + void testClientUriPathSegmentsBehavior() { + ClientUri c = ClientUri.create(URI.create("http://example.com:80")); // no trailing slash + UriPath up = c.path(); + assertThat(up.rawPath(), is("/")); // ! + List