diff --git a/all/pom.xml b/all/pom.xml index 9f17cb5ad42..25e696c6cca 100644 --- a/all/pom.xml +++ b/all/pom.xml @@ -1181,6 +1181,10 @@ io.helidon.webclient helidon-webclient-api + + io.helidon.webclient + helidon-webclient-discovery + io.helidon.webclient helidon-webclient diff --git a/bom/pom.xml b/bom/pom.xml index 9b5734e68bd..44186ccd911 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -1583,6 +1583,11 @@ helidon-webclient-api ${helidon.version} + + io.helidon.webclient + helidon-webclient-discovery + ${helidon.version} + io.helidon.webclient helidon-webclient diff --git a/docs/src/main/asciidoc/includes/attributes.adoc b/docs/src/main/asciidoc/includes/attributes.adoc index 56cb33630b1..39e2ddd535b 100644 --- a/docs/src/main/asciidoc/includes/attributes.adoc +++ b/docs/src/main/asciidoc/includes/attributes.adoc @@ -207,6 +207,7 @@ endif::[] :configurable-javadoc-base-url: {javadoc-base-url}/io.helidon.common.configurable :context-javadoc-base-url: {javadoc-base-url}/io.helidon.common.context :cors-javadoc-base-url: {javadoc-base-url}/io.helidon.cors +:discovery-providers-eureka-javadoc-base-url: {javadoc-base-url}/io.helidon.discovery.providers.eureka :discovery-javadoc-base-url: {javadoc-base-url}/io.helidon.discovery :faulttolerance-javadoc-base-url: {javadoc-base-url}/io.helidon.faulttolerance :grpc-server-javadoc-base-url: {javadoc-base-url}/io.helidon.webserver.grpc @@ -247,6 +248,7 @@ endif::[] :webclient-javadoc-base-url: {javadoc-base-url}/io.helidon.webclient :webclient-api-javadoc-base-url: {javadoc-base-url}/io.helidon.webclient.api +:webclient-discovery-javadoc-base-url: {javadoc-base-url}/io.helidon.webclient.discovery :webserver-javadoc-base-url: {javadoc-base-url}/io.helidon.webserver :webserver-staticcontent-javadoc-base-url: {webserver-javadoc-base-url}.staticcontent :webserver-cors-javadoc-base-url: {javadoc-base-url}/io.helidon.webserver.cors diff --git a/docs/src/main/asciidoc/se/discovery.adoc b/docs/src/main/asciidoc/se/discovery.adoc index 122bc6ad95f..4209842c460 100644 --- a/docs/src/main/asciidoc/se/discovery.adoc +++ b/docs/src/main/asciidoc/se/discovery.adoc @@ -31,6 +31,9 @@ include::{rootdir}/includes/se.adoc[] * <> * <> ** <> +* <> +** <> +* <> == Overview @@ -308,3 +311,115 @@ discovery: # <1> Users of the Helidon Eureka Discovery provider may also be interested in the (related) xref:{docdir}/integrations/eureka/eureka-registration.adoc[Eureka Server Service Instance Registration] feature. + +== Integrations + +Helidon integrates a <> with other Helidon modules as described below. + +=== Web Client Discovery Integration + +Helidon integrates a <> with xref:webclient.adoc[Web Client]. + +==== Maven Coordinates + +To include the Helidon Web Client Discovery integration in your project, you add the Web Client Discovery integration +dependency as well as a <> dependency (see +xref:{rootdir}/about/managing-dependencies.adoc[Managing Dependencies]): + +[source,xml] +.`pom.xml` +---- + + + io.helidon.webclient + helidon-webclient-discovery + runtime + + + io.helidon.discovery.providers + helidon-discovery-providers-eureka + runtime + + +---- +<1> Helidon Web Client Discovery integration dependency. +<2> The scope for the integration. `runtime` since the integration is never required at compile time. +<3> Helidon <> dependency (for example). +<4> The scope for the provider. Use `runtime` if you have no interest in provider-specific classes and methods (the most + common case). Use `compile` if you plan to call provider-specific methods. + +The behavior of the Web Client Discovery integration is +link:{webclient-discovery-javadoc-base-url}/io/helidon/webclient/discovery/WebClientDiscovery.html#handle(io.helidon.webclient.spi.WebClientService.Chain,io.helidon.webclient.api.WebClientServiceRequest)[fully +specified and documented]. + +==== Configuration + +The Helidon Web Client Discovery integration can be configured using xref:config/introduction.adoc[Helidon +Config]. Examples shown below are in YAML, but are expressible in any format and any location that Helidon Config +supports. + +Because the Helidon Web Client Discovery integration is fundamentally a +xref:webclient.adoc#_adding_service_to_webclient[Web Client Service], you configure it under a Web Client's `services` +configuration node: + +[source,yaml] +.`application.yaml` +---- +webclient: + services: + discovery: # <1> +---- +<1> Indicates that the Web Client Discovery integration should apply to this Web Client configuration. More configuration + is required; see below. + +You also configure the Discovery provider in use following its documentation. See, for example, +<<_configuration,Eureka configuration>>. + +==== Configuring URIs + +To mark URIs requested by a Web Client as subject to discovery, and to use discovery names appropriate for them, you +need to configure _prefix URIs_. URIs that match no prefix will not be subject to discovery: + +[source,yaml] +.`application.yaml` +---- +webclient: + services: + discovery: + properties: + helidon-discovery-EXAMPLE-prefix-uri: "https://example.com:443/" # <1> + helidon-discovery-TEST-prefix-uri: "https://test.example.com:443/" # <2> <3> +---- +<1> Indicates that URIs starting with `https://example.com:443/` will be subject to discovery, using the discovery name + of `EXAMPLE` +<2> Indicates that URIs starting with `https://test.example.com:443/` will be subject to discovery, using the discovery + name of `TEST` +<3> URIs that begin with text other than `https://example.com:443/` or `https://test.example.com:443/` will not be + subject to discovery + +===== Complicated Discovery Names + +To use complicated discovery names for certain prefix URIs, you can link them to specific prefix URIs as follows. This +configuration strategy is primarily for cases where, for whatever reason, the discovery name is too long or complicated +to appear in a property name: + +[source,yaml] +.`application.yaml` +---- +webclient: + services: + discovery: + properties: + helidon-discovery-EXAMPLE-prefix-uri: "https://example.com:443/" # <1> + helidon-discovery-EXAMPLE-name: "My Long Complicated Discovery Name" # <2> +---- +<1> Indicates that URIs starting with `https://example.com:443/` will be subject to discovery +<2> Indicates that the discovery name to use for prefix URIs identified by the + `helidon-discovery-EXAMPLE-prefix-uri` property name will be `My Long Complicated Discovery Name` + +== References + +* link:{discovery-javadoc-base-url}/module-summary.html[Discovery Javadoc] +* link:{discovery-providers-eureka-javadoc-base-url}/module-summary.html[Eureka Discovery Provider Javadoc] +* link:{webclient-discovery-javadoc-base-url}/module-summary.html[Web Client Discovery Integration Javadoc] +* xref:webclient.adoc[Helidon Web Client] \ No newline at end of file diff --git a/docs/src/main/asciidoc/se/webclient.adoc b/docs/src/main/asciidoc/se/webclient.adoc index 23ebee70f62..cdb6bd86d73 100644 --- a/docs/src/main/asciidoc/se/webclient.adoc +++ b/docs/src/main/asciidoc/se/webclient.adoc @@ -407,13 +407,34 @@ include::{sourcedir}/se/WebClientSnippets.java[tag=snippet_8,indent=0] === Adding Service to WebClient -WebClient currently supports 3 built-in services namely `metrics`, `tracing` and `security`. +WebClient currently supports four built-in services, namely +xref:discovery.adoc#_web_client_discovery_integration[`discovery`], `metrics`, `tracing` and `security`. ==== Enabling the service -In order for a service to function, their dependency needs to be added in the application's pom.xml. Below are examples on how to enable the built-in services: +In order for a service to function, its dependencies need to be added in the application's `pom.xml`. Below are examples on how to enable the built-in services: + +* `discovery` (see xref:discovery.adoc#_web_client_discovery_integration[its documentation]) ++ +.`pom.xml` +[source,xml] +---- + + io.helidon.webclient + helidon-webclient-discovery + runtime + + + io.helidon.discovery.providers + helidon-discovery-providers-eureka + runtime + +---- +<1> Backs the `discovery` service with a xref:discovery.adoc#_eureka[Discovery provider based on Netflix's Eureka] * `metrics` ++ +.`pom.xml` [source,xml] ---- @@ -421,7 +442,10 @@ In order for a service to function, their dependency needs to be added in the ap helidon-webclient-metrics ---- + * `tracing` ++ +.`pom.xml` [source,xml] ---- @@ -429,7 +453,10 @@ In order for a service to function, their dependency needs to be added in the ap helidon-webclient-tracing ---- + * `security` ++ +.`pom.xml` [source,xml] ---- @@ -562,6 +589,7 @@ include::{rootdir}/config/io_helidon_webclient_context_WebClientContextService.a * link:{webclient-javadoc-base-url}.http2/module-summary.html[Helidon WebClient HTTP/2 Support] * link:{webclient-javadoc-base-url}.dns.resolver.first/module-summary.html[Helidon WebClient DNS Resolver First Support] * link:{webclient-javadoc-base-url}.dns.resolver.roundrobin/module-summary.html[Helidon WebClient DNS Resolver Round Robin Support] +* link:{webclient-javadoc-base-url}.discovery/module-summary.html[Helidon WebClient Discovery Support] * link:{webclient-javadoc-base-url}.metrics/module-summary.html[Helidon WebClient Metrics Support] * link:{webclient-javadoc-base-url}.security/module-summary.html[Helidon WebClient Security Support] * link:{webclient-javadoc-base-url}.tracing/module-summary.html[Helidon WebClient Tracing Support] diff --git a/webclient/discovery/README.md b/webclient/discovery/README.md new file mode 100644 index 00000000000..8f5a08fa5b4 --- /dev/null +++ b/webclient/discovery/README.md @@ -0,0 +1,6 @@ +# Web Client Discovery + +This module integrates [Helidon Discovery](https://helidon.io/docs/latest/se/discovery) with [Helidon Web +Client](https://helidon.io/docs/latest/se/webclient) by providing a a suitable +[`WebClientService`](https://helidon.io/docs/latest/apidocs/io.helidon.webclient.api/io/helidon/webclient/spi/WebClientService.html) +[implementation](src/main/java/io/helidon/webclient/discovery/WebClientDiscovery.java). diff --git a/webclient/discovery/etc/spotbugs/exclude.xml b/webclient/discovery/etc/spotbugs/exclude.xml new file mode 100644 index 00000000000..ff328ad0aa1 --- /dev/null +++ b/webclient/discovery/etc/spotbugs/exclude.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/webclient/discovery/pom.xml b/webclient/discovery/pom.xml new file mode 100644 index 00000000000..c23ce492a2d --- /dev/null +++ b/webclient/discovery/pom.xml @@ -0,0 +1,196 @@ + + + + 4.0.0 + + io.helidon.webclient + helidon-webclient-project + 4.3.0-SNAPSHOT + + + helidon-webclient-discovery + Helidon WebClient Discovery + + + src/test/resources/logging.properties + true + etc/spotbugs/exclude.xml + + + + + + + io.helidon.builder + helidon-builder-api + + + io.helidon.common + helidon-common-config + + + io.helidon.common + helidon-common-uri + + + io.helidon.discovery + helidon-discovery + + + io.helidon.webclient + helidon-webclient-api + + + + + io.helidon.common + helidon-common + + + io.helidon.common + helidon-common-types + + + io.helidon.config + helidon-config + + + io.helidon.service + helidon-service-registry + + + + + io.helidon.config + helidon-config-yaml + test + + + io.helidon.discovery.providers + helidon-discovery-providers-eureka + test + + + org.hamcrest + hamcrest-all + test + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.helidon.common.features + helidon-common-features-codegen + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.service + helidon-service-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + -Xlint:all + + + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.service + helidon-service-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + check-dependencies + verify + + io.helidon.common:helidon-common-parameters + io.helidon.common:helidon-common-parameters + + + + + + maven-surefire-plugin + + + ${java.util.logging.config.file} + + + + + + + diff --git a/webclient/discovery/src/main/java/io/helidon/webclient/discovery/DefaultWebClientDiscovery.java b/webclient/discovery/src/main/java/io/helidon/webclient/discovery/DefaultWebClientDiscovery.java new file mode 100644 index 00000000000..8d8e600a396 --- /dev/null +++ b/webclient/discovery/src/main/java/io/helidon/webclient/discovery/DefaultWebClientDiscovery.java @@ -0,0 +1,344 @@ +/* + * 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.lang.System.Logger; +import java.net.URI; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Map.Entry; +import java.util.NavigableMap; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.helidon.common.uri.UriInfo; +import io.helidon.webclient.api.ClientUri; +import io.helidon.webclient.api.WebClientServiceRequest; +import io.helidon.webclient.api.WebClientServiceResponse; + +import static java.lang.System.Logger.Level.DEBUG; +import static java.lang.System.Logger.Level.WARNING; +import static java.lang.System.getLogger; +import static java.util.HashMap.newHashMap; +import static java.util.Objects.requireNonNull; +import static java.util.Objects.requireNonNullElse; + +/** + * A {@link WebClientDiscovery} built and returned by the {@link WebClientDiscovery#create(WebClientDiscoveryConfig)} + * method. + * + * @see #handle(Chain, WebClientServiceRequest) + * @see WebClientDiscovery#handle(Chain, WebClientServiceRequest) + * @see WebClientDiscovery#create(WebClientDiscoveryConfig) + */ +final class DefaultWebClientDiscovery implements WebClientDiscovery { + + + /* + * Static fields. + */ + + + private static final Logger LOGGER = getLogger(DefaultWebClientDiscovery.class.getName()); + + + /* + * Instance fields. + */ + + + private final WebClientDiscoveryConfig prototype; + + + /* + * Constructors. + */ + + + DefaultWebClientDiscovery(WebClientDiscoveryConfig prototype) { + super(); + this.prototype = requireNonNull(prototype, "prototype"); + } + + + /* + * Instance methods. + */ + + + /** + * Returns the {@link WebClientDiscoveryConfig} supplied to the invocation of the {@link + * WebClientDiscovery#create(WebClientDiscoveryConfig)} method that created this {@link DefaultWebClientDiscovery}. + * + * @return a non-{@code null} {@link WebClientDiscoveryConfig} + * @see WebClientDiscovery#create(WebClientDiscoveryConfig) + */ + @Override // WebClientDiscovery (RuntimeType.Api) + public WebClientDiscoveryConfig prototype() { + return this.prototype; + } + + @Override // WebClientDiscovery (WebClientService) + public WebClientServiceResponse handle(Chain chain, WebClientServiceRequest request) { + ClientUri clientUri = request.uri(); + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, "Initial ClientUri: " + clientUri + "; properties: " + request.properties()); + } + + DiscoveryRequest discoveryRequest = DiscoveryRequest.of(request.properties(), request.uri()).orElse(null); + if (discoveryRequest == null) { + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, "No discovery needed for " + request.uri()); + } + return chain.proceed(request); + } + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, "DiscoveryRequest: " + discoveryRequest); + } + + URI discoveredUri = this.prototype() + .discovery() + .uris(discoveryRequest.discoveryName(), discoveryRequest.defaultUri()) + .getFirst() + .uri(); + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, "URI discovered for " + discoveryRequest.discoveryName() + ": " + discoveredUri); + } + + // (Edge case. Eureka in particular does not contractually guarantee whether a URI it returns will be opaque or + // not. An opaque URI could conceivably be OK in some possible worlds, but ClientUri doesn't handle opaque + // URIs. Just skip it.) + if (discoveredUri.isOpaque()) { + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, "Discarding discovered URI " + + discoveredUri + + " because it is opaque and ClientUri does not support opaque URIs"); + } + return chain.proceed(request); + } + + // Resolve the extra path against the discovered URI, deliberately using fully defined + // java.net.URI#resolve(String) semantics (which reifies RFC 2396 semantics + // (https://www.rfc-editor.org/rfc/rfc2396#section-5.2)). + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, "Resolving " + discoveryRequest.extraPath() + " against " + discoveredUri); + } + discoveredUri = discoveredUri.resolve(discoveryRequest.extraPath()); + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, "Resolution result: " + discoveredUri); + } + + // Install (raw) path, (possibly) new scheme, host, and port. Deliberately leave existing query and fragment + // alone. + clientUri.path(discoveredUri.getRawPath()); + String discoveredScheme = discoveredUri.getScheme(); + if (discoveredScheme != null) { + clientUri.scheme(discoveredScheme); + } + String discoveredHost = discoveredUri.getHost(); + if (discoveredHost != null) { + clientUri.host(discoveredHost); + } + int discoveredPort = discoveredUri.getPort(); + if (discoveredPort >= 0) { + clientUri.port(discoveredPort); + } + + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, "Final ClientUri: " + clientUri); + } + return chain.proceed(request); + } + + + /* + * Static methods. + */ + + + /** + * Creates and returns a {@link URI} suitable for the supplied {@link String} representation. + * + *

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:

+ * + *
    + * + *
  • a {@link String} representation of {@code http://example.com} when passed through this method will result in + * a {@link URI} that is effectively {@code http://example.com:80/} (note the port and path)
  • + * + *
  • A {@link String} representation of {@code https://example.com} will become {@code https://example.com:443/} + * (note the port and path)
  • + * + *
  • A {@link String representation of {@code path} will become {@code http://localhost:80/path} (note the scheme, + * host, port and path)
  • + * + *
+ * + *

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 of(Map properties, UriInfo uriInfo) { + Map discoveryNamesById = newHashMap(properties.size()); + NavigableMap idsByPrefixUri = new TreeMap<>(); + Set prefixes; + String idsProperty = properties.getOrDefault("helidon-discovery-ids", "").trim(); + if (idsProperty.isBlank()) { + // The user didn't say explicitly which properties will be of interest. Scan. + for (Entry e : properties.entrySet()) { + Matcher m = KEY_PATTERN.matcher(e.getKey()); + if (!m.find()) { + continue; + } + String id = m.group(1); // "ID" in helidon-discovery-ID-... + switch (m.group(2)) { // "name" or "prefix-uri" + case "name": + String discoveryName = requireNonNullElse(e.getValue(), "").trim(); + discoveryNamesById.put(id, discoveryName.isBlank() ? id : discoveryName); + break; + case "prefix-uri": + discoveryNamesById.putIfAbsent(id, id); + String prefix = requireNonNullElse(e.getValue(), "").trim(); + if (!prefix.isBlank()) { + idsByPrefixUri.putIfAbsent(prefix, id); + } + break; + default: + // (Won't happen; see #KEY_PATTERN.) + break; + } + } + prefixes = idsByPrefixUri.descendingKeySet(); + } else { + // The user basically (indirectly) stated which properties will be of interest. Use only those, and in + // that order. + prefixes = new LinkedHashSet<>(); + String[] ids = WHITESPACE_AND_COMMAS_PATTERN.split(idsProperty, 0); + for (String id : ids) { + if (id.isEmpty()) { + continue; + } + String discoveryName = properties.getOrDefault("helidon-discovery-" + id + "-name", "").trim(); + discoveryNamesById.put(id, discoveryName.isBlank() ? id : discoveryName); + String prefix = properties.getOrDefault("helidon-discovery-" + id + "-prefix-uri", "").trim(); + if (!prefix.isBlank()) { + prefixes.add(prefix); + idsByPrefixUri.computeIfAbsent(prefix, p -> id); + } + } + } + // The only thing we need from the UriInfo is its notional URI. So why not just pass a java.net.URI to this + // method instead of a UriInfo if that's all we actually need? Because mutable UriInfo implementations, + // like ClientUri, create (and supply, via toUri()) URIs in a very specific way throughout the WebClient + // machinery, adding and altering values in some places (such as host and port and path), and we need to + // maintain that behavior throughout this WebClientService implementation for overall fidelity with the rest + // of WebClient. See #uri(String) above. + URI uriInfoUri = uriInfo.toUri().normalize(); + for (String prefix : prefixes) { + // Turn the properties-supplied prefix string into a URI. + URI prefixUri; + try { + prefixUri = uri(prefix).normalize(); + } catch (IllegalArgumentException e) { + if (LOGGER.isLoggable(WARNING)) { + LOGGER.log(WARNING, "Ignoring " + prefix + " because it is not a valid URI", e); + } + continue; + } + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, "Testing " + uriInfoUri + " to see if it is prefixed with " + prefixUri); + } + // Relativize the URI in flight against the prefix URI, using the well-defined semantics of + // URI#relativize(String), yielding, if there is a match, in most cases, just a simple path. + URI diff = prefixUri.relativize(uriInfoUri); + if (diff == uriInfoUri) { + // Relativization "failed"; see + // https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/net/URI.html#relativize(java.net.URI). No + // problem; we just didn't match this particular prefix. Carry on to the next one. + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, "Ignoring " + prefixUri + " because it does not prefix " + uriInfoUri); + } + continue; + } + // We matched a prefix and now have the raw materials to query Discovery. Return early. + return + Optional.of(new DiscoveryRequest(discoveryNamesById.get(idsByPrefixUri.get(prefix)), + prefixUri, + diff.getRawPath())); + } + return Optional.empty(); + } + + } + +} diff --git a/webclient/discovery/src/main/java/io/helidon/webclient/discovery/WebClientDiscovery.java b/webclient/discovery/src/main/java/io/helidon/webclient/discovery/WebClientDiscovery.java new file mode 100644 index 00000000000..37409114dd6 --- /dev/null +++ b/webclient/discovery/src/main/java/io/helidon/webclient/discovery/WebClientDiscovery.java @@ -0,0 +1,337 @@ +/* + * 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.util.function.Consumer; + +import io.helidon.builder.api.RuntimeType; +import io.helidon.webclient.api.WebClientServiceRequest; +import io.helidon.webclient.api.WebClientServiceResponse; +import io.helidon.webclient.spi.WebClientService; + +/** + * A {@link WebClientService} that {@linkplain #handle(Chain, WebClientServiceRequest) intercepts} certain {@linkplain + * WebClientServiceRequest requests} and uses {@linkplain io.helidon.discovery.Discovery Helidon Discovery} where + * appropriate to discover endpoints for those requests, rerouting them as necessary. + * + *

{@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.

+ * + *

Logging

+ * + *

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.Api, WebClientService { + + /** + * Handles the supplied {@link WebClientServiceRequest} by finding {@linkplain + * io.helidon.discovery.Discovery Helidon Discovery}-related information in {@linkplain + * WebClientServiceRequest#properties() its properties}, using that information, if found, to {@linkplain + * io.helidon.discovery.Discovery#uris(String, java.net.URI) discover} a {@link java.net.URI URI} appropriate for + * the request, and altering the {@link io.helidon.webclient.api.ClientUri} as necessary with the discovered + * information before continuing with the request. + * + *

The specification of this method is described below, followed by a description of its default + * implementation.

+ * + *

Specification

+ * + *

Any implementation of this method must:

+ * + *
    + * + *
  1. Deterministically decide whether the supplied {@link WebClientServiceRequest} is one for which {@linkplain + * io.helidon.discovery.Discovery Helidon Discovery} is appropriate. If it is not, the implementation must call + * {@link Chain#proceed(WebClientRequest) chain.proceed(request)} and return the result.
  2. + * + *
  3. Deterministically produce a discovery name and a default URI appropriate for the + * supplied {@link WebClientServiceRequest} to use during the discovery process. If either cannot be produced for + * any reason, the implementation must call {@link Chain#proceed(WebClientRequest) chain.proceed(request)} and + * return the result.
  4. + * + *
  5. Use the discovery name and default URI in a {@linkplain io.helidon.discovery.Discovery#uris(String, URI) + * discovery request}. If this cannot happen for any reason, the implementation must call {@link + * Chain#proceed(WebClientRequest) chain.proceed(request)} and return the result.
  6. + * + *
  7. Select a URI from the discovered response (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.
  8. + * + *
  9. Deterministically alter the {@linkplain WebClientServiceRequest#uri() supplied + * 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.
  10. + * + *
  11. Call {@link Chain#proceed(WebClientRequest) chain.proceed(request)} and return the result.
  12. + * + *
+ * + *

Implementation Behavior

+ * + *

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:

+ * + *
    + * + *
  1. The id set is determined from the {@linkplain WebClientServiceRequest#properties() + * properties}. The id set helps identify properties of interest that correlate URIs with discovery + * names. The id set is initially empty.
      + * + *
    1. The id set may be explicit:
        + * + *
      1. If the {@code helidon-discovery-ids} property exists, its value (e.g. SERVICE1, + * SERVICE2) is split into tokens around commas, ignoring whitespace. Each distinct token is + * an id (e.g. SERVICE1 and SERVICE2).
      2. + * + *
      3. The id set is then simply this ordered set of distinct tokens.
    2. + * + *
    3. If, instead, after the step immediately above, the id set is empty for any reason, then it must be + * inferred. For any TOKEN:
        + * + *
      1. If the 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.
      2. + * + *
      3. If the 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.
    4. + * + *
    5. If, after these steps, the id set remains empty, discovery is not needed, and {@link + * Chain#proceed(WebClientServiceRequest) chain.proceed(request)} is invoked and the result is + * returned.
  2. + * + *
  3. For each id ID (e.g. SERVICE1) in the (now non-empty) id + * set:
      + * + *
    1. If present, the value of the 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.
    2. + * + *
    3. Otherwise, a prefix URI is created from the value, following the logic that Helidon WebClient uses + * elsewhere internally to create URIs, for overall fidelity.
        + * + *
      1. Note that this logic may add additional information, or change existing + * information; e.g. in this example the URI would be http://service1.example.com:80/; + * note the explicit port ({@code 80}) and non-empty path that is now {@code /} instead of the empty + * string.
    4. + * + *
    5. If present, the value of the 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.)
  4. + * + *
  5. For every prefix URI found following the process described above, an attempt is made to relativize the + * {@linkplain WebClientServiceRequest#uri() original ClientUri} against it:
      + * + *
    1. The original {@link io.helidon.webclient.api.ClientUri ClientUri}'s {@link + * io.helidon.webclient.api.ClientUri#toUri() URI} (e.g. + * 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/).
        + * + *
      1. Note that for {@link io.helidon.webclient.api.ClientUri}-implementation-related + * reasons, the {@linkplain WebClientServiceRequest#uri() original 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.
    2. + * + *
    3. If relativization yields anything other than a simple raw path, the prefix URI is skipped and the loop + * continues.
    4. + * + *
    5. Otherwise, the loop exits with the prefix URI (e.g. + * http://service1.example.com:80/), discovery name (e.g. SERVICE1), + * and the result of relativization (the remaining raw path, e.g. + * foo)).
  6. + * + *
  7. If no prefix URI was found for any reason, discovery is not needed, {@link + * Chain#proceed(WebClientServiceRequest) chain.proceed(request)} is invoked and the result is returned.
  8. + * + *
  9. The {@linkplain WebClientDiscoveryConfig#discovery() 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/.
  10. + * + *
  11. A new raw path is formed by first {@linkplain java.net.URI#resolve(String) resolving} + * the remaining raw path (e.g. 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).
  12. + * + *
  13. If defined, the {@linkplain java.net.URI#getScheme() scheme} (e.g. 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.
  14. + * + *
  15. The new raw path (e.g. /v1/foo) is installed on the {@linkplain + * WebClientServiceRequest#uri() original ClientUri} using its {@link + * io.helidon.webclient.api.ClientUri#path(String) path(String)} method.
  16. + * + *
  17. This results in the final URI (e.g. 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.

+ * + *
Configuration Examples
+ * + *

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.

+ * + * @param chain a {@link Chain} + * @param request a {@link WebClientServiceRequest} + * @return the result of invoking the {@link Chain#proceed(WebClientServiceRequest) + * proceed(WebClientServiceRequest)} method on the supplied {@link Chain} + * @exception NullPointerException if any argument is {@code null} + * @see WebClientService#handle(Chain, WebClientServiceRequest) + */ + @Override // WebClientService + WebClientServiceResponse handle(Chain chain, WebClientServiceRequest request); + + @Override // WebClientService (NamedService) + default String name() { + return prototype().name(); + } + + /** + * Returns the determinate {@linkplain WebClientService#type() type of this 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(Consumer consumer) { + return builder().update(consumer).build(); + } + + /** + * Returns a new {@link WebClientDiscovery} implementation configured with the supplied {@code prototype}. + * + *

This 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.Factory { + + /** + * A {@link Discovery} (normally sourced from the service registry). + * + * @return a {@link Discovery} + */ + @Option.RegistryService + Discovery discovery(); + + /** + * The name to assign to the runtime type ({@code discovery} by default). + * + * @return a name + * @see io.helidon.common.config.NamedService#name() + */ + @Option.Configured + @Option.Default("discovery") + String name(); + +} diff --git a/webclient/discovery/src/main/java/io/helidon/webclient/discovery/WebClientDiscoveryProvider.java b/webclient/discovery/src/main/java/io/helidon/webclient/discovery/WebClientDiscoveryProvider.java new file mode 100644 index 00000000000..f7f48b4922d --- /dev/null +++ b/webclient/discovery/src/main/java/io/helidon/webclient/discovery/WebClientDiscoveryProvider.java @@ -0,0 +1,75 @@ +/* + * 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.common.config.Config; +import io.helidon.webclient.spi.WebClientServiceProvider; + +/** + * A {@link WebClientServiceProvider} that {@linkplain #create(Config, String) creates} {@link WebClientDiscovery} + * instances. + * + *

Instances 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 segments = up.segments(); + assertThat(segments, empty()); + + c = ClientUri.create(URI.create("http://example.com:80/")); // trailing slash + up = c.path(); + segments = up.segments(); + assertThat(segments, empty()); + + c = ClientUri.create(URI.create("http://example.com:80/;a=b")); + up = c.path(); + segments = up.segments(); + assertThat(segments, hasSize(2)); // ! + + assertThat(segments.get(0).rawValue(), isEmptyString()); // ! + Parameters p = segments.get(0).matrixParameters(); + assertThat(p.component(), is("uri/matrix")); // ? + Collection names = p.names(); + assertThat(names, empty()); + + assertThat(segments.get(1).rawValue(), is(";a=b")); + p = segments.get(1).matrixParameters(); + assertThat(p.component(), is("uri/matrix")); // ? + names = p.names(); + assertThat(names, hasSize(1)); + assertThat(names.iterator().next(), is("a")); + + c = ClientUri.create(URI.create("http://example.com:80/x")); + up = c.path(); + segments = up.segments(); + assertThat(segments, hasSize(2)); // ! + + assertThat(segments.get(0).rawValue(), isEmptyString()); // ! + assertThat(segments.get(1).rawValue(), is("x")); + + c = ClientUri.create(URI.create("http://example.com:80/x;a=b")); + up = c.path(); + segments = up.segments(); + assertThat(segments, hasSize(2)); // ! + + assertThat(segments.get(0).rawValue(), isEmptyString()); // ! + p = segments.get(0).matrixParameters(); + assertThat(p.component(), is("uri/matrix")); // ? + names = p.names(); + assertThat(names, empty()); + + assertThat(segments.get(1).rawValue(), is("x;a=b")); // ! + p = segments.get(1).matrixParameters(); + assertThat(p.component(), is("uri/matrix")); // ? + names = p.names(); + assertThat(names, hasSize(1)); + assertThat(names.iterator().next(), is("a")); + } + + @Test + void testClientUriResolvePathBehavior() { + URI u = URI.create("http://example.com:80/a"); + assertThat(u.resolve("/b"), // note that the path is absolute + is(URI.create("http://example.com:80/b"))); + assertThat(u.resolve("b"), // note that the path is relative + is(URI.create("http://example.com:80/b"))); + + ClientUri c = ClientUri.create(u); + assertThat(c.resolvePath("/b").toUri(), // note that the path is absolute + is(URI.create("http://example.com:80/a/b"))); // ! OK? + + c = ClientUri.create(u); + assertThat(c.resolvePath("b").toUri(), // note that the path is relative + is(URI.create("http://example.com:80/a/b"))); // ! OK? + } + +} diff --git a/webclient/discovery/src/test/java/io/helidon/webclient/discovery/DiscoveryRequestTest.java b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/DiscoveryRequestTest.java new file mode 100644 index 00000000000..9ee177ef277 --- /dev/null +++ b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/DiscoveryRequestTest.java @@ -0,0 +1,120 @@ +/* + * 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.io.UncheckedIOException; +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.logging.LogRecord; + +import io.helidon.config.Config; +import io.helidon.service.registry.Services; +import io.helidon.webclient.api.ClientUri; +import io.helidon.webclient.api.WebClient; +import io.helidon.webclient.discovery.DefaultWebClientDiscovery.DiscoveryRequest; +import org.junit.jupiter.api.Test; + +import static java.util.HashMap.newHashMap; +import static java.util.logging.Level.FINER; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +class DiscoveryRequestTest { + + private DiscoveryRequestTest() { + super(); + } + + @Test + void discoveryRequestTest() { + Map m = newHashMap(7); + m.put("helidon-discovery-ids", " prod ,, test "); + m.put("helidon-discovery-prod-prefix-uri", "http://example.com/prod/"); + m.put("helidon-discovery-test-prefix-uri", "http://example.com/test"); // note lack of trailing slash + m.put("helidon-discovery-test-name", "EXPERIMENTAL"); + + ClientUri u = ClientUri.create(URI.create("http://example.com/prod/foo")); + Optional o = DiscoveryRequest.of(m, u); + assertThat(o.isPresent(), is(true)); + DiscoveryRequest d = o.get(); + assertThat(d.discoveryName(), is("prod")); + assertThat(d.defaultUri(), is(URI.create("http://example.com:80/prod/"))); // note that ClientUri has added :80 internally + assertThat(d.extraPath(), is("foo")); + // By spec, URI path resolution takes /prod/ (trailing slash) + foo (no leading slash) and yields /prod/foo + assertThat(d.defaultUri().resolve(d.extraPath()), is(URI.create("http://example.com:80/prod/foo"))); + + u = ClientUri.create(URI.create("http://example.com/test/foo")); + o = DiscoveryRequest.of(m, u); + assertThat(o.isPresent(), is(true)); + d = o.get(); + assertThat(d.discoveryName(), is("EXPERIMENTAL")); + assertThat(d.defaultUri(), is(URI.create("http://example.com:80/test"))); // note lack of trailing slash, addition of :80 + assertThat(d.extraPath(), is("foo")); + // By spec, URI path resolution takes /test (no trailing slash) + foo (no leading slash) and yields /foo + assertThat(d.defaultUri().resolve(d.extraPath()), is(URI.create("http://example.com:80/foo"))); + } + + @Test + void testCustomDiscoveryNames() { + String[] uris = new String[2]; + Logger l = Logger.getLogger(DefaultWebClientDiscovery.class.getName()); + Handler h = new Handler() { + @Override + public void close() {} + @Override + public void flush() {} + @Override + public void publish(LogRecord r) { + if (r == null) { + return; + } + String message = r.getMessage(); + if (message.startsWith("Initial ClientUri: ")) { + uris[0] = message.substring("Initial ClientUri: ".length()); + } else if (message.startsWith("Final ClientUri: ")) { + uris[1] = message.substring("Final ClientUri: ".length()); + } + } + }; + l.addHandler(h); + WebClient c = WebClient.builder() + .config(Services.get(Config.class).get(this.getClass().getSimpleName() + "-testCustomDiscoveryNames")) + .build(); + Level level = l.getLevel(); + l.setLevel(FINER); + try { + // base-uri is http://prod.example.com/foo; full request therefore will be http://prod.example.com:80/foo/foo + c.get("foo").request(); + } catch (IllegalArgumentException dns) { + assertThat(dns.getMessage(), is("Failed to get address for host prod.example.com")); + } catch (UncheckedIOException expected) { + assertThat(expected.getCause(), is(instanceOf(java.net.SocketTimeoutException.class))); + } finally { + l.removeHandler(h); + l.setLevel(level); + } + // see src/test/resources/application.yaml + assertThat(uris[0], containsString("http://prod.example.com:80/foo/foo")); + assertThat(uris[1], is("http://prod.example.com:80/foo")); + } + +} diff --git a/webclient/discovery/src/test/java/io/helidon/webclient/discovery/URITest.java b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/URITest.java new file mode 100644 index 00000000000..0afab33172c --- /dev/null +++ b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/URITest.java @@ -0,0 +1,106 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.isEmptyString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.sameInstance; + +class URITest { + + private URITest() { + super(); + } + + @Test + void testRelativize() { + // Two absolute (non-schemeless) hierarchical URIs that differ only in their paths. + URI u0 = URI.create("http://example.com"); + assertThat(u0.getPath(), isEmptyString()); + URI u1 = URI.create("http://example.com/"); + assertThat(u1.getPath(), is("/")); + + // Relativize http://example.com/ against http://example.com. + // + // "...a new relative [schemeless] hierarchical URI is constructed...with a path component computed by removing + // this URI's [u0] path [""] from the beginning of the given URI's [u1] path ["/"]." + URI u2 = u0.relativize(u1); + + // See + // https://stackoverflow.com/questions/79787723/why-does-relativizing-a-java-net-uri-with-as-its-path-against-one-with-as + // + // See (maybe) https://bugs.openjdk.org/browse/JDK-6523089 + assertThat(u2.getPath(), isEmptyString()); // ! + + // (For completeness) + assertThat(u2.getScheme(), nullValue()); + assertThat(u2.getHost(), nullValue()); + + // "if the path ["/"] of this URI [u1] is not a prefix of the path [""] of the given URI [u0], then the given + // URI [u0] is returned." + u2 = u1.relativize(u0); + assertThat(u2, sameInstance(u0)); + } + + @Test + void testRelativizeEquals() { + URI u0 = URI.create("http://example.com"); + assertThat(u0.getPath(), isEmptyString()); + URI u1 = URI.create("http://example.com"); + assertThat(u1.getPath(), isEmptyString()); + assertThat(u0, is(u1)); + assertThat(u0.relativize(u1).getPath(), isEmptyString()); + assertThat(u1.relativize(u0).getPath(), isEmptyString()); + } + + @Test + void testResolveAbsolutes() { + // Two absolute (non-schemeless) hierarchical URIs that differ only in their paths, so neither can be resolved + // against the other. + URI u0 = URI.create("http://example.com"); + URI u1 = URI.create("http://example.com/"); + assertThat(u0.resolve(u1), sameInstance(u1)); + assertThat(u1.resolve(u0), sameInstance(u0)); + } + + @Test + void testResolveAgainstEmptyPath() { + URI u0 = URI.create("http://example.com"); + assertThat(u0.getPath(), isEmptyString()); + URI u1 = URI.create("a"); + URI u2 = u0.resolve(u1); + assertThat(u2.getPath(), is("/a")); // note the added / + u1 = URI.create("/a"); + u2 = u0.resolve(u1); + assertThat(u2.getPath(), is("/a")); + u0 = URI.create("http://example.com/"); + assertThat(u0.getPath(), is("/")); + u1 = URI.create("a"); + u2 = u0.resolve(u1); + assertThat(u2.getPath(), is("/a")); + + // (Can't resolve an absolute (non-schemeless) hierarchical URI (u0) against a relative URI (u1).) + u2 = u1.resolve(u0); + assertThat(u2, sameInstance(u0)); + } + +} diff --git a/webclient/discovery/src/test/java/io/helidon/webclient/discovery/UriEncodingTest.java b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/UriEncodingTest.java new file mode 100644 index 00000000000..56f88cfcc5e --- /dev/null +++ b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/UriEncodingTest.java @@ -0,0 +1,48 @@ +/* + * 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 io.helidon.common.uri.UriEncoding; +import io.helidon.webclient.api.ClientUri; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class UriEncodingTest { + + private UriEncodingTest() { + super(); + } + + @Test + void testDecode() { + ClientUri c = ClientUri.create(); + assertThat(c.scheme(), is("http")); // ! + assertThat(c.host(), is("localhost")); // ! + assertThat(c.port(), is(80)); // ! + c.path("A B"); + assertThat(c.path().rawPath(), is("A%20B")); + assertThat(c.path().path(), is("A B")); + assertThat(UriEncoding.decodeUri("A+B"), is("A B")); // ! + assertThat(URI.create("A+B").getPath(), is("A+B")); + assertThat(URI.create("A%20B").getPath(), is("A B")); + } + +} diff --git a/webclient/discovery/src/test/java/io/helidon/webclient/discovery/WebClientConfigTest.java b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/WebClientConfigTest.java new file mode 100644 index 00000000000..58b2c3ac111 --- /dev/null +++ b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/WebClientConfigTest.java @@ -0,0 +1,60 @@ +/* + * 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.util.List; +import java.util.Map; + +import io.helidon.config.Config; +import io.helidon.service.registry.Services; +import io.helidon.webclient.api.WebClientConfig; +import io.helidon.webclient.spi.WebClientService; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.is; + +class WebClientConfigTest { + + private WebClientConfigTest() { + super(); + } + + @Test + void testWebClientDiscoveryIsPickedUpAndConfigured() { + WebClientConfig c = WebClientConfig.builder().buildPrototype(); + List services = c.services(); + assertThat(services, hasSize(1)); + WebClientDiscovery s = (WebClientDiscovery) services.get(0); + // (We have the Eureka provider on the test classpath.) + assertThat(s.prototype().discovery().getClass().getName(), + is("io.helidon.discovery.providers.eureka.EurekaDiscoveryImpl")); + } + + @Test + void testPropertiesFromConfig() { + WebClientConfig c = WebClientConfig.builder() + .config(Services.get(Config.class).get(this.getClass().getSimpleName() + "-testPropertiesFromConfig")) + .buildPrototype(); + Map properties = c.properties(); + assertThat(properties.keySet(), hasSize(2)); + assertThat(properties, hasEntry("helidon-discovery-TEST-prefix-uri", "http://test.example.com/")); + assertThat(properties, hasEntry("helidon-discovery-PROD-prefix-uri", "http://prod.example.com/")); + } + +} diff --git a/webclient/discovery/src/test/java/io/helidon/webclient/discovery/WebClientTest.java b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/WebClientTest.java new file mode 100644 index 00000000000..68c99caa912 --- /dev/null +++ b/webclient/discovery/src/test/java/io/helidon/webclient/discovery/WebClientTest.java @@ -0,0 +1,35 @@ +/* + * 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.webclient.api.WebClient; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class WebClientTest { + + private WebClientTest() { + super(); + } + + @Test + void showThatWebClientRequiresLowercaseHttpAndHttpsSchemesOnly() { + WebClient c = WebClient.builder().build(); + assertThrows(IllegalArgumentException.class, c.get("otherscheme://example.com")::request); // ! + } + +} diff --git a/webclient/discovery/src/test/resources/application.yaml b/webclient/discovery/src/test/resources/application.yaml new file mode 100644 index 00000000000..1c7a300d37f --- /dev/null +++ b/webclient/discovery/src/test/resources/application.yaml @@ -0,0 +1,36 @@ +# +# 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. +# + +WebClientConfigTest-testPropertiesFromConfig: + base-uri: http://prod.example.com/foo + properties: + helidon-discovery-TEST-prefix-uri: http://test.example.com/ + helidon-discovery-PROD-prefix-uri: http://prod.example.com/ + +DiscoveryRequestTest-testCustomDiscoveryNames: + base-uri: http://prod.example.com/foo # ClientUri internals will add ":80" to the authority! + properties: + helidon-discovery-ids: prod, test + helidon-discovery-prod-name: PROD # optional; prod by default + helidon-discovery-prod-prefix-uri: ${DiscoveryRequestTest-testCustomDiscoveryNames.base-uri} + helidon-discovery-test-name: TEST + helidon-discovery-test-prefix-uri: http://test.example.com/foo + +discovery: + eureka: + client: + services-discover-services: false + base-uri: "http://localhost:8761/eureka" diff --git a/webclient/discovery/src/test/resources/logging.properties b/webclient/discovery/src/test/resources/logging.properties new file mode 100644 index 00000000000..409bea17360 --- /dev/null +++ b/webclient/discovery/src/test/resources/logging.properties @@ -0,0 +1,20 @@ +# +# 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. +# + +handlers = java.util.logging.ConsoleHandler +java.util.logging.ConsoleHandler.level = FINER +io.helidon.discovery.level = INFO +io.helidon.webclient.level = INFO diff --git a/webclient/pom.xml b/webclient/pom.xml index b69478f3f3a..a8034cf31d4 100644 --- a/webclient/pom.xml +++ b/webclient/pom.xml @@ -32,6 +32,7 @@ api + discovery dns-resolver http1 http2