From f131f6c041cde518f59b089c1203e2eea3abe223 Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Fri, 15 May 2026 10:25:00 -0400 Subject: [PATCH 1/9] Support for HTTP/3 (client side) Signed-off-by: Andriy Redko --- client/rest-http-client/build.gradle | 145 +++ .../licenses/bc-fips-2.1.2.jar.sha1 | 1 + .../licenses/bctls-fips-2.1.20.jar.sha1 | 1 + .../licenses/bcutil-fips-2.1.4.jar.sha1 | 1 + .../licenses/bouncycastle-LICENSE.txt | 14 + .../licenses/bouncycastle-NOTICE.txt | 1 + .../licenses/commons-codec-1.18.0.jar.sha1 | 1 + .../licenses/commons-codec-LICENSE.txt | 202 +++ .../licenses/commons-codec-NOTICE.txt | 17 + .../licenses/commons-logging-1.3.5.jar.sha1 | 1 + .../licenses/commons-logging-LICENSE.txt | 202 +++ .../licenses/commons-logging-NOTICE.txt | 6 + .../licenses/reactive-streams-1.0.4.jar.sha1 | 1 + .../licenses/reactive-streams-LICENSE.txt | 21 + .../licenses/reactive-streams-NOTICE.txt | 0 .../licenses/reactor-core-3.8.5.jar.sha1 | 1 + .../licenses/reactor-core-LICENSE.txt | 201 +++ .../licenses/reactor-core-NOTICE.txt | 0 .../licenses/slf4j-api-2.0.17.jar.sha1 | 1 + .../licenses/slf4j-api-LICENSE.txt | 21 + .../licenses/slf4j-api-NOTICE.txt | 0 .../httpclient/AsyncResponseProducer.java | 42 + .../org/opensearch/httpclient/BodyUtils.java | 268 ++++ .../opensearch/httpclient/Cancellable.java | 120 ++ .../opensearch/httpclient/DeadHostState.java | 125 ++ .../org/opensearch/httpclient/HttpHost.java | 52 + .../org/opensearch/httpclient/Request.java | 207 ++++ .../opensearch/httpclient/RequestLine.java | 95 ++ .../opensearch/httpclient/RequestLogger.java | 191 +++ .../opensearch/httpclient/RequestOptions.java | 289 +++++ .../org/opensearch/httpclient/Response.java | 124 ++ .../httpclient/ResponseException.java | 61 + .../httpclient/ResponseListener.java | 61 + .../httpclient/ResponseWarningsExtractor.java | 96 ++ .../opensearch/httpclient/RestHttpClient.java | 1095 +++++++++++++++++ .../httpclient/RestHttpClientBuilder.java | 251 ++++ .../org/opensearch/httpclient/StatusLine.java | 77 ++ .../httpclient/StreamingRequest.java | 140 +++ .../httpclient/StreamingResponse.java | 138 +++ .../httpclient/WarningFailureException.java | 79 ++ .../httpclient/WarningsHandler.java | 80 ++ .../opensearch/httpclient/internal/Node.java | 280 +++++ .../httpclient/internal/NodeSelector.java | 108 ++ .../httpclient/BouncyCastleThreadFilter.java | 25 + .../HostsTrackingFailureListener.java | 73 ++ .../HttpClientThreadLeakFilter.java | 24 + .../httpclient/RestClientTestUtil.java | 120 ++ .../RestHttpClientCompressionTests.java | 144 +++ .../RestHttpClientGzipCompressionTests.java | 191 +++ ...RestHttpClientMultipleHostsIntegTests.java | 348 ++++++ .../RestHttpClientMultipleHostsTests.java | 348 ++++++ .../RestHttpClientSingleHostIntegTests.java | 493 ++++++++ ...tpClientSingleHostStreamingIntegTests.java | 176 +++ .../RestHttpClientSingleHostTests.java | 732 +++++++++++ .../httpclient/RestHttpClientTestCase.java | 99 ++ settings.gradle | 1 + 56 files changed, 7591 insertions(+) create mode 100644 client/rest-http-client/build.gradle create mode 100644 client/rest-http-client/licenses/bc-fips-2.1.2.jar.sha1 create mode 100644 client/rest-http-client/licenses/bctls-fips-2.1.20.jar.sha1 create mode 100644 client/rest-http-client/licenses/bcutil-fips-2.1.4.jar.sha1 create mode 100644 client/rest-http-client/licenses/bouncycastle-LICENSE.txt create mode 100644 client/rest-http-client/licenses/bouncycastle-NOTICE.txt create mode 100644 client/rest-http-client/licenses/commons-codec-1.18.0.jar.sha1 create mode 100644 client/rest-http-client/licenses/commons-codec-LICENSE.txt create mode 100644 client/rest-http-client/licenses/commons-codec-NOTICE.txt create mode 100644 client/rest-http-client/licenses/commons-logging-1.3.5.jar.sha1 create mode 100644 client/rest-http-client/licenses/commons-logging-LICENSE.txt create mode 100644 client/rest-http-client/licenses/commons-logging-NOTICE.txt create mode 100644 client/rest-http-client/licenses/reactive-streams-1.0.4.jar.sha1 create mode 100644 client/rest-http-client/licenses/reactive-streams-LICENSE.txt create mode 100644 client/rest-http-client/licenses/reactive-streams-NOTICE.txt create mode 100644 client/rest-http-client/licenses/reactor-core-3.8.5.jar.sha1 create mode 100644 client/rest-http-client/licenses/reactor-core-LICENSE.txt create mode 100644 client/rest-http-client/licenses/reactor-core-NOTICE.txt create mode 100644 client/rest-http-client/licenses/slf4j-api-2.0.17.jar.sha1 create mode 100644 client/rest-http-client/licenses/slf4j-api-LICENSE.txt create mode 100644 client/rest-http-client/licenses/slf4j-api-NOTICE.txt create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/AsyncResponseProducer.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/BodyUtils.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/Cancellable.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/DeadHostState.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/HttpHost.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/Request.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLine.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLogger.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestOptions.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/Response.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseException.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseListener.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseWarningsExtractor.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClientBuilder.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/StatusLine.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingRequest.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingResponse.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningFailureException.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningsHandler.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/Node.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/NodeSelector.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/BouncyCastleThreadFilter.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/HostsTrackingFailureListener.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/HttpClientThreadLeakFilter.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/RestClientTestUtil.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientCompressionTests.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientGzipCompressionTests.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsIntegTests.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsTests.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostIntegTests.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostStreamingIntegTests.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostTests.java create mode 100644 client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientTestCase.java diff --git a/client/rest-http-client/build.gradle b/client/rest-http-client/build.gradle new file mode 100644 index 0000000000000..197e339837945 --- /dev/null +++ b/client/rest-http-client/build.gradle @@ -0,0 +1,145 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import de.thetaphi.forbiddenapis.gradle.CheckForbiddenApis + +apply plugin: 'opensearch.build' +apply plugin: 'opensearch.publish' +apply from: "$rootDir/gradle/fips.gradle" + +java { + targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_21 +} + +base { + group = 'org.opensearch.client' + archivesName = 'opensearch-rest-http-client' +} + +dependencies { + api "commons-codec:commons-codec:${versions.commonscodec}" + api "commons-logging:commons-logging:${versions.commonslogging}" + api "org.slf4j:slf4j-api:${versions.slf4j}" + fipsRuntimeOnly "org.bouncycastle:bc-fips:${versions.bouncycastle_jce}" + fipsRuntimeOnly "org.bouncycastle:bctls-fips:${versions.bouncycastle_tls}" + fipsRuntimeOnly "org.bouncycastle:bcutil-fips:${versions.bouncycastle_util}" + + // reactor + api "io.projectreactor:reactor-core:${versions.reactor}" + api "org.reactivestreams:reactive-streams:${versions.reactivestreams}" + + testImplementation "com.carrotsearch.randomizedtesting:randomizedtesting-runner:${versions.randomizedrunner}" + testImplementation "junit:junit:${versions.junit}" + testImplementation "org.hamcrest:hamcrest:${versions.hamcrest}" + testImplementation "org.mockito:mockito-core:${versions.mockito}" + testImplementation "org.objenesis:objenesis:${versions.objenesis}" + testImplementation "net.bytebuddy:byte-buddy:${versions.bytebuddy}" + testImplementation "net.bytebuddy:byte-buddy-agent:${versions.bytebuddy}" + testImplementation "org.apache.logging.log4j:log4j-api:${versions.log4j}" + testImplementation "org.apache.logging.log4j:log4j-core:${versions.log4j}" + testImplementation "org.apache.logging.log4j:log4j-jul:${versions.log4j}" + testImplementation "org.apache.logging.log4j:log4j-slf4j2-impl:${versions.log4j}" + testImplementation "io.projectreactor:reactor-test:${versions.reactor}" +} + +tasks.named("dependencyLicenses").configure { + mapping from: /bc.*/, to: 'bouncycastle' +} + +tasks.withType(CheckForbiddenApis).configureEach { + //client does not depend on server, so only jdk and http signatures should be checked + replaceSignatureFiles('jdk-signatures') +} + +tasks.named('forbiddenApisTest').configure { + //we are using jdk-internal instead of jdk-non-portable to allow for com.sun.net.httpserver.* usage + bundledSignatures -= 'jdk-non-portable' + bundledSignatures += 'jdk-internal' +} + +// JarHell is part of es server, which we don't want to pull in +// TODO: Not anymore. Now in :libs:opensearch-core +jarHell.enabled = false + +testingConventions { + naming.clear() + naming { + Tests { + baseClass 'org.opensearch.httpclient.RestHttpClientTestCase' + } + } +} + +thirdPartyAudit { + ignoreMissingClasses( + //commons-logging optional dependencies + 'org.apache.avalon.framework.logger.Logger', + 'org.apache.log.Hierarchy', + 'org.apache.log.Logger', + 'org.apache.log4j.Level', + 'org.apache.log4j.Logger', + 'org.apache.log4j.Priority', + 'org.apache.logging.log4j.Level', + 'org.apache.logging.log4j.LogManager', + 'org.apache.logging.log4j.Marker', + 'org.apache.logging.log4j.MarkerManager', + 'org.apache.logging.log4j.spi.AbstractLoggerAdapter', + 'org.apache.logging.log4j.spi.ExtendedLogger', + 'org.apache.logging.log4j.spi.LoggerAdapter', + 'org.apache.logging.log4j.spi.LoggerContext', + 'org.apache.logging.log4j.spi.LoggerContextFactory', + 'org.apache.logging.log4j.util.StackLocatorUtil', + //commons-logging provided dependencies + 'javax.servlet.ServletContextEvent', + 'javax.servlet.ServletContextListener', + 'io.micrometer.context.ContextAccessor', + 'io.micrometer.context.ContextRegistry', + 'io.micrometer.context.ContextSnapshot', + 'io.micrometer.context.ContextSnapshot$Scope', + 'io.micrometer.context.ContextSnapshotFactory', + 'io.micrometer.context.ContextSnapshotFactory$Builder', + 'io.micrometer.context.ThreadLocalAccessor', + 'io.micrometer.core.instrument.Clock', + 'io.micrometer.core.instrument.Counter', + 'io.micrometer.core.instrument.Counter$Builder', + 'io.micrometer.core.instrument.DistributionSummary', + 'io.micrometer.core.instrument.DistributionSummary$Builder', + 'io.micrometer.core.instrument.Meter', + 'io.micrometer.core.instrument.MeterRegistry', + 'io.micrometer.core.instrument.Metrics', + 'io.micrometer.core.instrument.Tag', + 'io.micrometer.core.instrument.Tags', + 'io.micrometer.core.instrument.Timer', + 'io.micrometer.core.instrument.Timer$Builder', + 'io.micrometer.core.instrument.Timer$Sample', + 'io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics', + 'io.micrometer.core.instrument.composite.CompositeMeterRegistry', + 'io.micrometer.core.instrument.search.Search', + 'reactor.blockhound.BlockHound$Builder', + 'reactor.blockhound.integration.BlockHoundIntegration' + ) + ignoreViolations( + 'reactor.core.publisher.CallSiteSupplierFactory$SharedSecretsCallSiteSupplierFactory', + 'reactor.core.publisher.CallSiteSupplierFactory$SharedSecretsCallSiteSupplierFactory$TracingException' + ) +} + +tasks.withType(JavaCompile) { + // Suppressing '[options] target value 8 is obsolete and will be removed in a future release' + configure(options) { + options.compilerArgs << '-Xlint:-options' + } +} + +tasks.named("missingJavadoc").configure { + it.enabled = false +} diff --git a/client/rest-http-client/licenses/bc-fips-2.1.2.jar.sha1 b/client/rest-http-client/licenses/bc-fips-2.1.2.jar.sha1 new file mode 100644 index 0000000000000..bd2f333cb12d0 --- /dev/null +++ b/client/rest-http-client/licenses/bc-fips-2.1.2.jar.sha1 @@ -0,0 +1 @@ +061fbe8383f70489dda95a11a2a4739eb818ff2c \ No newline at end of file diff --git a/client/rest-http-client/licenses/bctls-fips-2.1.20.jar.sha1 b/client/rest-http-client/licenses/bctls-fips-2.1.20.jar.sha1 new file mode 100644 index 0000000000000..7266ec5abf10a --- /dev/null +++ b/client/rest-http-client/licenses/bctls-fips-2.1.20.jar.sha1 @@ -0,0 +1 @@ +9c0632a6c5ca09a86434cf5e02e72c221e1c930f \ No newline at end of file diff --git a/client/rest-http-client/licenses/bcutil-fips-2.1.4.jar.sha1 b/client/rest-http-client/licenses/bcutil-fips-2.1.4.jar.sha1 new file mode 100644 index 0000000000000..73b19722430fb --- /dev/null +++ b/client/rest-http-client/licenses/bcutil-fips-2.1.4.jar.sha1 @@ -0,0 +1 @@ +1d37b7a28560684f5b8e4fd65478c9130d4015d0 \ No newline at end of file diff --git a/client/rest-http-client/licenses/bouncycastle-LICENSE.txt b/client/rest-http-client/licenses/bouncycastle-LICENSE.txt new file mode 100644 index 0000000000000..5c7c14696849d --- /dev/null +++ b/client/rest-http-client/licenses/bouncycastle-LICENSE.txt @@ -0,0 +1,14 @@ +Copyright (c) 2000 - 2023 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/client/rest-http-client/licenses/bouncycastle-NOTICE.txt b/client/rest-http-client/licenses/bouncycastle-NOTICE.txt new file mode 100644 index 0000000000000..8b137891791fe --- /dev/null +++ b/client/rest-http-client/licenses/bouncycastle-NOTICE.txt @@ -0,0 +1 @@ + diff --git a/client/rest-http-client/licenses/commons-codec-1.18.0.jar.sha1 b/client/rest-http-client/licenses/commons-codec-1.18.0.jar.sha1 new file mode 100644 index 0000000000000..01a6a8f446302 --- /dev/null +++ b/client/rest-http-client/licenses/commons-codec-1.18.0.jar.sha1 @@ -0,0 +1 @@ +ee45d1cf6ec2cc2b809ff04b4dc7aec858e0df8f \ No newline at end of file diff --git a/client/rest-http-client/licenses/commons-codec-LICENSE.txt b/client/rest-http-client/licenses/commons-codec-LICENSE.txt new file mode 100644 index 0000000000000..d645695673349 --- /dev/null +++ b/client/rest-http-client/licenses/commons-codec-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/client/rest-http-client/licenses/commons-codec-NOTICE.txt b/client/rest-http-client/licenses/commons-codec-NOTICE.txt new file mode 100644 index 0000000000000..1da9af50f6008 --- /dev/null +++ b/client/rest-http-client/licenses/commons-codec-NOTICE.txt @@ -0,0 +1,17 @@ +Apache Commons Codec +Copyright 2002-2014 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +src/test/org/apache/commons/codec/language/DoubleMetaphoneTest.java +contains test data from http://aspell.net/test/orig/batch0.tab. +Copyright (C) 2002 Kevin Atkinson (kevina@gnu.org) + +=============================================================================== + +The content of package org.apache.commons.codec.language.bm has been translated +from the original php source code available at http://stevemorse.org/phoneticinfo.htm +with permission from the original authors. +Original source copyright: +Copyright (c) 2008 Alexander Beider & Stephen P. Morse. diff --git a/client/rest-http-client/licenses/commons-logging-1.3.5.jar.sha1 b/client/rest-http-client/licenses/commons-logging-1.3.5.jar.sha1 new file mode 100644 index 0000000000000..f7ddad61aaeaa --- /dev/null +++ b/client/rest-http-client/licenses/commons-logging-1.3.5.jar.sha1 @@ -0,0 +1 @@ +a3fcc5d3c29b2b03433aa2d2f2d2c1b1638924a1 \ No newline at end of file diff --git a/client/rest-http-client/licenses/commons-logging-LICENSE.txt b/client/rest-http-client/licenses/commons-logging-LICENSE.txt new file mode 100644 index 0000000000000..d645695673349 --- /dev/null +++ b/client/rest-http-client/licenses/commons-logging-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/client/rest-http-client/licenses/commons-logging-NOTICE.txt b/client/rest-http-client/licenses/commons-logging-NOTICE.txt new file mode 100644 index 0000000000000..a37977d45a168 --- /dev/null +++ b/client/rest-http-client/licenses/commons-logging-NOTICE.txt @@ -0,0 +1,6 @@ +Apache Commons Logging +Copyright 2003-2013 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + diff --git a/client/rest-http-client/licenses/reactive-streams-1.0.4.jar.sha1 b/client/rest-http-client/licenses/reactive-streams-1.0.4.jar.sha1 new file mode 100644 index 0000000000000..45a80e3f7e361 --- /dev/null +++ b/client/rest-http-client/licenses/reactive-streams-1.0.4.jar.sha1 @@ -0,0 +1 @@ +3864a1320d97d7b045f729a326e1e077661f31b7 \ No newline at end of file diff --git a/client/rest-http-client/licenses/reactive-streams-LICENSE.txt b/client/rest-http-client/licenses/reactive-streams-LICENSE.txt new file mode 100644 index 0000000000000..1e3c7e7c77495 --- /dev/null +++ b/client/rest-http-client/licenses/reactive-streams-LICENSE.txt @@ -0,0 +1,21 @@ +MIT No Attribution + +Copyright 2014 Reactive Streams + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/client/rest-http-client/licenses/reactive-streams-NOTICE.txt b/client/rest-http-client/licenses/reactive-streams-NOTICE.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/client/rest-http-client/licenses/reactor-core-3.8.5.jar.sha1 b/client/rest-http-client/licenses/reactor-core-3.8.5.jar.sha1 new file mode 100644 index 0000000000000..19e8d37abd9d7 --- /dev/null +++ b/client/rest-http-client/licenses/reactor-core-3.8.5.jar.sha1 @@ -0,0 +1 @@ +e3afac59d35d67364b64389e2ed7cd274829df71 \ No newline at end of file diff --git a/client/rest-http-client/licenses/reactor-core-LICENSE.txt b/client/rest-http-client/licenses/reactor-core-LICENSE.txt new file mode 100644 index 0000000000000..e5583c184e67a --- /dev/null +++ b/client/rest-http-client/licenses/reactor-core-LICENSE.txt @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + 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 + + https://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. diff --git a/client/rest-http-client/licenses/reactor-core-NOTICE.txt b/client/rest-http-client/licenses/reactor-core-NOTICE.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/client/rest-http-client/licenses/slf4j-api-2.0.17.jar.sha1 b/client/rest-http-client/licenses/slf4j-api-2.0.17.jar.sha1 new file mode 100644 index 0000000000000..435f6c13a28b6 --- /dev/null +++ b/client/rest-http-client/licenses/slf4j-api-2.0.17.jar.sha1 @@ -0,0 +1 @@ +d9e58ac9c7779ba3bf8142aff6c830617a7fe60f \ No newline at end of file diff --git a/client/rest-http-client/licenses/slf4j-api-LICENSE.txt b/client/rest-http-client/licenses/slf4j-api-LICENSE.txt new file mode 100644 index 0000000000000..8fda22f4d72f6 --- /dev/null +++ b/client/rest-http-client/licenses/slf4j-api-LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) 2004-2014 QOS.ch +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/client/rest-http-client/licenses/slf4j-api-NOTICE.txt b/client/rest-http-client/licenses/slf4j-api-NOTICE.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/AsyncResponseProducer.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/AsyncResponseProducer.java new file mode 100644 index 0000000000000..47d3f2916c8ad --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/AsyncResponseProducer.java @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Vector; +import java.util.concurrent.Flow.Subscriber; +import java.util.concurrent.Flow.Subscription; + +class AsyncResponseProducer implements Subscriber> { + private Subscription subscription; + private final List buffers = new Vector<>(); + + @Override + public void onSubscribe(Subscription subscription) { + this.subscription = subscription; + subscription.request(1); + } + + @Override + public void onNext(List item) { + buffers.addAll(item); + subscription.request(1); + } + + @Override + public void onError(Throwable throwable) {} + + @Override + public void onComplete() {} + + public List getResult() { + return buffers; + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/BodyUtils.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/BodyUtils.java new file mode 100644 index 0000000000000..6cb34bf2ba0fe --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/BodyUtils.java @@ -0,0 +1,268 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Flow; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +import reactor.adapter.JdkFlowAdapter; +import reactor.core.publisher.Mono; + +/** + * Helper class to deal with request / response bodies. + */ +final class BodyUtils { + static Mono getBody(HttpRequest httpRequest) { + return httpRequest.bodyPublisher().map(JdkFlowAdapter::flowPublisherToFlux).map(Mono::from).orElseGet(Mono::empty); + } + + static String getBodyAsString(Response response) { + return getBodyAsString(response.getEntity()); + } + + static Mono getBodyAsString(HttpRequest httpRequest) { + return httpRequest.bodyPublisher().map(p -> { + var bodySubscriber = HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8); + var flowSubscriber = new StringSubscriber(bodySubscriber); + p.subscribe(flowSubscriber); + return Mono.fromCompletionStage(bodySubscriber.getBody()); + }).orElseGet(Mono::empty); + } + + static String getBodyAsString(List body) { + final StringBuilder builder = new StringBuilder(); + if (body != null && body.isEmpty() == false) { + for (ByteBuffer chunk : body) { + chunk.mark(); + builder.append(StandardCharsets.UTF_8.decode(chunk).toString()); + chunk.reset(); + } + } + return builder.toString(); + } + + static List compress(List body) { + if (body == null || body.isEmpty()) { + return body; + } else { + body.stream().forEach(ByteBuffer::mark); + try (ByteBufferOutputStream bbout = new ByteBufferOutputStream(); OutputStream out = new GZIPOutputStream(bbout)) { + try (WritableByteChannel channel = Channels.newChannel(out)) { + for (ByteBuffer buffer : body) { + channel.write(buffer); + } + out.flush(); + } + return bbout.getBufferList(); + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } finally { + body.stream().forEach(ByteBuffer::reset); + } + } + } + + static ByteBuffer compress(ByteBuffer body) { + if (body == null) { + return body; + } else { + body.mark(); + try (ByteBufferOutputStream bbout = new ByteBufferOutputStream(); OutputStream out = new GZIPOutputStream(bbout)) { + try (WritableByteChannel channel = Channels.newChannel(out)) { + channel.write(body); + out.flush(); + } + + final List bufferList = bbout.getBufferList(); + if (bufferList.isEmpty() == false) { + return bufferList.get(0); + } else { + // We should never end up here + return ByteBuffer.allocate(0); /* empty byte buffer */ + } + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } finally { + body.reset(); + } + } + } + + static ByteBuffer decompress(ByteBuffer body) { + if (body == null) { + return body; + } else { + body.mark(); + + try (ByteBufferInputStream bbin = new ByteBufferInputStream(List.of(body)); InputStream in = new GZIPInputStream(bbin)) { + return ByteBuffer.wrap(in.readAllBytes()); + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } finally { + body.reset(); + } + } + } + + static List decompress(List body) { + if (body == null || body.isEmpty()) { + return body; + } else { + body.stream().forEach(ByteBuffer::mark); + try (ByteBufferInputStream bbin = new ByteBufferInputStream(body); InputStream in = new GZIPInputStream(bbin)) { + return List.of(ByteBuffer.wrap(in.readAllBytes())); + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } finally { + body.stream().forEach(ByteBuffer::reset); + } + } + } + + /** + * See please https://github.com/justinsb/avro/blob/master/src/java/org/apache/avro/ipc/ByteBufferOutputStream.java + */ + private final static class ByteBufferOutputStream extends OutputStream { + private static final int BUFFER_SIZE = 8192; + private List buffers; + + ByteBufferOutputStream() { + reset(); + } + + /** Returns all data written and resets the stream to be empty. */ + List getBufferList() { + List result = buffers; + reset(); + for (ByteBuffer buffer : result) { + buffer.flip(); + } + return result; + } + + private void reset() { + buffers = new ArrayList(1); + buffers.add(ByteBuffer.allocate(BUFFER_SIZE)); + } + + @Override + public void write(int b) { + ByteBuffer buffer = buffers.get(buffers.size() - 1); + if (buffer.remaining() < 1) { + buffer = ByteBuffer.allocate(BUFFER_SIZE); + buffers.add(buffer); + } + buffer.put((byte) b); + } + + @Override + public void write(byte[] b, int off, int len) { + ByteBuffer buffer = buffers.get(buffers.size() - 1); + int remaining = buffer.remaining(); + while (len > remaining) { + buffer.put(b, off, remaining); + len -= remaining; + off += remaining; + buffer = ByteBuffer.allocate(BUFFER_SIZE); + buffers.add(buffer); + remaining = buffer.remaining(); + } + buffer.put(b, off, len); + } + } + + /** + * See please https://github.com/justinsb/avro/blob/master/src/java/org/apache/avro/ipc/ByteBufferInputStream.java + */ + private static final class ByteBufferInputStream extends InputStream { + private List buffers; + private int current; + + ByteBufferInputStream(List buffers) { + this.buffers = buffers; + } + + /** @see InputStream#read() + * @throws EOFException if EOF is reached. */ + @Override + public int read() throws IOException { + return getBuffer().get() & 0xff; + } + + /** @see InputStream#read(byte[], int, int) + * @throws EOFException if EOF is reached before reading all the bytes. */ + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (len == 0) return 0; + ByteBuffer buffer = getBuffer(); + int remaining = buffer.remaining(); + if (len > remaining) { + buffer.get(b, off, remaining); + return remaining; + } else { + buffer.get(b, off, len); + return len; + } + } + + /** Returns the next non-empty buffer. + * @throws EOFException if EOF is reached before reading all the bytes. + */ + private ByteBuffer getBuffer() throws IOException { + while (current < buffers.size()) { + ByteBuffer buffer = buffers.get(current); + if (buffer.hasRemaining()) return buffer; + current++; + } + throw new EOFException(); + } + } + + private static final class StringSubscriber implements Flow.Subscriber { + final HttpResponse.BodySubscriber delegate; + + StringSubscriber(HttpResponse.BodySubscriber delegate) { + this.delegate = delegate; + } + + @Override + public void onSubscribe(Flow.Subscription subscription) { + delegate.onSubscribe(subscription); + } + + @Override + public void onNext(ByteBuffer item) { + delegate.onNext(List.of(item)); + } + + @Override + public void onError(Throwable throwable) { + delegate.onError(throwable); + } + + @Override + public void onComplete() { + delegate.onComplete(); + } + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/Cancellable.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/Cancellable.java new file mode 100644 index 0000000000000..86ba31d1c265c --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/Cancellable.java @@ -0,0 +1,120 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import java.io.IOException; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.Future; + +/** + * Represents an operation that can be cancelled. + * Returned when executing async requests through {@link RestHttpClient#performRequestAsync(Request, ResponseListener)}, so that the request + * can be cancelled if needed. Cancelling a request will result in calling {@link Future#cancel(boolean mayInterruptIfRunning)} on the + * underlying request future object. Note that cancelling a request does not automatically translate to aborting its execution on the + * server side, which needs to be specifically implemented in each API. + */ +public class Cancellable { + static final Cancellable NO_OP = new Cancellable(null) { + @Override + public boolean cancel() { + throw new UnsupportedOperationException(); + } + + @Override + void runIfNotCancelled(Runnable runnable) { + throw new UnsupportedOperationException(); + } + }; + + static Cancellable fromFuture(Future f) { + return new Cancellable(f); + } + + private final Future future; + + private Cancellable(Future f) { + this.future = f; + } + + /** + * Cancels the on-going request that is associated with the current instance of {@link Cancellable}. + * + */ + public synchronized boolean cancel() { + return this.future.cancel(true); + } + + /** + * Executes some arbitrary code if the on-going request has not been cancelled, otherwise throws {@link CancellationException}. + * This is needed to guarantee that cancelling a request works correctly even in case {@link #cancel()} is called between different + * attempts of the same request. + * If the request has already been cancelled we don't go ahead with the next attempt, and artificially raise the + * {@link CancellationException}, otherwise we run the provided {@link Runnable} which will reset the request and send the next attempt. + * Note that this method must be synchronized as well as the {@link #cancel()} method, to prevent a request from being cancelled + * when there is no future to cancel, which would make cancelling the request a no-op. + */ + synchronized void runIfNotCancelled(Runnable runnable) { + if (this.future.isCancelled()) { + throw newCancellationException(); + } + runnable.run(); + } + + /** + * Executes some arbitrary code if the on-going request has not been cancelled, otherwise throws {@link CancellationException}. + * This is needed to guarantee that cancelling a request works correctly even in case {@link #cancel()} is called between different + * attempts of the same request. The {@link #cancel()} method can be called at anytime, + * and we need to handle the case where it gets called while there is no request being executed as one attempt may have failed and + * the subsequent attempt has not been started yet. + * If the request has already been cancelled we don't go ahead with the next attempt, and artificially raise the + * {@link CancellationException}, otherwise we run the provided {@link Runnable} which will reset the request and send the next attempt. + * Note that this method must be synchronized as well as the {@link #cancel()} method, to prevent a request from being cancelled + * when there is no future to cancel, which would make cancelling the request a no-op. + */ + synchronized T callIfNotCancelled(Callable callable) throws IOException { + if (this.future.isCancelled()) { + throw newCancellationException(); + } + try { + return callable.call(); + } catch (final IOException ex) { + throw ex; + } catch (final Exception ex) { + throw new IOException(ex); + } + } + + static CancellationException newCancellationException() { + return new CancellationException("request was cancelled"); + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/DeadHostState.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/DeadHostState.java new file mode 100644 index 0000000000000..66ebc0f87716b --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/DeadHostState.java @@ -0,0 +1,125 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * Holds the state of a dead connection to a host. Keeps track of how many failed attempts were performed and + * when the host should be retried (based on number of previous failed attempts). + * Class is immutable, a new copy of it should be created each time the state has to be changed. + */ +final class DeadHostState implements Comparable { + + private static final long MIN_CONNECTION_TIMEOUT_NANOS = TimeUnit.MINUTES.toNanos(1); + static final long MAX_CONNECTION_TIMEOUT_NANOS = TimeUnit.MINUTES.toNanos(30); + static final Supplier DEFAULT_TIME_SUPPLIER = System::nanoTime; + + private final int failedAttempts; + private final long deadUntilNanos; + private final Supplier timeSupplier; + + /** + * Build the initial dead state of a host. Useful when a working host stops functioning + * and needs to be marked dead after its first failure. In such case the host will be retried after a minute or so. + * + * @param timeSupplier a way to supply the current time and allow for unit testing + */ + DeadHostState(Supplier timeSupplier) { + this.failedAttempts = 1; + this.deadUntilNanos = timeSupplier.get() + MIN_CONNECTION_TIMEOUT_NANOS; + this.timeSupplier = timeSupplier; + } + + /** + * Build the dead state of a host given its previous dead state. Useful when a host has been failing before, hence + * it already failed for one or more consecutive times. The more failed attempts we register the longer we wait + * to retry that same host again. Minimum is 1 minute (for a node the only failed once created + * through {@link #DeadHostState(Supplier)}), maximum is 30 minutes (for a node that failed more than 10 consecutive times) + * + * @param previousDeadHostState the previous state of the host which allows us to increase the wait till the next retry attempt + */ + DeadHostState(DeadHostState previousDeadHostState) { + long timeoutNanos = (long) Math.min( + MIN_CONNECTION_TIMEOUT_NANOS * 2 * Math.pow(2, previousDeadHostState.failedAttempts * 0.5 - 1), + MAX_CONNECTION_TIMEOUT_NANOS + ); + this.deadUntilNanos = previousDeadHostState.timeSupplier.get() + timeoutNanos; + this.failedAttempts = previousDeadHostState.failedAttempts + 1; + this.timeSupplier = previousDeadHostState.timeSupplier; + } + + /** + * Indicates whether it's time to retry to failed host or not. + * + * @return true if the host should be retried, false otherwise + */ + boolean shallBeRetried() { + return timeSupplier.get() - deadUntilNanos > 0; + } + + /** + * Returns the timestamp (nanos) till the host is supposed to stay dead without being retried. + * After that the host should be retried. + */ + long getDeadUntilNanos() { + return deadUntilNanos; + } + + int getFailedAttempts() { + return failedAttempts; + } + + @Override + public int compareTo(DeadHostState other) { + if (timeSupplier != other.timeSupplier) { + throw new IllegalArgumentException( + "can't compare DeadHostStates holding different time suppliers as they may " + "be based on different clocks" + ); + } + return Long.compare(deadUntilNanos, other.deadUntilNanos); + } + + @Override + public String toString() { + return "DeadHostState{" + + "failedAttempts=" + + failedAttempts + + ", deadUntilNanos=" + + deadUntilNanos + + ", timeSupplier=" + + timeSupplier + + '}'; + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/HttpHost.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/HttpHost.java new file mode 100644 index 0000000000000..2c8fd25f89f3c --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/HttpHost.java @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Objects; + +/** + * Represents HTTP host (scheme, hostname and port) + * Note: This is an experimental API. + */ +public record HttpHost(String scheme, String hostname, int port) { + /** + * Converts the host to string + */ + @Override + public String toString() { + final StringBuilder buffer = new StringBuilder(); + buffer.append(this.scheme); + buffer.append("://"); + buffer.append(this.hostname); + if (this.port != -1) { + buffer.append(':'); + buffer.append(Integer.toString(this.port)); + } + return buffer.toString(); + } + + public static HttpHost create(String uriStr) throws URISyntaxException { + Objects.requireNonNull(uriStr); + + String text = uriStr; + String scheme = null; + final int schemeIdx = text.indexOf("://"); + if (schemeIdx > 0) { + scheme = text.substring(0, schemeIdx); + if (scheme.isBlank()) { + throw new URISyntaxException(uriStr, "scheme contains blanks"); + } + } + + final URI uri = new URI(uriStr); + return new HttpHost(scheme, uri.getHost(), uri.getPort()); + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/Request.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/Request.java new file mode 100644 index 0000000000000..9a0b8781f6ea1 --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/Request.java @@ -0,0 +1,207 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.net.http.HttpRequest.BodyPublisher; +import java.net.http.HttpRequest.BodyPublishers; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static java.util.Collections.unmodifiableMap; + +/** + * HTTP Request to OpenSearch. + * Note: This is an experimental API. + */ +public final class Request { + private final String method; + private final String endpoint; + private final Map parameters = new HashMap<>(); + + private BodyPublisher entity; + private RequestOptions options = RequestOptions.DEFAULT; + + /** + * Create the {@linkplain Request}. + * @param method the HTTP method + * @param endpoint the path of the request (without scheme, host, port, or prefix) + */ + public Request(String method, String endpoint) { + this.method = Objects.requireNonNull(method, "method cannot be null"); + this.endpoint = Objects.requireNonNull(endpoint, "endpoint cannot be null"); + } + + /** + * The HTTP method. + */ + public String getMethod() { + return method; + } + + /** + * The path of the request (without scheme, host, port, or prefix). + */ + public String getEndpoint() { + return endpoint; + } + + /** + * Add a query string parameter. + * @param name the name of the url parameter. Must not be null. + * @param value the value of the url url parameter. If {@code null} then + * the parameter is sent as {@code name} rather than {@code name=value} + * @throws IllegalArgumentException if a parameter with that name has + * already been set + */ + public void addParameter(String name, String value) { + Objects.requireNonNull(name, "url parameter name cannot be null"); + if (parameters.containsKey(name)) { + throw new IllegalArgumentException("url parameter [" + name + "] has already been set to [" + parameters.get(name) + "]"); + } else { + parameters.put(name, value); + } + } + + /** + * Add query parameters using the provided map of key value pairs. + * + * @param paramSource a map of key value pairs where the key is the url parameter. + * @throws IllegalArgumentException if a parameter with that name has already been set. + */ + public void addParameters(Map paramSource) { + paramSource.forEach(this::addParameter); + } + + /** + * Query string parameters. The returned map is an unmodifiable view of the + * map in the request so calls to {@link #addParameter(String, String)} + * will change it. + */ + public Map getParameters() { + if (options.getParameters().isEmpty()) { + return unmodifiableMap(parameters); + } else { + Map combinedParameters = new HashMap<>(parameters); + combinedParameters.putAll(options.getParameters()); + return unmodifiableMap(combinedParameters); + } + } + + /** + * Set the body of the request. If not set or set to {@code null} then no + * body is sent with the request. + * + * @param entity the {@link BodyPublisher} to be set as the body of the request. + */ + public void setEntity(BodyPublisher entity) { + this.entity = entity; + } + + /** + * Set the body of the request to a string. If not set or set to + * {@code null} then no body is sent with the request. The + * {@code Content-Type} will be sent as {@code application/json}. + * If you need a different content type then use + * {@link #setEntity(BodyPublisher)}. + * + * @param entity JSON string to be set as the entity body of the request. + */ + public void setJsonEntity(String entity) { + setEntity(entity == null ? BodyPublishers.noBody() : BodyPublishers.ofString(entity)); + } + + /** + * The body of the request. If {@code null} then no body + * is sent with the request. + */ + public BodyPublisher getEntity() { + return entity; + } + + /** + * Set the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * + * @param options the options to be set. + * @throws NullPointerException if {@code options} is null. + */ + public void setOptions(RequestOptions options) { + Objects.requireNonNull(options, "options cannot be null"); + this.options = options; + } + + /** + * Set the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * + * @param options the options to be set. + * @throws NullPointerException if {@code options} is null. + */ + public void setOptions(RequestOptions.Builder options) { + Objects.requireNonNull(options, "options cannot be null"); + this.options = options.build(); + } + + /** + * Get the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + */ + public RequestOptions getOptions() { + return options; + } + + /** + * Convert request to string representation + */ + @Override + public String toString() { + StringBuilder b = new StringBuilder(); + b.append("Request{"); + b.append("method='").append(method).append('\''); + b.append(", endpoint='").append(endpoint).append('\''); + if (false == parameters.isEmpty()) { + b.append(", params=").append(parameters); + } + if (entity != null) { + b.append(", entity=").append(entity); + } + b.append(", options=").append(options); + return b.append('}').toString(); + } + + /** + * Compare two requests for equality + * @param obj request instance to compare with + */ + @Override + public boolean equals(Object obj) { + if (obj == null || (obj.getClass() != getClass())) { + return false; + } + if (obj == this) { + return true; + } + + Request other = (Request) obj; + return method.equals(other.method) + && endpoint.equals(other.endpoint) + && parameters.equals(other.parameters) + && Objects.equals(entity, other.entity) + && options.equals(other.options); + } + + /** + * Calculate the hash code of the request + */ + @Override + public int hashCode() { + return Objects.hash(method, endpoint, parameters, entity, options); + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLine.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLine.java new file mode 100644 index 0000000000000..665ba9d02506f --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLine.java @@ -0,0 +1,95 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.io.Serializable; +import java.net.URI; +import java.net.http.HttpClient.Version; +import java.net.http.HttpRequest; +import java.util.Objects; + +/** + * Request line (protocol, method, uri) + * Note: This is an experimental API. + */ +public final class RequestLine implements Serializable { + + private static final long serialVersionUID = 2810581718468737193L; + + private final Version protoversion; + private final String method; + private final String uri; + + /** + * Create a new instance from the request + * @param request HTTP request + */ + public RequestLine(final HttpRequest request) { + Objects.requireNonNull(request, "Request"); + this.method = request.method(); + this.uri = buildUri(request.uri()); + this.protoversion = request.version().orElse(Version.HTTP_1_1); + } + + private static String buildUri(URI uri) { + final String query = uri.getQuery(); + if (query != null && query.isBlank() == false) { + return uri.getPath() + "?" + query; + } else { + return uri.getPath(); + } + } + + /** + * Creates new request line instance + * @param method request HTTP method + * @param uri request uri + * @param version HTTP protocol + */ + public RequestLine(final String method, final URI uri, final Version version) { + super(); + this.method = Objects.requireNonNull(method, "Method"); + this.uri = Objects.requireNonNull(uri, "URI").getPath(); + this.protoversion = version != null ? version : Version.HTTP_1_1; + } + + /** + * Gets the request HTTP method + * @return HTTP method + */ + public String getMethod() { + return this.method; + } + + /** + * Gets the request HTTP protocol + * @return HTTP protocol + */ + public Version getProtocolVersion() { + return this.protoversion; + } + + /** + * Gets the request uri + * @return request uri + */ + public String getUri() { + return this.uri; + } + + /** + * Converts the request line to string + */ + @Override + public String toString() { + final StringBuilder buf = new StringBuilder(); + buf.append(this.method).append(" ").append(this.uri).append(" ").append(this.protoversion); + return buf.toString(); + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLogger.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLogger.java new file mode 100644 index 0000000000000..3c91f6693efba --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLogger.java @@ -0,0 +1,191 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.opensearch.httpclient.internal.Node; + +import java.io.IOException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Flow; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Helper class that exposes static methods to unify the way requests are logged. + * Includes trace logging to log complete requests and responses in curl format. + * Useful for debugging, manually sending logged requests via curl and checking their responses. + * Trace logging is a feature that all the language clients provide. + */ +final class RequestLogger { + + private static final Log tracer = LogFactory.getLog("tracer"); + + private RequestLogger() {} + + /** + * Logs a streaming request that yielded a streaming response + */ + static void logStreamingResponse( + Log logger, + HttpRequest request, + HttpHost host, + HttpResponse>> httpResponse + ) { + logResponse(logger, request, host, httpResponse, List.of()); + } + + /** + * Logs a request that yielded a response + */ + static void logResponse(Log logger, HttpRequest request, HttpHost host, HttpResponse> httpResponse) { + logResponse(logger, request, host, httpResponse, httpResponse.body()); + } + + /** + * Logs a request that failed + */ + static void logFailedRequest(Log logger, Function request, Node node, Exception e) { + if (logger.isDebugEnabled()) { + final HttpRequest r = request.apply(node); + logger.debug("request [" + r.method() + " " + node.getHost() + r.uri() + "] failed", e); + } + if (tracer.isTraceEnabled()) { + String traceRequest; + try { + traceRequest = buildTraceRequest(request, node); + } catch (IOException e1) { + tracer.trace("error while reading request for trace purposes", e); + traceRequest = ""; + } + tracer.trace(traceRequest); + } + } + + static String buildWarningMessage(HttpRequest request, HttpHost host, List warnings) { + StringBuilder message = new StringBuilder("request [").append(request.method()) + .append(" ") + .append(host) + .append(request.uri()) + .append("] returned ") + .append(warnings.size()) + .append(" warnings: "); + for (int i = 0; i < warnings.size(); i++) { + if (i > 0) { + message.append(","); + } + message.append("[").append(warnings.get(i)).append("]"); + } + return message.toString(); + } + + /** + * Creates curl output for given request + */ + static String buildTraceRequest(Function request, Node node) throws IOException { + final HttpRequest r = request.apply(node); + return buildTraceRequest(r, node.getHost()); + } + + static String buildTraceRequest(HttpRequest request, HttpHost host) throws IOException { + String requestLine = "curl -iX " + request.method() + " '" + request.uri() + "'"; + final String body = BodyUtils.getBodyAsString(request).block(); + if (body != null) { + requestLine += " -d '"; + requestLine += body + "'"; + } + return requestLine; + } + + /** + * Creates curl output for given response + */ + static String buildTraceResponse(HttpResponse httpResponse, List body) throws IOException { + StringBuilder responseLine = new StringBuilder(); + responseLine.append("# ").append(new StatusLine(httpResponse)); + for (Map.Entry> header : httpResponse.headers().map().entrySet()) { + responseLine.append("\n# ") + .append(header.getKey()) + .append(": ") + .append(header.getValue().stream().collect(Collectors.joining(","))); + } + responseLine.append("\n#"); + + if (body != null && body.isEmpty() == false) { + for (ByteBuffer chunk : body) { + responseLine.append("\n# ").append(StandardCharsets.UTF_8.decode(chunk).toString()); + } + } + + return responseLine.toString(); + } + + /** + * Logs a request that yielded a response + */ + private static void logResponse(Log logger, HttpRequest request, HttpHost host, HttpResponse httpResponse, List body) { + if (logger.isDebugEnabled()) { + logger.debug("request [" + request.method() + " " + request.uri() + "] returned [" + new StatusLine(httpResponse) + "]"); + } + if (logger.isWarnEnabled()) { + List warnings = httpResponse.headers().allValues("Warning"); + if (warnings != null && warnings.size() > 0) { + logger.warn(buildWarningMessage(request, host, warnings)); + } + } + if (tracer.isTraceEnabled()) { + String requestLine; + try { + requestLine = buildTraceRequest(request, host); + } catch (IOException e) { + requestLine = ""; + tracer.trace("error while reading request for trace purposes", e); + } + String responseLine; + try { + responseLine = buildTraceResponse(httpResponse, body); + } catch (IOException e) { + responseLine = ""; + tracer.trace("error while reading response for trace purposes", e); + } + tracer.trace(requestLine + '\n' + responseLine); + } + } + +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestOptions.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestOptions.java new file mode 100644 index 0000000000000..d78538988eb85 --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestOptions.java @@ -0,0 +1,289 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * The portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * Note: This is an experimental API. + */ +public final class RequestOptions { + /** + * Default request options. + */ + public static final RequestOptions DEFAULT = new Builder( + Collections.emptyMap(), + Collections.emptyMap(), + null, + Duration.ofMillis(RestHttpClientBuilder.DEFAULT_RESPONSE_TIMEOUT_MILLIS) + ).build(); + + private final Map> headers; + private final Map parameters; + private final WarningsHandler warningsHandler; + private final Duration timeout; + + private RequestOptions(Builder builder) { + this.headers = Collections.unmodifiableMap(new HashMap<>(builder.headers)); + this.parameters = Collections.unmodifiableMap(new HashMap<>(builder.parameters)); + this.warningsHandler = builder.warningsHandler; + this.timeout = builder.timeout; + } + + /** + * Create a builder that contains these options but can be modified. + */ + public Builder toBuilder() { + return new Builder(headers, parameters, warningsHandler, timeout); + } + + /** + * Headers to attach to the request. + */ + public Map> getHeaders() { + return headers; + } + + /** + * Query parameters to attach to the request. Any parameters present here + * will override matching parameters in the {@link Request}, if they exist. + */ + public Map getParameters() { + return parameters; + } + + /** + * How this request should handle warnings. If null (the default) then + * this request will default to the behavior dictacted by + * {@link RestHttpClientBuilder#setStrictDeprecationMode}. + *

+ * This can be set to {@link WarningsHandler#PERMISSIVE} if the client + * should ignore all warnings which is the same behavior as setting + * strictDeprecationMode to true. It can be set to + * {@link WarningsHandler#STRICT} if the client should fail if there are + * any warnings which is the same behavior as settings + * strictDeprecationMode to false. + *

+ * It can also be set to a custom implementation of + * {@linkplain WarningsHandler} to permit only certain warnings or to + * fail the request if the warnings returned don't + * exactly match some set. + */ + public WarningsHandler getWarningsHandler() { + return warningsHandler; + } + + /** + * Gets the request timeout + * @return request timeout + */ + public Duration getTimeout() { + return timeout; + } + + /** + * Convert request options to string representation + */ + @Override + public String toString() { + StringBuilder b = new StringBuilder(); + b.append("RequestOptions{"); + boolean comma = false; + if (headers.size() > 0) { + comma = true; + b.append("headers="); + b.append(headers.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining(","))); + } + if (timeout != null) { + if (comma) b.append(", "); + comma = true; + b.append("timeout=").append(timeout.toMillis()).append("ms"); + } + if (parameters.size() > 0) { + if (comma) b.append(", "); + comma = true; + b.append("parameters="); + b.append(parameters.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining(","))); + } + if (warningsHandler != null) { + if (comma) b.append(", "); + comma = true; + b.append("warningsHandler=").append(warningsHandler); + } + return b.append('}').toString(); + } + + /** + * Compare two request options for equality + * @param obj request options instance to compare with + */ + @Override + public boolean equals(Object obj) { + if (obj == null || (obj.getClass() != getClass())) { + return false; + } + if (obj == this) { + return true; + } + + RequestOptions other = (RequestOptions) obj; + return headers.equals(other.headers) + && parameters.equals(other.parameters) + && Objects.equals(timeout, other.timeout) + && Objects.equals(warningsHandler, other.warningsHandler); + } + + /** + * Calculate the hash code of the request options + */ + @Override + public int hashCode() { + return Objects.hash(headers, parameters, warningsHandler); + } + + /** + * Builds {@link RequestOptions}. Get one by calling + * {@link RequestOptions#toBuilder} on {@link RequestOptions#DEFAULT} or + * any other {@linkplain RequestOptions}. + */ + public static class Builder { + private final Map> headers; + private final Map parameters; + private WarningsHandler warningsHandler; + private Duration timeout = Duration.ofMillis(RestHttpClientBuilder.DEFAULT_RESPONSE_TIMEOUT_MILLIS); + + private Builder( + Map> headers, + Map parameters, + WarningsHandler warningsHandler, + Duration timeout + ) { + this.headers = new HashMap<>(headers); + this.parameters = new HashMap<>(parameters); + this.warningsHandler = warningsHandler; + this.timeout = timeout; + } + + /** + * Build the {@linkplain RequestOptions}. + */ + public RequestOptions build() { + return new RequestOptions(this); + } + + /** + * Sets request timeout + * + * @param timeout timeout + */ + public void setTimeout(Duration timeout) { + Objects.requireNonNull(timeout, "timeout cannot be null"); + this.timeout = timeout; + } + + /** + * Add the provided headers to the request. + * + * @param headers headers to add + * @throws NullPointerException if {@code name} or {@code value} is null. + */ + public Builder addHeaders(Map> headers) { + Objects.requireNonNull(headers, "headers cannot be null"); + for (Map.Entry> header : headers.entrySet()) { + header.getValue().forEach(v -> addHeader(header.getKey(), v)); + } + return this; + } + + /** + * Add the provided header to the request. + * + * @param name the header name + * @param value the header value + * @throws NullPointerException if {@code name} or {@code value} is null. + */ + public Builder addHeader(String name, String value) { + Objects.requireNonNull(name, "header name cannot be null"); + Objects.requireNonNull(value, "header value cannot be null"); + this.headers.computeIfAbsent(name, v -> new ArrayList<>()).add(value); + return this; + } + + /** + * Add the provided query parameter to the request. Any parameters added here + * will override matching parameters in the {@link Request}, if they exist. + * + * @param name the query parameter name + * @param value the query parameter value + * @throws NullPointerException if {@code name} or {@code value} is null. + */ + public Builder addParameter(String name, String value) { + Objects.requireNonNull(name, "query parameter name cannot be null"); + Objects.requireNonNull(value, "query parameter value cannot be null"); + this.parameters.put(name, value); + return this; + } + + /** + * How this request should handle warnings. If null (the default) then + * this request will default to the behavior dictacted by + * {@link RestHttpClientBuilder#setStrictDeprecationMode}. + *

+ * This can be set to {@link WarningsHandler#PERMISSIVE} if the client + * should ignore all warnings which is the same behavior as setting + * strictDeprecationMode to true. It can be set to + * {@link WarningsHandler#STRICT} if the client should fail if there are + * any warnings which is the same behavior as settings + * strictDeprecationMode to false. + *

+ * It can also be set to a custom implementation of + * {@linkplain WarningsHandler} to permit only certain warnings or to + * fail the request if the warnings returned don't + * exactly match some set. + * + * @param warningsHandler the {@link WarningsHandler} to be used + */ + public void setWarningsHandler(WarningsHandler warningsHandler) { + this.warningsHandler = warningsHandler; + } + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/Response.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/Response.java new file mode 100644 index 0000000000000..bb890d21ea94d --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/Response.java @@ -0,0 +1,124 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.io.InputStream; +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Flow; + +/** + * Holds an opensearch response. It wraps the {@link HttpResponse} returned and associates it with + * its corresponding {@link RequestLine} and {@link HttpHost}. + * Note: This is an experimental API. + */ +public class Response { + private final RequestLine requestLine; + private final HttpHost host; + private final HttpResponse response; + private final List body; + private final boolean compressed; + + private Response(RequestLine requestLine, HttpHost host, HttpResponse response, List body) { + Objects.requireNonNull(requestLine, "requestLine cannot be null"); + Objects.requireNonNull(host, "host cannot be null"); + Objects.requireNonNull(response, "response cannot be null"); + this.requestLine = requestLine; + this.host = host; + this.response = response; + this.body = body; + this.compressed = response.headers().firstValue("Content-Encoding").filter("gzip"::equalsIgnoreCase).map(h -> true).orElse(false); + } + + static Response fromStreaming(RequestLine requestLine, HttpHost host, HttpResponse>> response) { + return new Response(requestLine, host, response, List.of() /* streaming body could be very large */); + } + + static Response from(RequestLine requestLine, HttpHost host, HttpResponse> response) { + return new Response(requestLine, host, response, response.body()); + } + + /** + * Returns the request line that generated this response + */ + public RequestLine getRequestLine() { + return requestLine; + } + + /** + * Returns the node that returned this response + */ + public HttpHost getHost() { + return host; + } + + /** + * Returns the status line of the current response + */ + public StatusLine getStatusLine() { + return new StatusLine(response); + } + + /** + * Returns all the response headers + */ + public HttpHeaders getHeaders() { + return response.headers(); + } + + /** + * Returns the value of the first header with a specified name of this message. + * If there is more than one matching header in the message the first element is returned. + * If there is no matching header in the message null is returned. + * + * @param name header name + */ + public String getHeader(String name) { + return response.headers().firstValue(name).orElse(null); + } + + /** + * Returns the response body available, null otherwise + * @see InputStream + */ + public List getEntity() { + return (compressed == false) ? body : BodyUtils.decompress(body); + } + + /** + * Returns a list of all warning headers returned in the response. + */ + public List getWarnings() { + return ResponseWarningsExtractor.getWarnings(response); + } + + /** + * Returns true if there is at least one warning header returned in the + * response. + */ + public boolean hasWarnings() { + List warnings = response.headers().allValues("Warning"); + return warnings != null && warnings.size() > 0; + } + + HttpResponse getHttpResponse() { + return response; + } + + /** + * Convert response to string representation + */ + @Override + public String toString() { + return "Response{" + "requestLine=" + requestLine + ", host=" + host + ", response=" + getStatusLine() + '}'; + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseException.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseException.java new file mode 100644 index 0000000000000..f5889341bdedc --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseException.java @@ -0,0 +1,61 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Locale; + +/** + * Exception thrown when an opensearch node responds to a request with a status code that indicates an error. + * Holds the response that was returned. + */ +public final class ResponseException extends IOException { + private static final long serialVersionUID = 1L; + private final Response response; + + /** + * Creates a ResponseException containing the given {@code Response}. + * + * @param response The error response. + */ + public ResponseException(Response response) throws IOException { + super(buildMessage(response)); + this.response = response; + } + + static String buildMessage(Response response) throws IOException { + String message = String.format( + Locale.ROOT, + "method [%s], host [%s], URI [%s], status line [%s]", + response.getRequestLine().getMethod(), + response.getHost(), + response.getRequestLine().getUri(), + response.getStatusLine().toString() + ); + + if (response.hasWarnings()) { + message += "\n" + "Warnings: " + response.getWarnings(); + } + + List entity = response.getEntity(); + if (entity != null) { + message += "\n" + BodyUtils.getBodyAsString(response); + } + return message; + } + + /** + * Returns the {@link Response} that caused this exception to be thrown. + */ + public Response getResponse() { + return response; + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseListener.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseListener.java new file mode 100644 index 0000000000000..efed8fcdfd4b5 --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseListener.java @@ -0,0 +1,61 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +/** + * Listener to be provided when calling async performRequest methods provided by {@link RestHttpClient}. + * Those methods that do accept a listener will return immediately, execute asynchronously, and notify + * the listener whenever the request yielded a response, or failed with an exception. + * + *

+ * Note that it is not safe to call {@link RestHttpClient#close()} from either of these + * callbacks. + */ +public interface ResponseListener { + + /** + * Method invoked if the request yielded a successful response. + * + * @param response success response + */ + void onSuccess(Response response); + + /** + * Method invoked if the request failed. There are two main categories of failures: connection failures (usually + * {@link java.io.IOException}s, or responses that were treated as errors based on their error response code + * ({@link ResponseException}s). + * + * @param exception the failure exception + */ + void onFailure(Exception exception); +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseWarningsExtractor.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseWarningsExtractor.java new file mode 100644 index 0000000000000..72b98056a3a00 --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseWarningsExtractor.java @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class ResponseWarningsExtractor { + + /** + * Optimized regular expression to test if a string matches the RFC 1123 date + * format (with quotes and leading space). Start/end of line characters and + * atomic groups are used to prevent backtracking. + */ + private static final Pattern WARNING_HEADER_DATE_PATTERN = Pattern.compile("^ " + // start of line, leading space + // quoted RFC 1123 date format + "\"" + // opening quote + "(?>Mon|Tue|Wed|Thu|Fri|Sat|Sun), " + // day of week, atomic group to prevent backtracking + "\\d{2} " + // 2-digit day + "(?>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) " + // month, atomic group to prevent backtracking + "\\d{4} " + // 4-digit year + "\\d{2}:\\d{2}:\\d{2} " + // (two-digit hour):(two-digit minute):(two-digit second) + "GMT" + // GMT + "\"$"); // closing quote (optional, since an older version can still send a warn-date), end of line + + /** + * Length of RFC 1123 format (with quotes and leading space), used in + * matchWarningHeaderPatternByPrefix(String). + */ + private static final int WARNING_HEADER_DATE_LENGTH = 0 + 1 + 1 + 3 + 1 + 1 + 2 + 1 + 3 + 1 + 4 + 1 + 2 + 1 + 2 + 1 + 2 + 1 + 3 + 1; + + private ResponseWarningsExtractor() {} + + /** + * Returns a list of all warning headers returned in the response. + * @param response HTTP response + */ + static List getWarnings(final HttpResponse response) { + List warnings = new ArrayList<>(); + for (String warning : response.headers().allValues("Warning")) { + if (matchWarningHeaderPatternByPrefix(warning)) { + warnings.add(extractWarningValueFromWarningHeader(warning)); + } else { + warnings.add(warning); + } + } + return warnings; + } + + /** + * Tests if a string matches the RFC 7234 specification for warning headers. + * This assumes that the warn code is always 299 and the warn agent is always + * OpenSearch. + * + * @param s the value of a warning header formatted according to RFC 7234 + * @return {@code true} if the input string matches the specification + */ + private static boolean matchWarningHeaderPatternByPrefix(final String s) { + return s.startsWith("299 OpenSearch-"); + } + + /** + * Refer to org.opensearch.common.logging.DeprecationLogger + */ + private static String extractWarningValueFromWarningHeader(final String s) { + String warningHeader = s; + + /* + * The following block tests for the existence of a RFC 1123 date in the warning header. If the date exists, it is removed for + * extractWarningValueFromWarningHeader(String) to work properly (as it does not handle dates). + */ + if (s.length() > WARNING_HEADER_DATE_LENGTH) { + final String possibleDateString = s.substring(s.length() - WARNING_HEADER_DATE_LENGTH); + final Matcher matcher = WARNING_HEADER_DATE_PATTERN.matcher(possibleDateString); + + if (matcher.matches()) { + warningHeader = warningHeader.substring(0, s.length() - WARNING_HEADER_DATE_LENGTH); + } + } + + final int firstQuote = warningHeader.indexOf('\"'); + final int lastQuote = warningHeader.length() - 1; + final String warningValue = warningHeader.substring(firstQuote + 1, lastQuote); + return warningValue; + } + +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java new file mode 100644 index 0000000000000..92c1c271e59cf --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java @@ -0,0 +1,1095 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.opensearch.httpclient.internal.Node; +import org.opensearch.httpclient.internal.NodeSelector; + +import javax.net.ssl.SSLHandshakeException; + +import java.io.Closeable; +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublisher; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandler; +import java.net.http.HttpResponse.BodyHandlers; +import java.net.http.HttpTimeoutException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Flow; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.reactivestreams.Publisher; +import reactor.adapter.JdkFlowAdapter; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.singletonList; + +/** + * Client that connects to an OpenSearch cluster through HTTP. + *

+ * Must be created using {@link RestHttpClientBuilder}, which allows to set all the different options or just rely on defaults. + * The hosts that are part of the cluster need to be provided at creation time, but can also be replaced later + * by calling {@link #setNodes(Collection)}. + *

+ * The method {@link #performRequest(Request)} allows to send a request to the cluster. When + * sending a request, a host gets selected out of the provided ones in a round-robin fashion. Failing hosts are marked dead and + * retried after a certain amount of time (minimum 1 minute, maximum 30 minutes), depending on how many times they previously + * failed (the more failures, the later they will be retried). In case of failures all of the alive nodes (or dead nodes that + * deserve a retry) are retried until one responds or none of them does, in which case an {@link IOException} will be thrown. + *

+ * Requests can be either synchronous or asynchronous. The asynchronous variants all end with {@code Async}. + *

+ * Requests can be traced by enabling trace logging for "tracer". The trace logger outputs requests and responses in curl format. + * + * Note: This is an experimental API. + */ +public class RestHttpClient implements Closeable { + + private static final Log logger = LogFactory.getLog(RestHttpClient.class); + + private final HttpClient client; + final Map> defaultHeaders; + private final String pathPrefix; + private final AtomicInteger lastNodeIndex = new AtomicInteger(0); + private final ConcurrentMap denylist = new ConcurrentHashMap<>(); + private final FailureListener failureListener; + private final NodeSelector nodeSelector; + private volatile List nodes; + private final WarningsHandler warningsHandler; + private final boolean compressionEnabled; + + RestHttpClient( + HttpClient client, + Map> defaultHeaders, + List nodes, + String pathPrefix, + FailureListener failureListener, + NodeSelector nodeSelector, + boolean strictDeprecationMode, + boolean compressionEnabled + ) { + this.client = client; + this.defaultHeaders = Collections.unmodifiableMap(defaultHeaders); + this.failureListener = failureListener; + this.pathPrefix = pathPrefix; + this.nodeSelector = nodeSelector; + this.warningsHandler = strictDeprecationMode ? WarningsHandler.STRICT : WarningsHandler.PERMISSIVE; + this.compressionEnabled = compressionEnabled; + setNodes(nodes); + } + + /** + * Returns a new {@link RestHttpClientBuilder} to help with {@link RestHttpClient} creation. + * Creates a new builder instance and sets the nodes that the client will send requests to. + * + * @param cloudId a valid elastic cloud cloudId that will route to a cluster. The cloudId is located in + * the user console https://cloud.elastic.co and will resemble a string like the following + * optionalHumanReadableName:dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRlbGFzdGljc2VhcmNoJGtpYmFuYQ== + */ + public static RestHttpClientBuilder builder(String cloudId) { + // there is an optional first portion of the cloudId that is a human readable string, but it is not used. + if (cloudId.contains(":")) { + if (cloudId.indexOf(":") == cloudId.length() - 1) { + throw new IllegalStateException("cloudId " + cloudId + " must begin with a human readable identifier followed by a colon"); + } + cloudId = cloudId.substring(cloudId.indexOf(":") + 1); + } + + String decoded = new String(Base64.getDecoder().decode(cloudId), UTF_8); + // once decoded the parts are separated by a $ character. + // they are respectively domain name and optional port, opensearch id, opensearch-dashboards id + String[] decodedParts = decoded.split("\\$"); + if (decodedParts.length != 3) { + throw new IllegalStateException("cloudId " + cloudId + " did not decode to a cluster identifier correctly"); + } + + // domain name and optional port + String[] domainAndMaybePort = decodedParts[0].split(":", 2); + String domain = domainAndMaybePort[0]; + int port; + + if (domainAndMaybePort.length == 2) { + try { + port = Integer.parseInt(domainAndMaybePort[1]); + } catch (NumberFormatException nfe) { + throw new IllegalStateException("cloudId " + cloudId + " does not contain a valid port number"); + } + } else { + port = 443; + } + + String url = decodedParts[1] + "." + domain; + return builder(new HttpHost("https", url, port)); + } + + /** + * Returns a new {@link RestHttpClientBuilder} to help with {@link RestHttpClient} creation. + * Creates a new builder instance and sets the hosts that the client will send requests to. + *

+ * Prefer this to {@link #builder(HttpHost...)} if you have metadata up front about the nodes. + * If you don't either one is fine. + * + * @param nodes The nodes that the client will send requests to. + */ + public static RestHttpClientBuilder builder(Node... nodes) { + return new RestHttpClientBuilder(nodes == null ? null : Arrays.asList(nodes)); + } + + /** + * Returns a new {@link RestHttpClientBuilder} to help with {@link RestHttpClient} creation. + * Creates a new builder instance and sets the nodes that the client will send requests to. + *

+ * You can use this if you do not have metadata up front about the nodes. If you do, prefer + * {@link #builder(Node...)}. + * @see Node#Node(HttpHost) + * + * @param hosts The hosts that the client will send requests to. + */ + public static RestHttpClientBuilder builder(HttpHost... hosts) { + if (hosts == null || hosts.length == 0) { + throw new IllegalArgumentException("hosts must not be null nor empty"); + } + List nodes = Arrays.stream(hosts).map(Node::new).collect(Collectors.toList()); + return new RestHttpClientBuilder(nodes); + } + + /** + * Replaces the nodes with which the client communicates. + * + * @param nodes the new nodes to communicate with. + */ + public synchronized void setNodes(Collection nodes) { + if (nodes == null || nodes.isEmpty()) { + throw new IllegalArgumentException("nodes must not be null or empty"); + } + + Map nodesByHost = new LinkedHashMap<>(); + for (Node node : nodes) { + Objects.requireNonNull(node, "node cannot be null"); + // TODO should we throw an IAE if we have two nodes with the same host? + nodesByHost.put(node.getHost(), node); + } + this.nodes = Collections.unmodifiableList(new ArrayList<>(nodesByHost.values())); + this.denylist.clear(); + } + + /** + * Get the list of nodes that the client knows about. The list is + * unmodifiable. + */ + public List getNodes() { + return nodes; + } + + /** + * check client running status + * @return client running status + */ + public boolean isRunning() { + return client.isTerminated() == false; + } + + /** + * Sends a streaming request to the OpenSearch cluster that the client points to and returns streaming response. This is an experimental API. + * @param request streaming request + * @return streaming response + * @throws IOException IOException + */ + public StreamingResponse streamRequest(StreamingRequest request) throws IOException { + final InternalStreamingRequest internalRequest = new InternalStreamingRequest(request); + return streamRequest(nextNodes(), internalRequest); + } + + /** + * Sends a request to the OpenSearch cluster that the client points to. + * Blocks until the request is completed and returns its response or fails + * by throwing an exception. Selects a host out of the provided ones in a + * round-robin fashion. Failing hosts are marked dead and retried after a + * certain amount of time (minimum 1 minute, maximum 30 minutes), depending + * on how many times they previously failed (the more failures, the later + * they will be retried). In case of failures all of the alive nodes (or + * dead nodes that deserve a retry) are retried until one responds or none + * of them does, in which case an {@link IOException} will be thrown. + *

+ * This method works by performing an asynchronous call and waiting + * for the result. If the asynchronous call throws an exception we wrap + * it and rethrow it so that the stack trace attached to the exception + * contains the call site. While we attempt to preserve the original + * exception this isn't always possible and likely haven't covered all of + * the cases. You can get the original exception from + * {@link Exception#getCause()}. + * + * @param request the request to perform + * @return the response returned by OpenSearch + * @throws IOException in case of a problem or the connection was aborted + * @throws ResponseException in case OpenSearch responded with a status code that indicated an error + */ + public Response performRequest(Request request) throws IOException { + InternalRequest internalRequest = new InternalRequest(request); + return performRequest(nextNodes(), internalRequest, null); + } + + private Response performRequest(final Iterator nodes, final InternalRequest request, Exception previousException) + throws IOException { + Node node = nodes.next(); + RequestContext> context = request.createContextForNextAttempt(node); + HttpResponse> httpResponse; + try { + httpResponse = client.send(context.requestProducer(), context.asyncResponseConsumer()); + } catch (Exception e) { + RequestLogger.logFailedRequest(logger, request.httpRequest, context.node(), e); + onFailure(context.node()); + Exception cause = extractAndWrapCause(e); + addSuppressedException(previousException, cause); + if (nodes.hasNext()) { + return performRequest(nodes, request, cause); + } + if (cause instanceof IOException) { + throw (IOException) cause; + } + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new IllegalStateException("unexpected exception type: must be either RuntimeException or IOException", cause); + } + ResponseOrResponseException responseOrResponseException = convertResponse(request, context.node(), httpResponse); + if (responseOrResponseException.responseException == null) { + return responseOrResponseException.response; + } + addSuppressedException(previousException, responseOrResponseException.responseException); + if (nodes.hasNext()) { + return performRequest(nodes, request, responseOrResponseException.responseException); + } + throw responseOrResponseException.responseException; + } + + private Publisher>>> streamRequest( + final Node node, + final Iterator nodes, + final InternalStreamingRequest request + ) throws IOException { + return request.cancellable.callIfNotCancelled(() -> { + final RequestContext>> context = request.createContextForNextAttempt(node); + final CompletableFuture>>> future = client.sendAsync( + context.requestProducer(), + context.asyncResponseConsumer() + ); + request.setCancellable(future); + + final Mono>>> publisher = Mono.fromCompletionStage(future).flatMap(response -> { + try { + final ResponseOrResponseException responseOrResponseException = convertResponse(request, node, response); + if (responseOrResponseException.responseException == null) { + return Mono.just(response); + } else { + if (nodes.hasNext()) { + return Mono.from(streamRequest(nodes.next(), nodes, request)); + } else { + return Mono.error(responseOrResponseException.responseException); + } + } + } catch (final Exception ex) { + return Mono.error(ex); + } + }); + + return publisher; + }); + } + + private StreamingResponse streamRequest(final Iterator nodes, final InternalStreamingRequest request) throws IOException { + return request.cancellable.callIfNotCancelled(() -> { + final Node node = nodes.next(); + return new StreamingResponse(new RequestLine(request.httpRequest.apply(node)), streamRequest(node, nodes, request)); + }); + } + + private ResponseOrResponseException convertResponse(InternalRequest request, Node node, HttpResponse> httpResponse) + throws IOException { + + final HttpRequest httpRequest = request.httpRequest.apply(node); + RequestLogger.logResponse(logger, httpRequest, node.getHost(), httpResponse); + int statusCode = httpResponse.statusCode(); + + Response response = Response.from(new RequestLine(httpRequest), node.getHost(), httpResponse); + if (isSuccessfulResponse(statusCode) || request.ignoreErrorCodes.contains(response.getStatusLine().getStatusCode())) { + onResponse(node); + if (request.warningsHandler.warningsShouldFailRequest(response.getWarnings())) { + throw new WarningFailureException(response); + } + return new ResponseOrResponseException(response); + } + ResponseException responseException = new ResponseException(response); + if (isRetryStatus(statusCode)) { + // mark host dead and retry against next one + onFailure(node); + return new ResponseOrResponseException(responseException); + } + // mark host alive and don't retry, as the error should be a request problem + onResponse(node); + throw responseException; + } + + private ResponseOrResponseException convertResponse( + InternalStreamingRequest request, + Node node, + HttpResponse>> httpResponse + ) throws IOException { + + // Streaming Response could accumulate a lot of data so we may not be able to fully consume it. + final HttpRequest httpRequest = request.httpRequest.apply(node); + final Response response = Response.fromStreaming(new RequestLine(httpRequest), node.getHost(), httpResponse); + + RequestLogger.logStreamingResponse(logger, request.httpRequest.apply(node), node.getHost(), httpResponse); + int statusCode = httpResponse.statusCode(); + + if (isSuccessfulResponse(statusCode) || request.ignoreErrorCodes.contains(response.getStatusLine().getStatusCode())) { + onResponse(node); + if (request.warningsHandler.warningsShouldFailRequest(response.getWarnings())) { + throw new WarningFailureException(response); + } + return new ResponseOrResponseException(response); + } + ResponseException responseException = new ResponseException(response); + if (isRetryStatus(statusCode)) { + // mark host dead and retry against next one + onFailure(node); + return new ResponseOrResponseException(responseException); + } + // mark host alive and don't retry, as the error should be a request problem + onResponse(node); + throw responseException; + } + + /** + * Sends a request to the OpenSearch cluster that the client points to. + * The request is executed asynchronously and the provided + * {@link ResponseListener} gets notified upon request completion or + * failure. Selects a host out of the provided ones in a round-robin + * fashion. Failing hosts are marked dead and retried after a certain + * amount of time (minimum 1 minute, maximum 30 minutes), depending on how + * many times they previously failed (the more failures, the later they + * will be retried). In case of failures all of the alive nodes (or dead + * nodes that deserve a retry) are retried until one responds or none of + * them does, in which case an {@link IOException} will be thrown. + * + * @param request the request to perform + * @param responseListener the {@link ResponseListener} to notify when the + * request is completed or fails + */ + public Cancellable performRequestAsync(Request request, ResponseListener responseListener) { + try { + FailureTrackingResponseListener failureTrackingResponseListener = new FailureTrackingResponseListener(responseListener); + InternalRequest internalRequest = new InternalRequest(request); + performRequestAsync(nextNodes(), internalRequest, failureTrackingResponseListener); + return internalRequest.cancellable; + } catch (Exception e) { + responseListener.onFailure(e); + return Cancellable.NO_OP; + } + } + + private void performRequestAsync( + final Iterator nodes, + final InternalRequest request, + final FailureTrackingResponseListener listener + ) { + request.cancellable.runIfNotCancelled(() -> { + final RequestContext> context = request.createContextForNextAttempt(nodes.next()); + CompletableFuture>> future = client.sendAsync( + context.requestProducer(), + context.asyncResponseConsumer() + ); + + request.setCancellable(future); + future.whenComplete((httpResponse, throwable) -> { + if (httpResponse != null) { + try { + ResponseOrResponseException responseOrResponseException = convertResponse(request, context.node(), httpResponse); + if (responseOrResponseException.responseException == null) { + listener.onSuccess(responseOrResponseException.response); + } else { + if (nodes.hasNext()) { + listener.trackFailure(responseOrResponseException.responseException); + performRequestAsync(nodes, request, listener); + } else { + listener.onDefinitiveFailure(responseOrResponseException.responseException); + } + } + } catch (Exception e) { + listener.onDefinitiveFailure(e); + } + } else if (throwable instanceof Exception failure) { + if (failure instanceof CancellationException) { + listener.onDefinitiveFailure(Cancellable.newCancellationException()); + } else { + Exception cause = failure; + if (failure instanceof CompletionException && failure.getCause() instanceof Exception ce) { + cause = ce; + } + try { + RequestLogger.logFailedRequest(logger, request.httpRequest, context.node(), failure); + onFailure(context.node()); + if (nodes.hasNext()) { + listener.trackFailure(failure); + performRequestAsync(nodes, request, listener); + } else { + listener.onDefinitiveFailure(cause); + } + } catch (Exception e) { + listener.onDefinitiveFailure(e); + } + } + } + }); + }); + + } + + /** + * Returns a non-empty {@link Iterator} of nodes to be used for a request + * that match the {@link NodeSelector}. + *

+ * If there are no living nodes that match the {@link NodeSelector} + * this will return the dead node that matches the {@link NodeSelector} + * that is closest to being revived. + * @throws IOException if no nodes are available + */ + private Iterator nextNodes() throws IOException { + List nodes = this.nodes; + Iterable hosts = selectNodes(nodes, denylist, lastNodeIndex, nodeSelector); + return hosts.iterator(); + } + + /** + * Select nodes to try and sorts them so that the first one will be tried initially, then the following ones + * if the previous attempt failed and so on. Package private for testing. + */ + static Iterable selectNodes( + List nodes, + Map denylist, + AtomicInteger lastNodeIndex, + NodeSelector nodeSelector + ) throws IOException { + /* + * Sort the nodes into living and dead lists. + */ + List livingNodes = new ArrayList<>(Math.max(0, nodes.size() - denylist.size())); + List deadNodes = new ArrayList<>(denylist.size()); + for (Node node : nodes) { + DeadHostState deadness = denylist.get(node.getHost()); + if (deadness == null || deadness.shallBeRetried()) { + livingNodes.add(node); + } else { + deadNodes.add(new DeadNode(node, deadness)); + } + } + + if (false == livingNodes.isEmpty()) { + /* + * Normal state: there is at least one living node. If the + * selector is ok with any over the living nodes then use them + * for the request. + */ + List selectedLivingNodes = new ArrayList<>(livingNodes); + nodeSelector.select(selectedLivingNodes); + if (false == selectedLivingNodes.isEmpty()) { + /* + * Rotate the list using a global counter as the distance so subsequent + * requests will try the nodes in a different order. + */ + Collections.rotate(selectedLivingNodes, lastNodeIndex.getAndIncrement()); + return selectedLivingNodes; + } + } + + /* + * Last resort: there are no good nodes to use, either because + * the selector rejected all the living nodes or because there aren't + * any living ones. Either way, we want to revive a single dead node + * that the NodeSelectors are OK with. We do this by passing the dead + * nodes through the NodeSelector so it can have its say in which nodes + * are ok. If the selector is ok with any of the nodes then we will take + * the one in the list that has the lowest revival time and try it. + */ + if (false == deadNodes.isEmpty()) { + final List selectedDeadNodes = new ArrayList<>(deadNodes); + /* + * We'd like NodeSelectors to remove items directly from deadNodes + * so we can find the minimum after it is filtered without having + * to compare many things. This saves us a sort on the unfiltered + * list. + */ + nodeSelector.select(() -> new DeadNodeIteratorAdapter(selectedDeadNodes.iterator())); + if (false == selectedDeadNodes.isEmpty()) { + return singletonList(Collections.min(selectedDeadNodes).node); + } + } + throw new IOException( + "NodeSelector [" + nodeSelector + "] rejected all nodes, " + "living " + livingNodes + " and dead " + deadNodes + ); + } + + /** + * Called after each successful request call. + * Receives as an argument the host that was used for the successful request. + */ + private void onResponse(Node node) { + DeadHostState removedHost = this.denylist.remove(node.getHost()); + if (logger.isDebugEnabled() && removedHost != null) { + logger.debug("removed [" + node + "] from denylist"); + } + } + + /** + * Called after each failed attempt. + * Receives as an argument the host that was used for the failed attempt. + */ + private void onFailure(Node node) { + while (true) { + DeadHostState previousDeadHostState = denylist.putIfAbsent( + node.getHost(), + new DeadHostState(DeadHostState.DEFAULT_TIME_SUPPLIER) + ); + if (previousDeadHostState == null) { + if (logger.isDebugEnabled()) { + logger.debug("added [" + node + "] to denylist"); + } + break; + } + if (denylist.replace(node.getHost(), previousDeadHostState, new DeadHostState(previousDeadHostState))) { + if (logger.isDebugEnabled()) { + logger.debug("updated [" + node + "] already in denylist"); + } + break; + } + } + failureListener.onFailure(node); + } + + /** + * Close the underlying {@link HttpClient} instance + */ + @Override + public void close() throws IOException { + client.shutdownNow(); + client.close(); + } + + private static boolean isSuccessfulResponse(int statusCode) { + return statusCode < 300; + } + + private static boolean isRetryStatus(int statusCode) { + switch (statusCode) { + case 502: + case 503: + case 504: + return true; + } + return false; + } + + private static void addSuppressedException(Exception suppressedException, Exception currentException) { + if (suppressedException != null) { + currentException.addSuppressed(suppressedException); + } + } + + private HttpRequest.Builder createHttpRequest( + Node node, + String method, + URI uri, + BodyPublisher body, + Duration timeout, + boolean compressed + ) { + return HttpRequest.newBuilder() + .uri(URI.create(node.getHost().toString()).resolve(uri)) + .timeout(timeout) + .method( + method, + (body == null) ? BodyPublishers.noBody() + : compressed == false ? body + : BodyPublishers.fromPublisher( + JdkFlowAdapter.publisherToFlowPublisher( + JdkFlowAdapter.flowPublisherToFlux(body).buffer().map(BodyUtils::compress).flatMap(Flux::fromIterable) + ) + ) + ); + } + + private HttpRequest.Builder createStreamingHttpRequest( + Node node, + String method, + URI uri, + Publisher body, + Duration timeout, + boolean compressed + ) { + return HttpRequest.newBuilder() + .uri(URI.create(node.getHost().toString()).resolve(uri)) + .timeout(timeout) + .method( + method, + (body == null) ? BodyPublishers.noBody() + : compressed == false ? BodyPublishers.fromPublisher(JdkFlowAdapter.publisherToFlowPublisher(body)) + : BodyPublishers.fromPublisher(JdkFlowAdapter.publisherToFlowPublisher(Flux.from(body).map(BodyUtils::compress))) + ); + } + + static URI buildUri(String pathPrefix, String path, Map params) { + Objects.requireNonNull(path, "path must not be null"); + try { + String fullPath; + if (pathPrefix != null && pathPrefix.isEmpty() == false) { + if (pathPrefix.endsWith("/") && path.startsWith("/")) { + fullPath = pathPrefix.substring(0, pathPrefix.length() - 1) + path; + } else if (pathPrefix.endsWith("/") || path.startsWith("/")) { + fullPath = pathPrefix + path; + } else { + fullPath = pathPrefix + "/" + path; + } + } else { + fullPath = path; + } + + final String additionalQuery = params.entrySet() + .stream() + .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + final URI uri = URI.create(fullPath); + + String newQuery = uri.getQuery(); + if (newQuery == null) { + newQuery = additionalQuery; + } else { + newQuery += "&" + additionalQuery; + } + + return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), newQuery, uri.getFragment()); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } + + /** + * Listener used in any async call to wrap the provided user listener (or SyncResponseListener in sync calls). + * Allows to track potential failures coming from the different retry attempts and returning to the original listener + * only when we got a response (successful or not to be retried) or there are no hosts to retry against. + */ + static class FailureTrackingResponseListener { + private final ResponseListener responseListener; + private volatile Exception exception; + + FailureTrackingResponseListener(ResponseListener responseListener) { + this.responseListener = responseListener; + } + + /** + * Notifies the caller of a response through the wrapped listener + */ + void onSuccess(Response response) { + responseListener.onSuccess(response); + } + + /** + * Tracks one last definitive failure and returns to the caller by notifying the wrapped listener + */ + void onDefinitiveFailure(Exception exception) { + trackFailure(exception); + responseListener.onFailure(this.exception); + } + + /** + * Tracks an exception, which caused a retry hence we should not return yet to the caller + */ + void trackFailure(Exception exception) { + addSuppressedException(this.exception, exception); + this.exception = exception; + } + } + + /** + * Listener that allows to be notified whenever a failure happens. Useful when sniffing is enabled, so that we can sniff on failure. + * The default implementation is a no-op. + */ + public static class FailureListener { + /** + * Create a {@link FailureListener} instance. + */ + public FailureListener() {} + + /** + * Notifies that the node provided as argument has just failed. + * + * @param node The node which has failed. + */ + public void onFailure(Node node) {} + } + + /** + * Contains a reference to a denylisted node and the time until it is + * revived. We use this so we can do a single pass over the denylist. + */ + private static class DeadNode implements Comparable { + final Node node; + final DeadHostState deadness; + + DeadNode(Node node, DeadHostState deadness) { + this.node = node; + this.deadness = deadness; + } + + @Override + public String toString() { + return node.toString(); + } + + @Override + public int compareTo(DeadNode rhs) { + return deadness.compareTo(rhs.deadness); + } + } + + /** + * Adapts an Iterator<DeadNodeAndRevival> into an + * Iterator<Node>. + */ + private static class DeadNodeIteratorAdapter implements Iterator { + private final Iterator itr; + + private DeadNodeIteratorAdapter(Iterator itr) { + this.itr = itr; + } + + @Override + public boolean hasNext() { + return itr.hasNext(); + } + + @Override + public Node next() { + return itr.next().node; + } + + @Override + public void remove() { + itr.remove(); + } + } + + private class InternalStreamingRequest { + private final Set ignoreErrorCodes; + private final Function httpRequest; + private final WarningsHandler warningsHandler; + private volatile Cancellable cancellable = Cancellable.fromFuture(new CompletableFuture<>()); + + InternalStreamingRequest(StreamingRequest request) { + Map params = new HashMap<>(request.getParameters()); + // ignore is a special parameter supported by the clients, shouldn't be sent to es + String ignoreString = params.remove("ignore"); + this.ignoreErrorCodes = getIgnoreErrorCodes(ignoreString, request.getMethod()); + + this.httpRequest = node -> { + URI uri = buildUri(pathPrefix, request.getEndpoint(), params); + HttpRequest.Builder builder = createStreamingHttpRequest( + node, + request.getMethod(), + uri, + request.getBody(), + request.getOptions().getTimeout(), + compressionEnabled + ); + setHeaders(builder, request.getOptions().getHeaders()); + return builder.build(); + }; + + this.warningsHandler = request.getOptions().getWarningsHandler() == null + ? RestHttpClient.this.warningsHandler + : request.getOptions().getWarningsHandler(); + } + + private void setHeaders(HttpRequest.Builder httpRequest, Map> requestHeaders) { + // request headers override default headers, so we don't add default headers if they exist as request headers + final Set requestNames = new HashSet<>(requestHeaders.size()); + for (Map.Entry> requestHeader : requestHeaders.entrySet()) { + requestHeader.getValue().forEach(v -> httpRequest.header(requestHeader.getKey(), v)); + requestNames.add(requestHeader.getKey()); + } + for (Map.Entry> defaultHeader : defaultHeaders.entrySet()) { + if (requestNames.contains(defaultHeader.getKey()) == false) { + defaultHeader.getValue().forEach(v -> httpRequest.header(defaultHeader.getKey(), v)); + } + } + if (compressionEnabled) { + httpRequest.header("Content-Encoding", "gzip"); + httpRequest.header("Accept-Encoding", "gzip"); + } + } + + private void setCancellable(Future f) { + cancellable = Cancellable.fromFuture(f); + } + + RequestContext>> createContextForNextAttempt(Node node) { + return new ReactiveRequestContext(this, node); + } + } + + private class InternalRequest { + private final Set ignoreErrorCodes; + private final Function httpRequest; + private final WarningsHandler warningsHandler; + private volatile Cancellable cancellable = Cancellable.fromFuture(new CompletableFuture<>()); + + InternalRequest(Request request) { + Map params = new HashMap<>(request.getParameters()); + // ignore is a special parameter supported by the clients, shouldn't be sent to es + String ignoreString = params.remove("ignore"); + this.ignoreErrorCodes = getIgnoreErrorCodes(ignoreString, request.getMethod()); + this.httpRequest = node -> { + URI uri = buildUri(pathPrefix, request.getEndpoint(), params); + final HttpRequest.Builder builder = createHttpRequest( + node, + request.getMethod(), + uri, + request.getEntity(), + request.getOptions().getTimeout(), + compressionEnabled + ); + setHeaders(builder, request.getOptions().getHeaders()); + return builder.build(); + }; + this.warningsHandler = request.getOptions().getWarningsHandler() == null + ? RestHttpClient.this.warningsHandler + : request.getOptions().getWarningsHandler(); + } + + private void setCancellable(Future f) { + cancellable = Cancellable.fromFuture(f); + } + + private void setHeaders(HttpRequest.Builder httpRequest, Map> requestHeaders) { + // request headers override default headers, so we don't add default headers if they exist as request headers + final Set requestNames = new HashSet<>(requestHeaders.size()); + + for (Map.Entry> requestHeader : requestHeaders.entrySet()) { + requestHeader.getValue().forEach(v -> httpRequest.header(requestHeader.getKey(), v)); + requestNames.add(requestHeader.getKey()); + } + for (Map.Entry> defaultHeader : defaultHeaders.entrySet()) { + if (requestNames.contains(defaultHeader.getKey()) == false) { + defaultHeader.getValue().forEach(v -> httpRequest.header(defaultHeader.getKey(), v)); + } + } + if (compressionEnabled) { + httpRequest.header("Content-Encoding", "gzip"); + httpRequest.header("Accept-Encoding", "gzip"); + } + } + + RequestContext> createContextForNextAttempt(Node node) { + return new AsyncRequestContext(this, node); + } + } + + private interface RequestContext { + Node node(); + + HttpRequest requestProducer(); + + BodyHandler asyncResponseConsumer(); + } + + private static class ReactiveRequestContext implements RequestContext>> { + private final Node node; + private final HttpRequest requestProducer; + private final BodyHandler>> asyncResponseConsumer; + + ReactiveRequestContext(InternalStreamingRequest request, Node node) { + this.node = node; + // we stream the request body if the entity allows for it + this.requestProducer = request.httpRequest.apply(node); + this.asyncResponseConsumer = BodyHandlers.ofPublisher(); + } + + @Override + public BodyHandler>> asyncResponseConsumer() { + return asyncResponseConsumer; + } + + @Override + public Node node() { + return node; + } + + @Override + public HttpRequest requestProducer() { + return requestProducer; + } + } + + private static class AsyncRequestContext implements RequestContext> { + private final Node node; + private final HttpRequest requestProducer; + private final BodyHandler> asyncResponseConsumer; + + AsyncRequestContext(InternalRequest request, Node node) { + this.node = node; + this.requestProducer = request.httpRequest.apply(node); + this.asyncResponseConsumer = BodyHandlers.fromSubscriber(new AsyncResponseProducer(), AsyncResponseProducer::getResult); + } + + @Override + public BodyHandler> asyncResponseConsumer() { + return asyncResponseConsumer; + } + + @Override + public Node node() { + return node; + } + + @Override + public HttpRequest requestProducer() { + return requestProducer; + } + } + + private static Set getIgnoreErrorCodes(String ignoreString, String requestMethod) { + Set ignoreErrorCodes; + if (ignoreString == null) { + if ("HEAD".equalsIgnoreCase(requestMethod)) { + // 404 never causes error if returned for a HEAD request + ignoreErrorCodes = Collections.singleton(404); + } else { + ignoreErrorCodes = Collections.emptySet(); + } + } else { + String[] ignoresArray = ignoreString.split(","); + ignoreErrorCodes = new HashSet<>(); + if ("HEAD".equalsIgnoreCase(requestMethod)) { + // 404 never causes error if returned for a HEAD request + ignoreErrorCodes.add(404); + } + for (String ignoreCode : ignoresArray) { + try { + ignoreErrorCodes.add(Integer.valueOf(ignoreCode)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("ignore value should be a number, found [" + ignoreString + "] instead", e); + } + } + } + return ignoreErrorCodes; + } + + private static class ResponseOrResponseException { + private final Response response; + private final ResponseException responseException; + + ResponseOrResponseException(Response response) { + this.response = Objects.requireNonNull(response); + this.responseException = null; + } + + ResponseOrResponseException(ResponseException responseException) { + this.responseException = Objects.requireNonNull(responseException); + this.response = null; + } + } + + /** + * Wrap the exception so the caller's signature shows up in the stack trace, taking care to copy the original type and message + * where possible so async and sync code don't have to check different exceptions. + */ + private static Exception extractAndWrapCause(Exception exception) { + if (exception instanceof InterruptedException) { + throw new RuntimeException("thread waiting for the response was interrupted", exception); + } + if (exception instanceof ExecutionException || exception instanceof CompletionException) { + Throwable t = exception.getCause() == null ? exception : exception.getCause(); + if (t instanceof Error) { + throw (Error) t; + } + exception = (Exception) t; + } + if (exception instanceof HttpTimeoutException) { + HttpTimeoutException e = new HttpTimeoutException(exception.getMessage()); + e.initCause(exception); + return e; + } + if (exception instanceof ClosedChannelException) { + ClosedChannelException e = new ClosedChannelException(); + e.initCause(exception); + return e; + } + if (exception instanceof SocketTimeoutException) { + SocketTimeoutException e = new SocketTimeoutException(exception.getMessage()); + e.initCause(exception); + return e; + } + if (exception instanceof SSLHandshakeException) { + SSLHandshakeException e = new SSLHandshakeException( + exception.getMessage() + "\nSee https://opensearch.org/docs/latest/clients/java-rest-high-level/ for troubleshooting." + ); + e.initCause(exception); + return e; + } + if (exception instanceof ConnectException) { + ConnectException e = new ConnectException(exception.getMessage()); + e.initCause(exception); + return e; + } + if (exception instanceof IOException) { + return new IOException(exception.getMessage(), exception); + } + if (exception instanceof RuntimeException) { + return new RuntimeException(exception.getMessage(), exception); + } + return new RuntimeException("error while performing request", exception); + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClientBuilder.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClientBuilder.java new file mode 100644 index 0000000000000..149e4e7a8f227 --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClientBuilder.java @@ -0,0 +1,251 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import org.opensearch.httpclient.internal.Node; +import org.opensearch.httpclient.internal.NodeSelector; + +import javax.net.ssl.SSLContext; + +import java.net.Authenticator; +import java.net.http.HttpClient; +import java.security.AccessController; +import java.security.NoSuchAlgorithmException; +import java.security.PrivilegedAction; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Helps creating a new {@link RestHttpClient}. Allows to set the most common http client configuration options when internally + * creating the underlying {@link HttpClient}. Also allows to provide an externally created + * {@link HttpClient} in case additional customization is needed. + * + * Note: This is an experimental API. + */ +public final class RestHttpClientBuilder { + /** + * The default connection timeout in milliseconds. + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 1000; + + /** + * The default response timeout in milliseconds. + */ + public static final int DEFAULT_RESPONSE_TIMEOUT_MILLIS = 30000; + + private final List nodes; + private Map> defaultHeaders = Map.of(); + private RestHttpClient.FailureListener failureListener; + private HttpClientConfigCallback httpClientConfigCallback; + private String pathPrefix; + private NodeSelector nodeSelector = NodeSelector.ANY; + private boolean strictDeprecationMode = false; + private boolean compressionEnabled = false; + + /** + * Creates a new builder instance and sets the hosts that the client will send requests to. + * + * @throws IllegalArgumentException if {@code nodes} is {@code null} or empty. + */ + RestHttpClientBuilder(List nodes) { + if (nodes == null || nodes.isEmpty()) { + throw new IllegalArgumentException("nodes must not be null or empty"); + } + for (Node node : nodes) { + if (node == null) { + throw new IllegalArgumentException("node cannot be null"); + } + } + this.nodes = nodes; + } + + /** + * Sets the default request headers, which will be sent along with each request. + *

+ * Request-time headers will always overwrite any default headers. + * + * @param defaultHeaders array of default header + * @throws NullPointerException if {@code defaultHeaders} or any header is {@code null}. + */ + public RestHttpClientBuilder setDefaultHeaders(Map> defaultHeaders) { + Objects.requireNonNull(defaultHeaders, "defaultHeaders must not be null"); + this.defaultHeaders = Collections.unmodifiableMap(defaultHeaders); + return this; + } + + /** + * Sets the {@link RestHttpClient.FailureListener} to be notified for each request failure + * + * @param failureListener the {@link RestHttpClient.FailureListener} for each failure + * @throws NullPointerException if {@code failureListener} is {@code null}. + */ + public RestHttpClientBuilder setFailureListener(RestHttpClient.FailureListener failureListener) { + Objects.requireNonNull(failureListener, "failureListener must not be null"); + this.failureListener = failureListener; + return this; + } + + /** + * Sets the {@link HttpClientConfigCallback} to be used to customize http client configuration + * + * @param httpClientConfigCallback the {@link HttpClientConfigCallback} to be used + * @throws NullPointerException if {@code httpClientConfigCallback} is {@code null}. + */ + public RestHttpClientBuilder setHttpClientConfigCallback(HttpClientConfigCallback httpClientConfigCallback) { + Objects.requireNonNull(httpClientConfigCallback, "httpClientConfigCallback must not be null"); + this.httpClientConfigCallback = httpClientConfigCallback; + return this; + } + + /** + * Sets the path's prefix for every request used by the http client. + *

+ * For example, if this is set to "/my/path", then any client request will become "/my/path/" + endpoint. + *

+ * In essence, every request's {@code endpoint} is prefixed by this {@code pathPrefix}. The path prefix is useful for when + * OpenSearch is behind a proxy that provides a base path or a proxy that requires all paths to start with '/'; + * it is not intended for other purposes and it should not be supplied in other scenarios. + * + * @param pathPrefix the path prefix for every request. + * @throws NullPointerException if {@code pathPrefix} is {@code null}. + * @throws IllegalArgumentException if {@code pathPrefix} is empty, or ends with more than one '/'. + */ + public RestHttpClientBuilder setPathPrefix(String pathPrefix) { + this.pathPrefix = cleanPathPrefix(pathPrefix); + return this; + } + + /** + * Cleans up the given path prefix to ensure that looks like "/base/path". + * + * @param pathPrefix the path prefix to be cleaned up. + * @return the cleaned up path prefix. + * @throws NullPointerException if {@code pathPrefix} is {@code null}. + * @throws IllegalArgumentException if {@code pathPrefix} is empty, or ends with more than one '/'. + */ + public static String cleanPathPrefix(String pathPrefix) { + Objects.requireNonNull(pathPrefix, "pathPrefix must not be null"); + + if (pathPrefix.isEmpty()) { + throw new IllegalArgumentException("pathPrefix must not be empty"); + } + + String cleanPathPrefix = pathPrefix; + if (cleanPathPrefix.startsWith("/") == false) { + cleanPathPrefix = "/" + cleanPathPrefix; + } + + // best effort to ensure that it looks like "/base/path" rather than "/base/path/" + if (cleanPathPrefix.endsWith("/") && cleanPathPrefix.length() > 1) { + cleanPathPrefix = cleanPathPrefix.substring(0, cleanPathPrefix.length() - 1); + + if (cleanPathPrefix.endsWith("/")) { + throw new IllegalArgumentException("pathPrefix is malformed. too many trailing slashes: [" + pathPrefix + "]"); + } + } + return cleanPathPrefix; + } + + /** + * Sets the {@link NodeSelector} to be used for all requests. + * + * @param nodeSelector the {@link NodeSelector} to be used + * @throws NullPointerException if the provided nodeSelector is null + */ + public RestHttpClientBuilder setNodeSelector(NodeSelector nodeSelector) { + Objects.requireNonNull(nodeSelector, "nodeSelector must not be null"); + this.nodeSelector = nodeSelector; + return this; + } + + /** + * Whether the REST client should return any response containing at least + * one warning header as a failure. + * + * @param strictDeprecationMode flag for enabling strict deprecation mode + */ + public RestHttpClientBuilder setStrictDeprecationMode(boolean strictDeprecationMode) { + this.strictDeprecationMode = strictDeprecationMode; + return this; + } + + /** + * Whether the REST client should compress requests using gzip content encoding and add the "Accept-Encoding: gzip" + * header to receive compressed responses. + * + * @param compressionEnabled flag for enabling compression + */ + public RestHttpClientBuilder setCompressionEnabled(boolean compressionEnabled) { + this.compressionEnabled = compressionEnabled; + return this; + } + + /** + * Creates a new {@link RestHttpClient} based on the provided configuration. + */ + public RestHttpClient build() { + if (failureListener == null) { + failureListener = new RestHttpClient.FailureListener(); + } + @SuppressWarnings("removal") + HttpClient httpClient = AccessController.doPrivileged((PrivilegedAction) this::createHttpClient); + + return new RestHttpClient( + httpClient, + defaultHeaders, + nodes, + pathPrefix, + failureListener, + nodeSelector, + strictDeprecationMode, + compressionEnabled + ); + } + + @SuppressWarnings("removal") + private HttpClient createHttpClient() { + try { + HttpClient.Builder httpClientBuilder = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(DEFAULT_CONNECT_TIMEOUT_MILLIS)) + .sslContext(SSLContext.getDefault()); + + if (httpClientConfigCallback != null) { + httpClientBuilder = httpClientConfigCallback.customizeHttpClient(httpClientBuilder); + } + + final HttpClient.Builder finalBuilder = httpClientBuilder; + return AccessController.doPrivileged((PrivilegedAction) finalBuilder::build); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("could not create the default ssl context", e); + } + } + + /** + * Callback used to customize the {@link HttpClient} instance used by a {@link RestHttpClient} instance. + */ + public interface HttpClientConfigCallback { + /** + * Allows to customize the {@link HttpClient} being created and used by the {@link RestHttpClient}. + * Commonly used to customize the default {@link Authenticator} for authentication for communication + * through TLS/SSL without losing any other useful default value that the {@link RestHttpClientBuilder} internally + * sets, like connection pooling. + * + * @param httpClientBuilder the {@link HttpClient.Builder} for customizing the client instance. + */ + HttpClient.Builder customizeHttpClient(HttpClient.Builder httpClientBuilder); + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/StatusLine.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/StatusLine.java new file mode 100644 index 0000000000000..d06416bf4da43 --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/StatusLine.java @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.net.http.HttpClient.Version; +import java.net.http.HttpResponse; +import java.util.Objects; + +/** + * Response status line (protocol, status code) + * Note: This is an experimental API. + */ +public final class StatusLine { + /** + * The protocol version. + */ + private final Version protoVersion; + + /** + * The status code. + */ + private final int statusCode; + + /** + * Creates a new status line from the response + * + * @param response HTTP response + */ + public StatusLine(final HttpResponse response) { + Objects.requireNonNull(response, "Response"); + this.protoVersion = response.version(); + this.statusCode = response.statusCode(); + } + + /** + * Creates a new status line with the given version and status. + * + * @param protoVersion the protocol version of the response + * @param statusCode the status code of the response + */ + public StatusLine(final Version protoVersion, final int statusCode) { + this.statusCode = statusCode; + this.protoVersion = protoVersion != null ? protoVersion : Version.HTTP_1_1; + } + + /** + * Gets the response HTTP status code + * @return HTTP status code + */ + public int getStatusCode() { + return this.statusCode; + } + + /** + * Gets the response HTTP protocol + * @return HTTP protocol + */ + public Version getProtocolVersion() { + return this.protoVersion; + } + + /** + * Converts the status line to string + */ + @Override + public String toString() { + final StringBuilder buf = new StringBuilder(); + buf.append(this.protoVersion).append(" ").append(this.statusCode).append(" "); + return buf.toString(); + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingRequest.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingRequest.java new file mode 100644 index 0000000000000..8e8f6d04a0abe --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingRequest.java @@ -0,0 +1,140 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import org.reactivestreams.Publisher; + +import static java.util.Collections.unmodifiableMap; + +/** + * HTTP Streaming Request to OpenSearch. + * Note: This is an experimental API. + */ +public class StreamingRequest { + private final String method; + private final String endpoint; + private final Map parameters = new HashMap<>(); + + private RequestOptions options = RequestOptions.DEFAULT; + private final Publisher publisher; + + /** + * Constructor + * @param method method + * @param endpoint endpoint + * @param publisher publisher + */ + public StreamingRequest(String method, String endpoint, Publisher publisher) { + this.method = method; + this.endpoint = endpoint; + this.publisher = publisher; + } + + /** + * Get endpoint + * @return endpoint + */ + public String getEndpoint() { + return endpoint; + } + + /** + * Get method + * @return method + */ + public String getMethod() { + return method; + } + + /** + * Get options + * @return options + */ + public RequestOptions getOptions() { + return options; + } + + /** + * Get parameters + * @return parameters + */ + public Map getParameters() { + if (options.getParameters().isEmpty()) { + return unmodifiableMap(parameters); + } else { + Map combinedParameters = new HashMap<>(parameters); + combinedParameters.putAll(options.getParameters()); + return unmodifiableMap(combinedParameters); + } + } + + /** + * Set the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * + * @param options the options to be set. + * @throws NullPointerException if {@code options} is null. + */ + public void setOptions(RequestOptions options) { + Objects.requireNonNull(options, "options cannot be null"); + this.options = options; + } + + /** + * Set the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * + * @param options the options to be set. + * @throws NullPointerException if {@code options} is null. + */ + public void setOptions(RequestOptions.Builder options) { + Objects.requireNonNull(options, "options cannot be null"); + this.options = options.build(); + } + + /** + * Add a query string parameter. + * @param name the name of the url parameter. Must not be null. + * @param value the value of the url url parameter. If {@code null} then + * the parameter is sent as {@code name} rather than {@code name=value} + * @throws IllegalArgumentException if a parameter with that name has + * already been set + */ + public void addParameter(String name, String value) { + Objects.requireNonNull(name, "url parameter name cannot be null"); + if (parameters.containsKey(name)) { + throw new IllegalArgumentException("url parameter [" + name + "] has already been set to [" + parameters.get(name) + "]"); + } else { + parameters.put(name, value); + } + } + + /** + * Add query parameters using the provided map of key value pairs. + * + * @param paramSource a map of key value pairs where the key is the url parameter. + * @throws IllegalArgumentException if a parameter with that name has already been set. + */ + public void addParameters(Map paramSource) { + paramSource.forEach(this::addParameter); + } + + /** + * Body publisher + * @return body publisher + */ + public Publisher getBody() { + return publisher; + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingResponse.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingResponse.java new file mode 100644 index 0000000000000..de20156d57587 --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingResponse.java @@ -0,0 +1,138 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.Flow; + +import org.reactivestreams.Publisher; +import reactor.adapter.JdkFlowAdapter; +import reactor.core.publisher.Mono; + +/** + * HTTP Streaming Response from OpenSearch. + * Note: This is an experimental API. + */ +public class StreamingResponse { + private final RequestLine requestLine; + private final Mono>>> publisher; + private volatile HttpHost host; + + /** + * Constructor + * @param requestLine request line + * @param publisher message publisher(response with a body) + */ + public StreamingResponse(RequestLine requestLine, Publisher>>> publisher) { + this.requestLine = requestLine; + // We cache the publisher here so the body or / and HttpResponse could + // be consumed independently or/and more than once. + this.publisher = Mono.from(publisher).cache(); + } + + /** + * Set host + * @param host host + */ + public void setHost(HttpHost host) { + this.host = host; + } + + /** + * Get request line + * @return request line + */ + public RequestLine getRequestLine() { + return requestLine; + } + + /** + * Get host + * @return host + */ + public HttpHost getHost() { + return host; + } + + /** + * Get response boby {@link Publisher} + * @return response boby {@link Publisher} + */ + public Publisher getBody() { + return publisher.flatMapMany(m -> { + final boolean compressed = m.headers() + .firstValue("Content-Encoding") + .filter("gzip"::equalsIgnoreCase) + .map(h -> true) + .orElse(false); + return JdkFlowAdapter.flowPublisherToFlux(m.body()).flatMapIterable(t -> t).map(b -> { + if (compressed) { + return BodyUtils.decompress(b); + } else { + return b; + } + }); + }); + } + + /** + * Returns the status line of the current response + */ + @SuppressWarnings("unchecked") + public StatusLine getStatusLine() { + return new StatusLine( + publisher.onErrorResume( + ResponseException.class, + e -> Mono.just((HttpResponse>>) e.getResponse().getHttpResponse()) + ).block() + ); + } + + /** + * Returns a list of all warning headers returned in the response. + */ + @SuppressWarnings("unchecked") + public List getWarnings() { + return ResponseWarningsExtractor.getWarnings( + publisher.onErrorResume( + ResponseException.class, + e -> Mono.just((HttpResponse>>) e.getResponse().getHttpResponse()) + ).block() + ); + } + + /** + * Returns a list of all headers returned in the response. + */ + @SuppressWarnings("unchecked") + public HttpHeaders getHeaders() { + return publisher.onErrorResume( + ResponseException.class, + e -> Mono.just((HttpResponse>>) e.getResponse().getHttpResponse()) + ).map(HttpResponse::headers).block(); + } + + /** + * Returns the value of the first header with a specified name of this message. + * If there is more than one matching header in the message the first element is returned. + * If there is no matching header in the message null is returned. + * + * @param name header name + */ + @SuppressWarnings("unchecked") + public String getHeader(String name) { + return publisher.onErrorResume( + ResponseException.class, + e -> Mono.just((HttpResponse>>) e.getResponse().getHttpResponse()) + ).mapNotNull(response -> response.headers().firstValue(name).orElse(null)).block(); + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningFailureException.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningFailureException.java new file mode 100644 index 0000000000000..23e0f779cac0f --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningFailureException.java @@ -0,0 +1,79 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import java.io.IOException; + +import static org.opensearch.httpclient.ResponseException.buildMessage; + +/** + * This exception is used to indicate that one or more {@link Response#getWarnings()} exist + * and is typically used when the {@link RestHttpClient} is set to fail by setting + * {@link RestHttpClientBuilder#setStrictDeprecationMode(boolean)} to `true`. + */ +// This class extends RuntimeException in order to deal with wrapping that is done in FutureUtils on exception. +// if the exception is not of type OpenSearchException or RuntimeException it will be wrapped in a UncategorizedExecutionException +public final class WarningFailureException extends RuntimeException { + + private final Response response; + + /** + * Creates a {@link WarningFailureException} instance. + * + * @param response the response that contains warnings. + * @throws IOException if there is a problem building the exception message. + */ + public WarningFailureException(Response response) throws IOException { + super(buildMessage(response)); + this.response = response; + } + + /** + * Wrap a {@linkplain WarningFailureException} with another one with the current + * stack trace. This is used during synchronous calls so that the caller + * ends up in the stack trace of the exception thrown. + * + * @param e the exception to be wrapped. + */ + WarningFailureException(WarningFailureException e) { + super(e.getMessage(), e); + this.response = e.getResponse(); + } + + /** + * Returns the {@link Response} that caused this exception to be thrown. + */ + public Response getResponse() { + return response; + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningsHandler.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningsHandler.java new file mode 100644 index 0000000000000..2d23ec7ec3720 --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningsHandler.java @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import java.util.List; + +/** + * Called if there are warnings to determine if those warnings should fail the + * request. + */ +public interface WarningsHandler { + + /** + * Determines whether the given list of warnings should fail the request. + * + * @param warnings a list of warnings. + * @return boolean indicating if the request should fail. + */ + boolean warningsShouldFailRequest(List warnings); + + /** + * The permissive warnings handler. Warnings will not fail the request. + */ + WarningsHandler PERMISSIVE = new WarningsHandler() { + @Override + public boolean warningsShouldFailRequest(List warnings) { + return false; + } + + @Override + public String toString() { + return "permissive"; + } + }; + + /** + * The strict warnings handler. Warnings will fail the request. + */ + WarningsHandler STRICT = new WarningsHandler() { + @Override + public boolean warningsShouldFailRequest(List warnings) { + return false == warnings.isEmpty(); + } + + @Override + public String toString() { + return "strict"; + } + }; +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/Node.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/Node.java new file mode 100644 index 0000000000000..1f7fe024713f2 --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/Node.java @@ -0,0 +1,280 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient.internal; + +import org.opensearch.httpclient.HttpHost; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** + * Metadata about an {@link HttpHost} running OpenSearch. + */ +public class Node { + /** + * Address that this host claims is its primary contact point. + */ + private final HttpHost host; + /** + * Addresses on which the host is listening. These are useful to have + * around because they allow you to find a host based on any address it + * is listening on. + */ + private final Set boundHosts; + /** + * Name of the node as configured by the {@code node.name} attribute. + */ + private final String name; + /** + * Version of OpenSearch that the node is running or {@code null} + * if we don't know the version. + */ + private final String version; + /** + * Roles that the OpenSearch process on the host has or {@code null} + * if we don't know what roles the node has. + */ + private final Roles roles; + /** + * Attributes declared on the node. + */ + private final Map> attributes; + + /** + * Create a {@linkplain Node} with metadata. All parameters except + * {@code host} are nullable and implementations of {@link NodeSelector} + * need to decide what to do in their absence. + * + * @param host primary host address + * @param boundHosts addresses on which the host is listening + * @param name name of the node + * @param version version of OpenSearch + * @param roles roles that the OpenSearch process has on the host + * @param attributes attributes declared on the node + */ + public Node(HttpHost host, Set boundHosts, String name, String version, Roles roles, Map> attributes) { + if (host == null) { + throw new IllegalArgumentException("host cannot be null"); + } + this.host = host; + this.boundHosts = boundHosts; + this.name = name; + this.version = version; + this.roles = roles; + this.attributes = attributes; + } + + /** + * Create a {@linkplain Node} without any metadata. + * + * @param host primary host address + */ + public Node(HttpHost host) { + this(host, null, null, null, null, null); + } + + /** + * Contact information for the host. + */ + public HttpHost getHost() { + return host; + } + + /** + * Addresses on which the host is listening. These are useful to have + * around because they allow you to find a host based on any address it + * is listening on. + */ + public Set getBoundHosts() { + return boundHosts; + } + + /** + * The {@code node.name} of the node. + */ + public String getName() { + return name; + } + + /** + * Version of OpenSearch that the node is running or {@code null} + * if we don't know the version. + */ + public String getVersion() { + return version; + } + + /** + * Roles that the OpenSearch process on the host has or {@code null} + * if we don't know what roles the node has. + */ + public Roles getRoles() { + return roles; + } + + /** + * Attributes declared on the node. + */ + public Map> getAttributes() { + return attributes; + } + + /** + * Convert node to string representation + */ + @Override + public String toString() { + StringBuilder b = new StringBuilder(); + b.append("[host=").append(host); + if (boundHosts != null) { + b.append(", bound=").append(boundHosts); + } + if (name != null) { + b.append(", name=").append(name); + } + if (version != null) { + b.append(", version=").append(version); + } + if (roles != null) { + b.append(", roles=").append(roles); + } + if (attributes != null) { + b.append(", attributes=").append(attributes); + } + return b.append(']').toString(); + } + + /** + * Compare two nodes for equality + * @param obj node instance to compare with + */ + @Override + public boolean equals(Object obj) { + if (obj == null || obj.getClass() != getClass()) { + return false; + } + Node other = (Node) obj; + return host.equals(other.host) + && Objects.equals(boundHosts, other.boundHosts) + && Objects.equals(name, other.name) + && Objects.equals(version, other.version) + && Objects.equals(roles, other.roles) + && Objects.equals(attributes, other.attributes); + } + + /** + * Calculate the hash code of the node + */ + @Override + public int hashCode() { + return Objects.hash(host, boundHosts, name, version, roles, attributes); + } + + /** + * Role information about an OpenSearch process. + */ + public static final class Roles { + + private final Set roles; + + /** + * Create a {@link Roles} instance of the given string set. + * + * @param roles set of role names. + */ + public Roles(final Set roles) { + this.roles = new TreeSet<>(roles); + } + + /** + * Returns whether or not the node could be elected cluster-manager. + */ + public boolean isClusterManagerEligible() { + return roles.contains("master") || roles.contains("cluster_manager"); + } + + /** + * Returns whether or not the node stores data. + */ + public boolean isData() { + return roles.contains("data"); + } + + /** + * Returns whether or not the node runs ingest pipelines. + */ + public boolean isIngest() { + return roles.contains("ingest"); + } + + /** + * Returns whether the node is dedicated to provide search capability. + */ + public boolean isSearch() { + return roles.contains("search"); + } + + /** + * Convert roles to string representation + */ + @Override + public String toString() { + return String.join(",", roles); + } + + /** + * Compare two roles for equality + * @param obj roles instance to compare with + */ + @Override + public boolean equals(Object obj) { + if (obj == null || obj.getClass() != getClass()) { + return false; + } + Roles other = (Roles) obj; + return roles.equals(other.roles); + } + + /** + * Calculate the hash code of the roles + */ + @Override + public int hashCode() { + return roles.hashCode(); + } + + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/NodeSelector.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/NodeSelector.java new file mode 100644 index 0000000000000..12f628d8f02bd --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/NodeSelector.java @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient.internal; + +import org.opensearch.httpclient.RestHttpClient; +import org.opensearch.httpclient.RestHttpClientBuilder; + +import java.util.Iterator; + +/** + * Selects nodes that can receive requests. Used to keep requests away + * from cluster-manager nodes or to send them to nodes with a particular attribute. + * Use with {@link RestHttpClientBuilder#setNodeSelector(NodeSelector)}. + */ +public interface NodeSelector { + /** + * Select the {@link Node}s to which to send requests. This is called with + * a mutable {@link Iterable} of {@linkplain Node}s in the order that the + * rest client would prefer to use them and implementers should remove + * nodes from the that should not receive the request. Implementers may + * iterate the nodes as many times as they need. + *

+ * This may be called twice per request: first for "living" nodes that + * have not been denylisted by previous errors. If the selector removes + * all nodes from the list or if there aren't any living nodes then the + * {@link RestHttpClient} will call this method with a list of "dead" nodes. + *

+ * Implementers should not rely on the ordering of the nodes. + * + * @param nodes the {@link Node}s targeted for the sending requests + */ + void select(Iterable nodes); + /* + * We were fairly careful with our choice of Iterable here. The caller has + * a List but reordering the list is likely to break round robin. Luckily + * Iterable doesn't allow any reordering. + */ + + /** + * Selector that matches any node. + */ + NodeSelector ANY = new NodeSelector() { + @Override + public void select(Iterable nodes) { + // Intentionally does nothing + } + + @Override + public String toString() { + return "ANY"; + } + }; + + /** + * Selector that matches any node that has metadata and doesn't + * have the {@code cluster_manager} role OR it has the data {@code data} + * role. + */ + NodeSelector SKIP_DEDICATED_CLUSTER_MANAGERS = new NodeSelector() { + @Override + public void select(Iterable nodes) { + for (Iterator itr = nodes.iterator(); itr.hasNext();) { + Node node = itr.next(); + if (node.getRoles() == null) continue; + if (node.getRoles().isClusterManagerEligible() + && false == node.getRoles().isData() + && false == node.getRoles().isIngest()) { + itr.remove(); + } + } + } + + @Override + public String toString() { + return "SKIP_DEDICATED_CLUSTER_MANAGERS"; + } + }; +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/BouncyCastleThreadFilter.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/BouncyCastleThreadFilter.java new file mode 100644 index 0000000000000..2c4a6ea8217b8 --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/BouncyCastleThreadFilter.java @@ -0,0 +1,25 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import com.carrotsearch.randomizedtesting.ThreadFilter; + +/** + * ThreadFilter to exclude ThreadLeak checks for BC’s global background threads + * + *

clone from the original, which is located in ':test:framework'

+ */ +public class BouncyCastleThreadFilter implements ThreadFilter { + @Override + public boolean reject(Thread t) { + String n = t.getName(); + // Ignore BC’s global background threads + return "BC Disposal Daemon".equals(n) || "BC Cleanup Executor".equals(n); + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/HostsTrackingFailureListener.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/HostsTrackingFailureListener.java new file mode 100644 index 0000000000000..2611e87cc30a5 --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/HostsTrackingFailureListener.java @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import org.opensearch.httpclient.internal.Node; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; + +/** + * {@link RestHttpClient.FailureListener} impl that allows to track when it gets called for which host. + */ +class HostsTrackingFailureListener extends RestHttpClient.FailureListener { + private volatile Set hosts = new HashSet<>(); + + @Override + public void onFailure(Node node) { + hosts.add(node.getHost()); + } + + void assertCalled(List nodes) { + HttpHost[] hosts = new HttpHost[nodes.size()]; + for (int i = 0; i < nodes.size(); i++) { + hosts[i] = nodes.get(i).getHost(); + } + assertCalled(hosts); + } + + void assertCalled(HttpHost... hosts) { + assertEquals(hosts.length, this.hosts.size()); + assertThat(this.hosts, containsInAnyOrder(hosts)); + this.hosts.clear(); + } + + void assertNotCalled() { + assertEquals(0, hosts.size()); + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/HttpClientThreadLeakFilter.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/HttpClientThreadLeakFilter.java new file mode 100644 index 0000000000000..1b0a38f37b92d --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/HttpClientThreadLeakFilter.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import com.carrotsearch.randomizedtesting.ThreadFilter; + +import java.net.http.HttpClient; + +/** + * The {@link HttpClient} creates own ASYNC pool based of {@code ForkJoin.commonPool()} which + * is impossible to supress or override. + */ +public final class HttpClientThreadLeakFilter implements ThreadFilter { + @Override + public boolean reject(Thread t) { + return t.getName().startsWith("ForkJoinPool.commonPool-"); + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestClientTestUtil.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestClientTestUtil.java new file mode 100644 index 0000000000000..391c5842beaaa --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestClientTestUtil.java @@ -0,0 +1,120 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import com.carrotsearch.randomizedtesting.generators.RandomNumbers; +import com.carrotsearch.randomizedtesting.generators.RandomPicks; +import com.carrotsearch.randomizedtesting.generators.RandomStrings; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +final class RestClientTestUtil { + + private static final String[] HTTP_METHODS = new String[] { "DELETE", "HEAD", "GET", "OPTIONS", "PATCH", "POST", "PUT", "TRACE" }; + private static final List ALL_STATUS_CODES; + private static final List OK_STATUS_CODES = Arrays.asList(200, 201); + private static final List ALL_ERROR_STATUS_CODES; + private static List ERROR_NO_RETRY_STATUS_CODES = Arrays.asList(400, 401, 403, 404, 405, 500); + private static List ERROR_RETRY_STATUS_CODES = Arrays.asList(502, 503, 504); + + static { + ALL_ERROR_STATUS_CODES = new ArrayList<>(ERROR_RETRY_STATUS_CODES); + ALL_ERROR_STATUS_CODES.addAll(ERROR_NO_RETRY_STATUS_CODES); + ALL_STATUS_CODES = new ArrayList<>(ALL_ERROR_STATUS_CODES); + ALL_STATUS_CODES.addAll(OK_STATUS_CODES); + } + + private RestClientTestUtil() { + + } + + static String[] getHttpMethods() { + return HTTP_METHODS; + } + + static String randomHttpMethod(Random random) { + return RandomPicks.randomFrom(random, HTTP_METHODS); + } + + static int randomStatusCode(Random random) { + return RandomPicks.randomFrom(random, ALL_STATUS_CODES); + } + + static int randomOkStatusCode(Random random) { + return RandomPicks.randomFrom(random, OK_STATUS_CODES); + } + + static int randomErrorNoRetryStatusCode(Random random) { + return RandomPicks.randomFrom(random, ERROR_NO_RETRY_STATUS_CODES); + } + + static int randomErrorRetryStatusCode(Random random) { + return RandomPicks.randomFrom(random, ERROR_RETRY_STATUS_CODES); + } + + static List getOkStatusCodes() { + return OK_STATUS_CODES; + } + + static List getAllErrorStatusCodes() { + return ALL_ERROR_STATUS_CODES; + } + + static List getAllStatusCodes() { + return ALL_STATUS_CODES; + } + + /** + * Create a random number of HTTP headers. + * Generated header names will either be the {@code baseName} plus its index, or exactly the provided {@code baseName} so that the + * we test also support for multiple headers with same key and different values. + */ + static Map> randomHeaders(Random random, final String baseName) { + final Map> headers = new HashMap<>(); + int numHeaders = RandomNumbers.randomIntBetween(random, 0, 5); + for (int i = 0; i < numHeaders; i++) { + String headerName = baseName; + // randomly exercise the code path that supports multiple headers with same key + if (random.nextBoolean()) { + headerName = headerName + i; + } + headers.put(headerName, List.of(RandomStrings.randomAsciiLettersOfLengthBetween(random, 3, 10))); + } + return headers; + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientCompressionTests.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientCompressionTests.java new file mode 100644 index 0000000000000..40a150c171f5c --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientCompressionTests.java @@ -0,0 +1,144 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.http.HttpClient.Version; +import java.net.http.HttpRequest.BodyPublishers; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +public class RestHttpClientCompressionTests extends RestHttpClientTestCase { + + private static HttpServer httpServer; + + @BeforeClass + public static void startHttpServer() throws Exception { + httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + httpServer.createContext("/", new GzipResponseHandler()); + httpServer.start(); + } + + @AfterClass + public static void stopHttpServers() throws IOException { + httpServer.stop(0); + httpServer = null; + } + + /** + * A response handler that accepts gzip-encoded data and replies request and response encoding values + * followed by the request body. The response is compressed if "Accept-Encoding" is "gzip". + */ + private static class GzipResponseHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + + // Decode body (if any) + String contentEncoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + String contentLength = exchange.getRequestHeaders().getFirst("Content-Length"); + InputStream body = exchange.getRequestBody(); + boolean compressedRequest = false; + if ("gzip".equals(contentEncoding)) { + body = new GZIPInputStream(body); + compressedRequest = true; + } + byte[] bytes = body.readAllBytes(); + boolean compress = "gzip".equals(exchange.getRequestHeaders().getFirst("Accept-Encoding")); + if (compress) { + exchange.getResponseHeaders().add("Content-Encoding", "gzip"); + } + + exchange.sendResponseHeaders(200, 0); + + // Encode response if needed + OutputStream out = exchange.getResponseBody(); + if (compress) { + out = new GZIPOutputStream(out); + } + + // Outputs ## + out.write(String.valueOf(contentEncoding).getBytes(StandardCharsets.UTF_8)); + out.write('#'); + out.write((compress ? "gzip" : "null").getBytes(StandardCharsets.UTF_8)); + out.write('#'); + out.write(((compressedRequest == true && contentLength != null) ? contentLength : "null").getBytes(StandardCharsets.UTF_8)); + out.write('#'); + out.write(bytes); + out.close(); + + exchange.close(); + } + } + + private RestHttpClient createClient(boolean enableCompression) { + InetSocketAddress address = httpServer.getAddress(); + return RestHttpClient.builder(new HttpHost("http", address.getHostString(), address.getPort())) + .setCompressionEnabled(enableCompression) + .setHttpClientConfigCallback(builder -> builder.version(Version.HTTP_1_1)) + .build(); + } + + public void testCompressingClientWithContentLengthSync() throws Exception { + try (RestHttpClient restClient = createClient(true)) { + Request request = new Request("POST", "/"); + request.setEntity(BodyPublishers.ofString("compressing client")); + + Response response = restClient.performRequest(request); + + String content = BodyUtils.getBodyAsString(response.getEntity()); + // Content-Encoding#Accept-Encoding#Content-Length#Content + // With HttpClient, we don't sent Content-Length so it is always null + Assert.assertEquals("gzip#gzip#null#compressing client", content); + } + } + + public void testCompressingClientContentLengthAsync() throws Exception { + try (RestHttpClient restClient = createClient(true)) { + Request request = new Request("POST", "/"); + request.setEntity(BodyPublishers.ofString("compressing client")); + + FutureResponse futureResponse = new FutureResponse(); + restClient.performRequestAsync(request, futureResponse); + Response response = futureResponse.get(); + + // Server should report it had a compressed request and sent back a compressed response + String content = BodyUtils.getBodyAsString(response.getEntity()); + + // Content-Encoding#Accept-Encoding#Content-Length#Content + // With HttpClient, we don't sent Content-Length so it is always null + Assert.assertEquals("gzip#gzip#null#compressing client", content); + } + } + + public static class FutureResponse extends CompletableFuture implements ResponseListener { + @Override + public void onSuccess(Response response) { + this.complete(response); + } + + @Override + public void onFailure(Exception exception) { + this.completeExceptionally(exception); + } + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientGzipCompressionTests.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientGzipCompressionTests.java new file mode 100644 index 0000000000000..1f58a56101ada --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientGzipCompressionTests.java @@ -0,0 +1,191 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.http.HttpRequest.BodyPublishers; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +public class RestHttpClientGzipCompressionTests extends RestHttpClientTestCase { + + private static HttpServer httpServer; + + @BeforeClass + public static void startHttpServer() throws Exception { + httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + httpServer.createContext("/", new GzipResponseHandler()); + httpServer.start(); + } + + @AfterClass + public static void stopHttpServers() throws IOException { + httpServer.stop(0); + httpServer = null; + } + + /** + * A response handler that accepts gzip-encoded data and replies request and response encoding values + * followed by the request body. The response is compressed if "Accept-Encoding" is "gzip". + */ + private static class GzipResponseHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + + // Decode body (if any) + String contentEncoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + InputStream body = exchange.getRequestBody(); + if ("gzip".equals(contentEncoding)) { + body = new GZIPInputStream(body); + } + byte[] bytes = body.readAllBytes(); + boolean compress = "gzip".equals(exchange.getRequestHeaders().getFirst("Accept-Encoding")); + if (compress) { + exchange.getResponseHeaders().add("Content-Encoding", "gzip"); + } + + exchange.sendResponseHeaders(200, 0); + + // Encode response if needed + OutputStream out = exchange.getResponseBody(); + if (compress) { + out = new GZIPOutputStream(out); + } + + // Outputs ## + out.write(String.valueOf(contentEncoding).getBytes(StandardCharsets.UTF_8)); + out.write('#'); + out.write((compress ? "gzip" : "null").getBytes(StandardCharsets.UTF_8)); + out.write('#'); + out.write(bytes); + out.close(); + + exchange.close(); + } + } + + private RestHttpClient createClient(boolean enableCompression) { + InetSocketAddress address = httpServer.getAddress(); + return RestHttpClient.builder(new HttpHost("http", address.getHostString(), address.getPort())) + .setCompressionEnabled(enableCompression) + .build(); + } + + public void testGzipHeaderSync() throws Exception { + try (RestHttpClient restClient = createClient(false)) { + // Send non-compressed request, expect compressed response + Request request = new Request("POST", "/"); + request.setEntity(BodyPublishers.ofString("plain request, gzip response")); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Accept-Encoding", "gzip").build()); + + Response response = restClient.performRequest(request); + + String content = BodyUtils.getBodyAsString(response.getEntity()); + Assert.assertEquals("null#gzip#plain request, gzip response", content); + } + } + + public void testGzipHeaderAsync() throws Exception { + try (RestHttpClient restClient = createClient(false)) { + // Send non-compressed request, expect compressed response + Request request = new Request("POST", "/"); + request.setEntity(BodyPublishers.ofString("plain request, gzip response")); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Accept-Encoding", "gzip").build()); + + FutureResponse futureResponse = new FutureResponse(); + restClient.performRequestAsync(request, futureResponse); + Response response = futureResponse.get(); + + String content = BodyUtils.getBodyAsString(response.getEntity()); + Assert.assertEquals("null#gzip#plain request, gzip response", content); + } + } + + public void testCompressingClientSync() throws Exception { + try (RestHttpClient restClient = createClient(true)) { + Request request = new Request("POST", "/"); + request.setEntity(BodyPublishers.ofString("compressing client")); + + Response response = restClient.performRequest(request); + + String content = BodyUtils.getBodyAsString(response.getEntity()); + Assert.assertEquals("gzip#gzip#compressing client", content); + } + } + + public void testCompressingClientAsync() throws Exception { + InetSocketAddress address = httpServer.getAddress(); + try ( + RestHttpClient restClient = RestHttpClient.builder(new HttpHost("http", address.getHostString(), address.getPort())) + .setCompressionEnabled(true) + .build() + ) { + Request request = new Request("POST", "/"); + request.setEntity(BodyPublishers.ofString("compressing client")); + + FutureResponse futureResponse = new FutureResponse(); + restClient.performRequestAsync(request, futureResponse); + Response response = futureResponse.get(); + + // Server should report it had a compressed request and sent back a compressed response + String content = BodyUtils.getBodyAsString(response.getEntity()); + Assert.assertEquals("gzip#gzip#compressing client", content); + } + } + + public static class FutureResponse extends CompletableFuture implements ResponseListener { + @Override + public void onSuccess(Response response) { + this.complete(response); + } + + @Override + public void onFailure(Exception exception) { + this.completeExceptionally(exception); + } + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsIntegTests.java new file mode 100644 index 0000000000000..d816db4d38517 --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsIntegTests.java @@ -0,0 +1,348 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.opensearch.httpclient.internal.Node; +import org.opensearch.httpclient.internal.NodeSelector; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Ignore; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.opensearch.httpclient.RestClientTestUtil.getAllStatusCodes; +import static org.opensearch.httpclient.RestClientTestUtil.randomErrorNoRetryStatusCode; +import static org.opensearch.httpclient.RestClientTestUtil.randomOkStatusCode; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Integration test to check interaction between {@link RestHttpClient} and {@link HttpClient}. + * Works against real http servers, multiple hosts. Also tests failover by randomly shutting down hosts. + */ +public class RestHttpClientMultipleHostsIntegTests extends RestHttpClientTestCase { + + private static WaitForCancelHandler waitForCancelHandler; + private static HttpServer[] httpServers; + private static HttpHost[] httpHosts; + private static boolean stoppedFirstHost = false; + private static String pathPrefixWithoutLeadingSlash; + private static String pathPrefix; + private static RestHttpClient restClient; + + @BeforeClass + public static void startHttpServer() throws Exception { + if (randomBoolean()) { + pathPrefixWithoutLeadingSlash = "testPathPrefix/" + randomAsciiLettersOfLengthBetween(1, 5); + pathPrefix = "/" + pathPrefixWithoutLeadingSlash; + } else { + pathPrefix = pathPrefixWithoutLeadingSlash = ""; + } + int numHttpServers = randomIntBetween(2, 4); + httpServers = new HttpServer[numHttpServers]; + httpHosts = new HttpHost[numHttpServers]; + waitForCancelHandler = new WaitForCancelHandler(); + for (int i = 0; i < numHttpServers; i++) { + HttpServer httpServer = createHttpServer(); + httpServers[i] = httpServer; + httpHosts[i] = new HttpHost("http", httpServer.getAddress().getHostString(), httpServer.getAddress().getPort()); + } + restClient = buildRestClient(NodeSelector.ANY); + } + + private static RestHttpClient buildRestClient(NodeSelector nodeSelector) { + RestHttpClientBuilder restClientBuilder = RestHttpClient.builder(httpHosts); + if (pathPrefix.length() > 0) { + restClientBuilder.setPathPrefix((randomBoolean() ? "/" : "") + pathPrefixWithoutLeadingSlash); + } + restClientBuilder.setNodeSelector(nodeSelector); + return restClientBuilder.build(); + } + + private static HttpServer createHttpServer() throws Exception { + HttpServer httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + httpServer.start(); + // returns a different status code depending on the path + for (int statusCode : getAllStatusCodes()) { + httpServer.createContext(pathPrefix + "/" + statusCode, new ResponseHandler(statusCode)); + } + httpServer.createContext(pathPrefix + "/wait", waitForCancelHandler); + return httpServer; + } + + private static class WaitForCancelHandler implements HttpHandler { + private volatile CountDownLatch requestCameInLatch; + private volatile CountDownLatch cancelHandlerLatch; + + void reset() { + cancelHandlerLatch = new CountDownLatch(1); + requestCameInLatch = new CountDownLatch(1); + } + + void cancelDone() { + cancelHandlerLatch.countDown(); + } + + void awaitRequest() throws InterruptedException { + requestCameInLatch.await(); + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + requestCameInLatch.countDown(); + try { + cancelHandlerLatch.await(); + } catch (InterruptedException ignore) {} finally { + exchange.sendResponseHeaders(200, 0); + exchange.close(); + } + } + } + + private static class ResponseHandler implements HttpHandler { + private final int statusCode; + + ResponseHandler(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public void handle(HttpExchange httpExchange) throws IOException { + httpExchange.getRequestBody().close(); + httpExchange.sendResponseHeaders(statusCode, -1); + httpExchange.close(); + } + } + + @AfterClass + public static void stopHttpServers() throws IOException { + restClient.close(); + restClient = null; + for (HttpServer httpServer : httpServers) { + httpServer.stop(0); + } + httpServers = null; + } + + @Before + public void stopRandomHost() { + // verify that shutting down some hosts doesn't matter as long as one working host is left behind + if (httpServers.length > 1 && randomBoolean()) { + List updatedHttpServers = new ArrayList<>(httpServers.length - 1); + int nodeIndex = randomIntBetween(0, httpServers.length - 1); + if (0 == nodeIndex) { + stoppedFirstHost = true; + } + for (int i = 0; i < httpServers.length; i++) { + HttpServer httpServer = httpServers[i]; + if (i == nodeIndex) { + httpServer.stop(0); + } else { + updatedHttpServers.add(httpServer); + } + } + httpServers = updatedHttpServers.toArray(new HttpServer[0]); + } + } + + public void testSyncRequests() throws IOException { + int numRequests = randomIntBetween(5, 20); + for (int i = 0; i < numRequests; i++) { + final String method = RestClientTestUtil.randomHttpMethod(getRandom()); + // we don't test status codes that are subject to retries as they interfere with hosts being stopped + final int statusCode = randomBoolean() ? randomOkStatusCode(getRandom()) : randomErrorNoRetryStatusCode(getRandom()); + Response response; + try { + response = restClient.performRequest(new Request(method, "/" + statusCode)); + } catch (ResponseException responseException) { + response = responseException.getResponse(); + } + assertEquals(method, response.getRequestLine().getMethod()); + assertEquals(statusCode, response.getStatusLine().getStatusCode()); + assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + statusCode, response.getRequestLine().getUri()); + } + } + + public void testAsyncRequests() throws Exception { + int numRequests = randomIntBetween(5, 20); + final CountDownLatch latch = new CountDownLatch(numRequests); + final List responses = new CopyOnWriteArrayList<>(); + for (int i = 0; i < numRequests; i++) { + final String method = RestClientTestUtil.randomHttpMethod(getRandom()); + // we don't test status codes that are subject to retries as they interfere with hosts being stopped + final int statusCode = randomBoolean() ? randomOkStatusCode(getRandom()) : randomErrorNoRetryStatusCode(getRandom()); + restClient.performRequestAsync(new Request(method, "/" + statusCode), new ResponseListener() { + @Override + public void onSuccess(Response response) { + responses.add(new TestResponse(method, statusCode, response)); + latch.countDown(); + } + + @Override + public void onFailure(Exception exception) { + responses.add(new TestResponse(method, statusCode, exception)); + latch.countDown(); + } + }); + } + assertTrue(latch.await(5, TimeUnit.SECONDS)); + + assertEquals(numRequests, responses.size()); + for (TestResponse testResponse : responses) { + Response response = testResponse.getResponse(); + assertEquals(testResponse.method, response.getRequestLine().getMethod()); + assertEquals(testResponse.statusCode, response.getStatusLine().getStatusCode()); + assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + testResponse.statusCode, response.getRequestLine().getUri()); + } + } + + @Ignore("https://github.com/elastic/elasticsearch/issues/45577") + public void testCancelAsyncRequests() throws Exception { + int numRequests = randomIntBetween(5, 20); + final List responses = new CopyOnWriteArrayList<>(); + final List exceptions = new CopyOnWriteArrayList<>(); + for (int i = 0; i < numRequests; i++) { + CountDownLatch latch = new CountDownLatch(1); + waitForCancelHandler.reset(); + Cancellable cancellable = restClient.performRequestAsync(new Request("GET", "/wait"), new ResponseListener() { + @Override + public void onSuccess(Response response) { + responses.add(response); + latch.countDown(); + } + + @Override + public void onFailure(Exception exception) { + exceptions.add(exception); + latch.countDown(); + } + }); + if (randomBoolean()) { + // we wait for the request to get to the server-side otherwise we almost always cancel + // the request artificially on the client-side before even sending it + waitForCancelHandler.awaitRequest(); + } + cancellable.cancel(); + waitForCancelHandler.cancelDone(); + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + assertEquals(0, responses.size()); + assertEquals(numRequests, exceptions.size()); + for (Exception exception : exceptions) { + assertThat(exception, instanceOf(CancellationException.class)); + } + } + + /** + * Test host selector against a real server and + * test what happens after calling + */ + public void testNodeSelector() throws Exception { + try (RestHttpClient restClient = buildRestClient(firstPositionNodeSelector())) { + Request request = new Request("GET", "/200"); + int rounds = between(1, 10); + for (int i = 0; i < rounds; i++) { + /* + * Run the request more than once to verify that the + * NodeSelector overrides the round robin behavior. + */ + if (stoppedFirstHost) { + try { + RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + fail("expected to fail to connect"); + } catch (ConnectException e) { + // HttpClient isn't consistent here. Sometimes the message is even null! + if (e.getMessage() != null) { + assertThat(e.getMessage(), containsString("Connection refused")); + } + } + } else { + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(httpHosts[0], response.getHost()); + } + } + } + } + + private static class TestResponse { + private final String method; + private final int statusCode; + private final Object response; + + TestResponse(String method, int statusCode, Object response) { + this.method = method; + this.statusCode = statusCode; + this.response = response; + } + + Response getResponse() { + if (response instanceof Response) { + return (Response) response; + } + if (response instanceof ResponseException) { + return ((ResponseException) response).getResponse(); + } + throw new AssertionError("unexpected response " + response.getClass()); + } + } + + private NodeSelector firstPositionNodeSelector() { + return nodes -> { + for (Iterator itr = nodes.iterator(); itr.hasNext();) { + if (httpHosts[0] != itr.next().getHost()) { + itr.remove(); + } + } + }; + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsTests.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsTests.java new file mode 100644 index 0000000000000..5c0a454d5b350 --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsTests.java @@ -0,0 +1,348 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you 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. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.httpclient; + +import com.carrotsearch.randomizedtesting.generators.RandomNumbers; + +import org.opensearch.httpclient.internal.Node; +import org.opensearch.httpclient.internal.NodeSelector; +import org.junit.After; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.opensearch.httpclient.RestClientTestUtil.randomErrorNoRetryStatusCode; +import static org.opensearch.httpclient.RestClientTestUtil.randomErrorRetryStatusCode; +import static org.opensearch.httpclient.RestClientTestUtil.randomHttpMethod; +import static org.opensearch.httpclient.RestClientTestUtil.randomOkStatusCode; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Tests for {@link RestHttpClient} behaviour against multiple hosts: fail-over, denylisting etc. + * Relies on a mock http client to intercept requests and return desired responses based on request path. + */ +public class RestHttpClientMultipleHostsTests extends RestHttpClientTestCase { + + private ExecutorService exec = Executors.newFixedThreadPool(1); + private List nodes; + private HostsTrackingFailureListener failureListener; + + public RestHttpClient createRestClient(NodeSelector nodeSelector) { + HttpClient httpClient = RestHttpClientSingleHostTests.mockHttpClient(exec); + int numNodes = RandomNumbers.randomIntBetween(getRandom(), 2, 5); + nodes = new ArrayList<>(numNodes); + for (int i = 0; i < numNodes; i++) { + nodes.add(new Node(new HttpHost("http", "localhost", 9200 + i))); + } + nodes = Collections.unmodifiableList(nodes); + failureListener = new HostsTrackingFailureListener(); + return new RestHttpClient(httpClient, Map.of(), nodes, null, failureListener, nodeSelector, false, false); + } + + /** + * Shutdown the executor so we don't leak threads into other test runs. + */ + @After + public void shutdownExec() { + exec.shutdown(); + } + + public void testRoundRobinOkStatusCodes() throws Exception { + RestHttpClient restClient = createRestClient(NodeSelector.ANY); + int numIters = RandomNumbers.randomIntBetween(getRandom(), 1, 5); + for (int i = 0; i < numIters; i++) { + Set hostsSet = hostsSet(); + for (int j = 0; j < nodes.size(); j++) { + int statusCode = randomOkStatusCode(getRandom()); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync( + restClient, + new Request(randomHttpMethod(getRandom()), "/" + statusCode) + ); + assertEquals(statusCode, response.getStatusLine().getStatusCode()); + assertTrue("host not found: " + response.getHost(), hostsSet.remove(response.getHost())); + } + assertEquals("every host should have been used but some weren't: " + hostsSet, 0, hostsSet.size()); + } + failureListener.assertNotCalled(); + } + + public void testRoundRobinNoRetryErrors() throws Exception { + RestHttpClient restClient = createRestClient(NodeSelector.ANY); + int numIters = RandomNumbers.randomIntBetween(getRandom(), 1, 5); + for (int i = 0; i < numIters; i++) { + Set hostsSet = hostsSet(); + for (int j = 0; j < nodes.size(); j++) { + String method = randomHttpMethod(getRandom()); + int statusCode = randomErrorNoRetryStatusCode(getRandom()); + try { + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync( + restClient, + new Request(method, "/" + statusCode) + ); + if (method.equals("HEAD") && statusCode == 404) { + // no exception gets thrown although we got a 404 + assertEquals(404, response.getStatusLine().getStatusCode()); + assertEquals(statusCode, response.getStatusLine().getStatusCode()); + assertTrue("host not found: " + response.getHost(), hostsSet.remove(response.getHost())); + } else { + fail("request should have failed"); + } + } catch (ResponseException e) { + if (method.equals("HEAD") && statusCode == 404) { + throw e; + } + Response response = e.getResponse(); + assertEquals(statusCode, response.getStatusLine().getStatusCode()); + assertTrue("host not found: " + response.getHost(), hostsSet.remove(response.getHost())); + assertEquals(0, e.getSuppressed().length); + } + } + assertEquals("every host should have been used but some weren't: " + hostsSet, 0, hostsSet.size()); + } + failureListener.assertNotCalled(); + } + + public void testRoundRobinRetryErrors() throws Exception { + RestHttpClient restClient = createRestClient(NodeSelector.ANY); + String retryEndpoint = randomErrorRetryEndpoint(); + try { + RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, new Request(randomHttpMethod(getRandom()), retryEndpoint)); + fail("request should have failed"); + } catch (ResponseException e) { + Set hostsSet = hostsSet(); + // first request causes all the hosts to be denylisted, the returned exception holds one suppressed exception each + failureListener.assertCalled(nodes); + do { + Response response = e.getResponse(); + assertEquals(Integer.parseInt(retryEndpoint.substring(1)), response.getStatusLine().getStatusCode()); + assertTrue( + "host [" + response.getHost() + "] not found, most likely used multiple times", + hostsSet.remove(response.getHost()) + ); + if (e.getSuppressed().length > 0) { + assertEquals(1, e.getSuppressed().length); + Throwable suppressed = e.getSuppressed()[0]; + assertThat(suppressed, instanceOf(ResponseException.class)); + e = (ResponseException) suppressed; + } else { + e = null; + } + } while (e != null); + assertEquals("every host should have been used but some weren't: " + hostsSet, 0, hostsSet.size()); + } catch (IOException e) { + Set hostsSet = hostsSet(); + // first request causes all the hosts to be denylisted, the returned exception holds one suppressed exception each + failureListener.assertCalled(nodes); + do { + HttpHost httpHost = HttpHost.create(e.getMessage()); + assertTrue("host [" + httpHost + "] not found, most likely used multiple times", hostsSet.remove(httpHost)); + if (e.getSuppressed().length > 0) { + assertEquals(1, e.getSuppressed().length); + Throwable suppressed = e.getSuppressed()[0]; + assertThat(suppressed, instanceOf(IOException.class)); + e = (IOException) suppressed; + } else { + e = null; + } + } while (e != null); + assertEquals("every host should have been used but some weren't: " + hostsSet, 0, hostsSet.size()); + } + + int numIters = RandomNumbers.randomIntBetween(getRandom(), 2, 5); + for (int i = 1; i <= numIters; i++) { + // check that one different host is resurrected at each new attempt + Set hostsSet = hostsSet(); + for (int j = 0; j < nodes.size(); j++) { + retryEndpoint = randomErrorRetryEndpoint(); + try { + RestHttpClientSingleHostTests.performRequestSyncOrAsync( + restClient, + new Request(randomHttpMethod(getRandom()), retryEndpoint) + ); + fail("request should have failed"); + } catch (ResponseException e) { + Response response = e.getResponse(); + assertThat(response.getStatusLine().getStatusCode(), equalTo(Integer.parseInt(retryEndpoint.substring(1)))); + assertTrue( + "host [" + response.getHost() + "] not found, most likely used multiple times", + hostsSet.remove(response.getHost()) + ); + // after the first request, all hosts are denylisted, a single one gets resurrected each time + failureListener.assertCalled(response.getHost()); + assertEquals(0, e.getSuppressed().length); + } catch (IOException e) { + HttpHost httpHost = HttpHost.create(e.getMessage()); + assertTrue("host [" + httpHost + "] not found, most likely used multiple times", hostsSet.remove(httpHost)); + // after the first request, all hosts are denylisted, a single one gets resurrected each time + failureListener.assertCalled(httpHost); + assertEquals(0, e.getSuppressed().length); + } + } + assertEquals("every host should have been used but some weren't: " + hostsSet, 0, hostsSet.size()); + if (getRandom().nextBoolean()) { + // mark one host back alive through a successful request and check that all requests after that are sent to it + HttpHost selectedHost = null; + int iters = RandomNumbers.randomIntBetween(getRandom(), 2, 10); + for (int y = 0; y < iters; y++) { + int statusCode = randomErrorNoRetryStatusCode(getRandom()); + Response response; + try { + response = RestHttpClientSingleHostTests.performRequestSyncOrAsync( + restClient, + new Request(randomHttpMethod(getRandom()), "/" + statusCode) + ); + } catch (ResponseException e) { + response = e.getResponse(); + } + assertThat(response.getStatusLine().getStatusCode(), equalTo(statusCode)); + if (selectedHost == null) { + selectedHost = response.getHost(); + } else { + assertThat(response.getHost(), equalTo(selectedHost)); + } + } + failureListener.assertNotCalled(); + // let the selected host catch up on number of failures, it gets selected a consecutive number of times as it's the one + // selected to be retried earlier (due to lower number of failures) till all the hosts have the same number of failures + for (int y = 0; y < i + 1; y++) { + retryEndpoint = randomErrorRetryEndpoint(); + try { + RestHttpClientSingleHostTests.performRequestSyncOrAsync( + restClient, + new Request(randomHttpMethod(getRandom()), retryEndpoint) + ); + fail("request should have failed"); + } catch (ResponseException e) { + Response response = e.getResponse(); + assertThat(response.getStatusLine().getStatusCode(), equalTo(Integer.parseInt(retryEndpoint.substring(1)))); + assertThat(response.getHost(), equalTo(selectedHost)); + failureListener.assertCalled(selectedHost); + } catch (IOException e) { + HttpHost httpHost = HttpHost.create(e.getMessage()); + assertThat(httpHost, equalTo(selectedHost)); + failureListener.assertCalled(selectedHost); + } + } + } + } + } + + public void testNodeSelector() throws Exception { + NodeSelector firstPositionOnly = restClientNodes -> { + boolean found = false; + for (Iterator itr = restClientNodes.iterator(); itr.hasNext();) { + if (nodes.get(0) == itr.next()) { + found = true; + } else { + itr.remove(); + } + } + assertTrue(found); + }; + RestHttpClient restClient = createRestClient(firstPositionOnly); + int rounds = between(1, 10); + for (int i = 0; i < rounds; i++) { + /* + * Run the request more than once to verify that the + * NodeSelector overrides the round robin behavior. + */ + Request request = new Request("GET", "/200"); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(nodes.get(0).getHost(), response.getHost()); + } + } + + public void testSetNodes() throws Exception { + RestHttpClient restClient = createRestClient(NodeSelector.SKIP_DEDICATED_CLUSTER_MANAGERS); + List newNodes = new ArrayList<>(nodes.size()); + for (int i = 0; i < nodes.size(); i++) { + Node.Roles roles = i == 0 + ? new Node.Roles(new TreeSet<>(Arrays.asList("data", "ingest"))) + : new Node.Roles(new TreeSet<>(Arrays.asList("master"))); + newNodes.add(new Node(nodes.get(i).getHost(), null, null, null, roles, null)); + } + restClient.setNodes(newNodes); + int rounds = between(1, 10); + for (int i = 0; i < rounds; i++) { + /* + * Run the request more than once to verify that the + * NodeSelector overrides the round robin behavior. + */ + Request request = new Request("GET", "/200"); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(newNodes.get(0).getHost(), response.getHost()); + } + } + + private static String randomErrorRetryEndpoint() { + switch (RandomNumbers.randomIntBetween(getRandom(), 0, 3)) { + case 0: + return "/" + randomErrorRetryStatusCode(getRandom()); + case 1: + return "/coe"; + case 2: + return "/soe"; + case 3: + return "/ioe"; + } + throw new UnsupportedOperationException(); + } + + /** + * Build a mutable {@link Set} containing all the {@link Node#getHost() hosts} + * in use by the test. + */ + private Set hostsSet() { + Set hosts = new HashSet<>(); + for (Node node : nodes) { + hosts.add(node.getHost()); + } + return hosts; + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostIntegTests.java new file mode 100644 index 0000000000000..7bcf1c8252d90 --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostIntegTests.java @@ -0,0 +1,493 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.junit.After; +import org.junit.Before; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.Authenticator; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.http.HttpClient; +import java.net.http.HttpRequest.BodyPublishers; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.CoreMatchers.startsWith; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * Integration test to check interaction between {@link RestHttpClient} and {@link HttpClient}. + * Works against a real http server, one single host. + */ +public class RestHttpClientSingleHostIntegTests extends RestHttpClientTestCase { + + private HttpServer httpServer; + private RestHttpClient restClient; + private String pathPrefix; + private Map> defaultHeaders; + private WaitForCancelHandler waitForCancelHandler; + private ExecutorService httpClientExecutor; + private ExecutorService httpServerExecutor; + + @Before + public void startHttpServer() throws Exception { + pathPrefix = randomBoolean() ? "/testPathPrefix/" + randomAsciiLettersOfLengthBetween(1, 5) : ""; + httpServer = createHttpServer(); + httpClientExecutor = Executors.newFixedThreadPool(5); + httpServerExecutor = Executors.newFixedThreadPool(10); + defaultHeaders = RestClientTestUtil.randomHeaders(getRandom(), "Header-default"); + restClient = createRestClient(false, true); + } + + private HttpServer createHttpServer() throws Exception { + HttpServer httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + httpServer.setExecutor(httpServerExecutor); + httpServer.start(); + // returns a different status code depending on the path + for (int statusCode : RestClientTestUtil.getAllStatusCodes()) { + httpServer.createContext(pathPrefix + "/" + statusCode, new ResponseHandler(statusCode)); + } + waitForCancelHandler = new WaitForCancelHandler(); + httpServer.createContext(pathPrefix + "/wait", waitForCancelHandler); + return httpServer; + } + + private static class WaitForCancelHandler implements HttpHandler { + + private final CountDownLatch cancelHandlerLatch = new CountDownLatch(1); + + void cancelDone() { + cancelHandlerLatch.countDown(); + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + cancelHandlerLatch.await(); + } catch (InterruptedException ignore) {} finally { + exchange.sendResponseHeaders(200, 0); + exchange.close(); + } + } + } + + private static class ResponseHandler implements HttpHandler { + private final int statusCode; + + ResponseHandler(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public void handle(HttpExchange httpExchange) throws IOException { + // copy request body to response body so we can verify it was sent + StringBuilder body = new StringBuilder(); + try (InputStreamReader reader = new InputStreamReader(httpExchange.getRequestBody(), StandardCharsets.UTF_8)) { + char[] buffer = new char[256]; + int read; + while ((read = reader.read(buffer)) != -1) { + body.append(buffer, 0, read); + } + } + // copy request headers to response headers so we can verify they were sent + Headers requestHeaders = httpExchange.getRequestHeaders(); + Headers responseHeaders = httpExchange.getResponseHeaders(); + for (Map.Entry> header : requestHeaders.entrySet()) { + responseHeaders.put(header.getKey(), header.getValue()); + } + httpExchange.getRequestBody().close(); + httpExchange.sendResponseHeaders(statusCode, body.length() == 0 ? -1 : body.length()); + if (body.length() > 0) { + try (OutputStream out = httpExchange.getResponseBody()) { + out.write(body.toString().getBytes(StandardCharsets.UTF_8)); + } + } + httpExchange.close(); + } + } + + private RestHttpClient createRestClient(final boolean useAuth, final boolean usePreemptiveAuth) { + final HttpHost httpHost = new HttpHost("http", httpServer.getAddress().getHostString(), httpServer.getAddress().getPort()); + final RestHttpClientBuilder restClientBuilder = RestHttpClient.builder(httpHost).setDefaultHeaders(defaultHeaders); + if (pathPrefix.length() > 0) { + restClientBuilder.setPathPrefix(pathPrefix); + } + + if (useAuth) { + if (usePreemptiveAuth == false) { + restClientBuilder.setHttpClientConfigCallback(new RestHttpClientBuilder.HttpClientConfigCallback() { + @Override + public HttpClient.Builder customizeHttpClient(final HttpClient.Builder httpClientBuilder) { + return httpClientBuilder.executor(httpClientExecutor).authenticator(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication("user", "pass".toCharArray()); + } + }); + } + }); + } else { + final String auth = Base64.getEncoder().encodeToString(("user" + ":" + "pass").getBytes(StandardCharsets.UTF_8)); + final Map> headers = new HashMap<>(defaultHeaders); + headers.put("Authorization", List.of("Basic " + auth)); + restClientBuilder.setDefaultHeaders(headers); + + restClientBuilder.setHttpClientConfigCallback(new RestHttpClientBuilder.HttpClientConfigCallback() { + @Override + public HttpClient.Builder customizeHttpClient(final HttpClient.Builder httpClientBuilder) { + return httpClientBuilder.executor(httpClientExecutor); + } + }); + } + } else { + restClientBuilder.setHttpClientConfigCallback(new RestHttpClientBuilder.HttpClientConfigCallback() { + @Override + public HttpClient.Builder customizeHttpClient(final HttpClient.Builder httpClientBuilder) { + return httpClientBuilder.executor(httpClientExecutor).connectTimeout(Duration.ofSeconds(15)); + } + }); + } + + return restClientBuilder.build(); + } + + @After + public void stopHttpServers() throws IOException, InterruptedException { + restClient.close(); + restClient = null; + httpServer.stop(0); + httpServer = null; + + httpServerExecutor.shutdown(); + if (httpServerExecutor.awaitTermination(30, TimeUnit.SECONDS) == false) { + httpServerExecutor.shutdownNow(); + } + + httpClientExecutor.shutdown(); + if (httpClientExecutor.awaitTermination(30, TimeUnit.SECONDS) == false) { + httpClientExecutor.shutdownNow(); + } + } + + /** + * Tests sending a bunch of async requests works well (e.g. no TimeoutException from the leased pool) + * See https://github.com/elastic/elasticsearch/issues/24069 + */ + public void testManyAsyncRequests() throws Exception { + int iters = randomIntBetween(500, 1000); + final CountDownLatch latch = new CountDownLatch(iters); + final List exceptions = new CopyOnWriteArrayList<>(); + for (int i = 0; i < iters; i++) { + Request request = new Request("PUT", "/200"); + request.setEntity(BodyPublishers.ofString("{}")); + // Add random jitter so HttpServer will not refuse the connections + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(randomLongBetween(1, 5))); + restClient.performRequestAsync(request, new ResponseListener() { + @Override + public void onSuccess(Response response) { + latch.countDown(); + } + + @Override + public void onFailure(Exception exception) { + exceptions.add(exception); + latch.countDown(); + } + }); + } + + assertTrue("timeout waiting for requests to be sent", latch.await(10, TimeUnit.SECONDS)); + if (exceptions.isEmpty() == false) { + AssertionError error = new AssertionError( + "expected no failures but got some. see suppressed for first 10 of [" + exceptions.size() + "] failures" + ); + for (Exception exception : exceptions.subList(0, Math.min(10, exceptions.size()))) { + error.addSuppressed(exception); + } + throw error; + } + } + + public void testCancelAsyncRequest() throws Exception { + Request request = new Request(RestClientTestUtil.randomHttpMethod(getRandom()), "/wait"); + CountDownLatch requestLatch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + Cancellable cancellable = restClient.performRequestAsync(request, new ResponseListener() { + @Override + public void onSuccess(Response response) { + throw new AssertionError("onResponse called unexpectedly"); + } + + @Override + public void onFailure(Exception exception) { + error.set(exception); + requestLatch.countDown(); + } + }); + cancellable.cancel(); + waitForCancelHandler.cancelDone(); + assertTrue(requestLatch.await(5, TimeUnit.SECONDS)); + assertThat(error.get(), instanceOf(CancellationException.class)); + } + + /** + * End to end test for headers. We test it explicitly against a real http client as there are different ways + * to set/add headers to the {@link HttpClient}. + * Exercises the test http server ability to send back whatever headers it received. + */ + public void testHeaders() throws Exception { + for (String method : RestClientTestUtil.getHttpMethods()) { + final Set standardHeaders = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + standardHeaders.addAll( + Arrays.asList("Connection", "Host", "User-agent", "Date", "Upgrade", "HTTP2-Settings", "Content-Length") + ); + + final Map> requestHeaders = RestClientTestUtil.randomHeaders(getRandom(), "Header"); + final int statusCode = RestClientTestUtil.randomStatusCode(getRandom()); + Request request = new Request(method, "/" + statusCode); + RequestOptions.Builder options = request.getOptions().toBuilder(); + options.addHeaders(requestHeaders); + request.setOptions(options); + Response esResponse; + try { + esResponse = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + } catch (ResponseException e) { + esResponse = e.getResponse(); + } + + assertEquals(method, esResponse.getRequestLine().getMethod()); + assertEquals(statusCode, esResponse.getStatusLine().getStatusCode()); + assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().getUri()); + + assertHeaders(defaultHeaders, requestHeaders, esResponse.getHeaders(), standardHeaders); + final Set removedHeaders = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (final Map.Entry> responseHeader : esResponse.getHeaders().map().entrySet()) { + String name = responseHeader.getKey().toLowerCase(Locale.ROOT); + // Some headers could be returned multiple times in response, like Connection fe. + if (name.startsWith("header") == false && removedHeaders.contains(name) == false) { + assertTrue("unknown header was returned " + name, standardHeaders.remove(name)); + removedHeaders.add(name); + } + } + assertTrue("some expected standard headers weren't returned: " + standardHeaders, standardHeaders.isEmpty()); + } + } + + /** + * End to end test for delete with body. We test it explicitly as it is not supported + * out of the box by {@link HttpClient}. + * Exercises the test http server ability to send back whatever body it received. + */ + public void testDeleteWithBody() throws Exception { + bodyTest("DELETE"); + } + + /** + * End to end test for get with body. We test it explicitly as it is not supported + * out of the box by {@link HttpClient}. + * Exercises the test http server ability to send back whatever body it received. + */ + public void testGetWithBody() throws Exception { + bodyTest("GET"); + } + + public void testEncodeParams() throws Exception { + { + Request request = new Request("PUT", "/200"); + request.addParameter("routing", "this/is/the/routing"); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?routing=this%2Fis%2Fthe%2Frouting", response.getRequestLine().getUri()); + } + { + Request request = new Request("PUT", "/200"); + request.addParameter("routing", "this|is|the|routing"); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?routing=this%7Cis%7Cthe%7Crouting", response.getRequestLine().getUri()); + } + { + Request request = new Request("PUT", "/200"); + request.addParameter("routing", "routing#1"); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?routing=routing%231", response.getRequestLine().getUri()); + } + { + Request request = new Request("PUT", "/200"); + request.addParameter("routing", "中文"); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?routing=%E4%B8%AD%E6%96%87", response.getRequestLine().getUri()); + } + { + Request request = new Request("PUT", "/200"); + request.addParameter("routing", "foo bar"); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?routing=foo+bar", response.getRequestLine().getUri()); + } + { + Request request = new Request("PUT", "/200"); + request.addParameter("routing", "foo+bar"); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?routing=foo%2Bbar", response.getRequestLine().getUri()); + } + { + Request request = new Request("PUT", "/200"); + request.addParameter("routing", "foo/bar"); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?routing=foo%2Fbar", response.getRequestLine().getUri()); + } + { + Request request = new Request("PUT", "/200"); + request.addParameter("routing", "foo^bar"); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?routing=foo%5Ebar", response.getRequestLine().getUri()); + } + } + + /** + * Verify that credentials are sent on the first request with preemptive auth enabled (default when provided with credentials). + */ + public void testPreemptiveAuthEnabled() throws Exception { + final String[] methods = { "POST", "PUT", "GET", "DELETE" }; + + try (RestHttpClient restClient = createRestClient(true, true)) { + for (final String method : methods) { + final Response response = bodyTest(restClient, method); + assertThat(response.getHeader("Authorization"), startsWith("Basic")); + } + } + } + + /** + * Verify that credentials are not sent on the first request with preemptive auth disabled. + */ + public void testPreemptiveAuthDisabled() throws Exception { + final String[] methods = { "POST", "PUT", "GET", "DELETE" }; + + try (RestHttpClient restClient = createRestClient(true, false)) { + for (final String method : methods) { + int statusCode = RestClientTestUtil.randomStatusCode(getRandom()); + if (statusCode == 401) { + final IOException ex = assertThrows(IOException.class, () -> bodyTest(restClient, method, statusCode, Map.of())); + assertThat(ex.getMessage(), equalTo("WWW-Authenticate header missing for response code 401")); + } else { + final Response response = bodyTest(restClient, method, statusCode, Map.of()); + assertThat(response.getHeader("Authorization"), nullValue()); + } + } + } + } + + /** + * Verify that credentials continue to be sent even if a 401 (Unauthorized) response is received + */ + public void testAuthCredentialsAreNotClearedOnAuthChallenge() throws Exception { + final String[] methods = { "POST", "PUT", "GET", "DELETE" }; + + try (RestHttpClient restClient = createRestClient(true, true)) { + for (final String method : methods) { + Map> realmHeader = Map.of("WWW-Authenticate", List.of("Basic realm=\"test\"")); + final Response response401 = bodyTest(restClient, method, 401, realmHeader); + assertThat(response401.getHeader("Authorization"), startsWith("Basic")); + + final Response response200 = bodyTest(restClient, method, 200, Map.of()); + assertThat(response200.getHeader("Authorization"), startsWith("Basic")); + } + } + } + + public void testUrlWithoutLeadingSlash() throws Exception { + if (pathPrefix.length() == 0) { + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, new Request("GET", "200")); + // a trailing slash gets automatically added even if a pathPrefix is not configured (HttpClient uses full URI) + assertEquals(200, response.getStatusLine().getStatusCode()); + } else { + { + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, new Request("GET", "200")); + // a trailing slash gets automatically added if a pathPrefix is configured + assertEquals(200, response.getStatusLine().getStatusCode()); + } + { + // pathPrefix is not required to start with '/', will be added automatically + try ( + RestHttpClient restClient = RestHttpClient.builder( + new HttpHost("http", httpServer.getAddress().getHostString(), httpServer.getAddress().getPort()) + ).setPathPrefix(pathPrefix.substring(1)).build() + ) { + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, new Request("GET", "200")); + // a trailing slash gets automatically added if a pathPrefix is configured + assertEquals(200, response.getStatusLine().getStatusCode()); + } + } + } + } + + private Response bodyTest(final String method) throws Exception { + return bodyTest(restClient, method); + } + + private Response bodyTest(final RestHttpClient restClient, final String method) throws Exception { + int statusCode = RestClientTestUtil.randomStatusCode(getRandom()); + return bodyTest(restClient, method, statusCode, Map.of()); + } + + private Response bodyTest(RestHttpClient restClient, String method, int statusCode, Map> headers) + throws Exception { + String requestBody = "{ \"field\": \"value\" }"; + Request request = new Request(method, "/" + statusCode); + request.setJsonEntity(requestBody); + RequestOptions.Builder options = request.getOptions().toBuilder(); + for (Map.Entry> header : headers.entrySet()) { + header.getValue().forEach(v -> options.addHeader(header.getKey(), v)); + } + request.setOptions(options); + Response esResponse; + try { + esResponse = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + } catch (ResponseException e) { + esResponse = e.getResponse(); + } + assertEquals(method, esResponse.getRequestLine().getMethod()); + assertEquals(statusCode, esResponse.getStatusLine().getStatusCode()); + assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().getUri()); + assertEquals(requestBody, BodyUtils.getBodyAsString(esResponse)); + + return esResponse; + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostStreamingIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostStreamingIntegTests.java new file mode 100644 index 0000000000000..61b5761f6a97e --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostStreamingIntegTests.java @@ -0,0 +1,176 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.junit.After; +import org.junit.Before; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.junit.Assert.assertEquals; + +/** + * Integration test to check interaction between {@link RestHttpClient} and {@link HttpClient}. + * Works against a real http server, one single host, use streaming. + */ +public class RestHttpClientSingleHostStreamingIntegTests extends RestHttpClientTestCase { + + private HttpServer httpServer; + private RestHttpClient restClient; + private String pathPrefix; + private Map> defaultHeaders; + private ExecutorService httpClientExecutor; + + @Before + public void startHttpServer() throws Exception { + pathPrefix = randomBoolean() ? "/testPathPrefix/" + randomAsciiLettersOfLengthBetween(1, 5) : ""; + httpServer = createHttpServer(); + httpClientExecutor = Executors.newWorkStealingPool(); + defaultHeaders = RestClientTestUtil.randomHeaders(getRandom(), "Header-default"); + restClient = createRestClient(); + } + + private HttpServer createHttpServer() throws Exception { + HttpServer httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + httpServer.start(); + // returns a different status code depending on the path + for (int statusCode : RestClientTestUtil.getAllStatusCodes()) { + httpServer.createContext(pathPrefix + "/" + statusCode, new ResponseHandler(statusCode)); + } + return httpServer; + } + + private static class ResponseHandler implements HttpHandler { + private final int statusCode; + + ResponseHandler(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public void handle(HttpExchange httpExchange) throws IOException { + Headers requestHeaders = httpExchange.getRequestHeaders(); + Headers responseHeaders = httpExchange.getResponseHeaders(); + for (Map.Entry> header : requestHeaders.entrySet()) { + responseHeaders.put(header.getKey(), header.getValue()); + } + try { + httpExchange.sendResponseHeaders(statusCode, 0); + httpExchange.getRequestBody().transferTo(httpExchange.getResponseBody()); + httpExchange.getResponseBody().flush(); + } finally { + httpExchange.getRequestBody().close(); + httpExchange.getResponseBody().close(); + httpExchange.close(); + } + } + } + + private RestHttpClient createRestClient() { + final HttpHost httpHost = new HttpHost("http", httpServer.getAddress().getHostString(), httpServer.getAddress().getPort()); + final RestHttpClientBuilder restClientBuilder = RestHttpClient.builder(httpHost) + .setDefaultHeaders(defaultHeaders) + .setCompressionEnabled(randomBoolean()); + if (pathPrefix.length() > 0) { + restClientBuilder.setPathPrefix(pathPrefix); + } + + restClientBuilder.setHttpClientConfigCallback(new RestHttpClientBuilder.HttpClientConfigCallback() { + @Override + public HttpClient.Builder customizeHttpClient(final HttpClient.Builder httpClientBuilder) { + return httpClientBuilder.executor(httpClientExecutor).connectTimeout(Duration.ofSeconds(15)); + } + }); + + return restClientBuilder.build(); + } + + @After + public void stopHttpServers() throws IOException, InterruptedException { + restClient.close(); + restClient = null; + httpServer.stop(0); + httpServer = null; + + httpClientExecutor.shutdown(); + if (httpClientExecutor.awaitTermination(30, TimeUnit.SECONDS) == false) { + httpClientExecutor.shutdownNow(); + } + } + + /** + * End to end test for delete with body. We test it explicitly as it is not supported + * out of the box by {@link HttpClient}. + * Exercises the test http server ability to send back whatever body it received. + */ + public void testDeleteWithBody() throws Exception { + bodyTest("DELETE"); + } + + /** + * End to end test for get with body. We test it explicitly as it is not supported + * out of the box by {@link HttpClient}. + * Exercises the test http server ability to send back whatever body it received. + */ + public void testGetWithBody() throws Exception { + bodyTest("GET"); + } + + private StreamingResponse bodyTest(final String method) throws Exception { + int statusCode = RestClientTestUtil.randomStatusCode(getRandom()); + return bodyTest(restClient, method, statusCode, Map.of()); + } + + private StreamingResponse bodyTest(RestHttpClient restClient, String method, int statusCode, Map> headers) + throws Exception { + String requestBody = "{ \"field\": \"value\" }"; + StreamingRequest request = new StreamingRequest(method, "/" + statusCode, Mono.just(StandardCharsets.UTF_8.encode(requestBody))); + RequestOptions.Builder options = request.getOptions().toBuilder(); + for (Map.Entry> header : headers.entrySet()) { + header.getValue().forEach(v -> options.addHeader(header.getKey(), v)); + } + request.setOptions(options); + StreamingResponse esResponse = restClient.streamRequest(request); + + assertEquals(method, esResponse.getRequestLine().getMethod()); + assertEquals(statusCode, esResponse.getStatusLine().getStatusCode()); + assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().getUri()); + + if (statusCode >= 200 && statusCode < 400) { + StepVerifier.create(Flux.from(esResponse.getBody()).map(StandardCharsets.UTF_8::decode).map(CharBuffer::toString)) + .expectNextMatches(s -> s.equals(requestBody)) + .expectComplete() + .verify(Duration.ofSeconds(5)); + } else { + StepVerifier.create(Flux.from(esResponse.getBody())).expectError(ResponseException.class).verify(Duration.ofSeconds(5)); + } + + return esResponse; + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostTests.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostTests.java new file mode 100644 index 0000000000000..6a583477ea027 --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostTests.java @@ -0,0 +1,732 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.opensearch.httpclient.internal.Node; +import org.opensearch.httpclient.internal.NodeSelector; +import org.junit.After; +import org.junit.Before; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpClient.Version; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublisher; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandler; +import java.net.http.HttpResponse.PushPromiseHandler; +import java.net.http.HttpTimeoutException; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.LongAdder; +import java.util.stream.Collectors; + +import static java.util.Collections.singletonList; +import static org.opensearch.httpclient.RestClientTestUtil.getAllErrorStatusCodes; +import static org.opensearch.httpclient.RestClientTestUtil.getHttpMethods; +import static org.opensearch.httpclient.RestClientTestUtil.getOkStatusCodes; +import static org.opensearch.httpclient.RestClientTestUtil.randomStatusCode; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +/** + * Tests for basic functionality of {@link RestHttpClient} against one single host: tests http requests being sent, headers, + * body, different status codes and corresponding responses/exceptions. + * Relies on a mock http client to intercept requests and return desired responses based on request path. + */ +public class RestHttpClientSingleHostTests extends RestHttpClientTestCase { + private static final Log logger = LogFactory.getLog(RestHttpClientSingleHostTests.class); + + private ExecutorService exec = Executors.newFixedThreadPool(1); + private RestHttpClient restClient; + private Map> defaultHeaders; + private Node node; + private HttpClient httpClient; + private HostsTrackingFailureListener failureListener; + private boolean strictDeprecationMode; + private LongAdder requests; + private AtomicReference requestProducerCapture; + + @Before + public void createRestClient() { + requests = new LongAdder(); + requestProducerCapture = new AtomicReference<>(); + httpClient = mockHttpClient(exec, (requestProducer, bodyHandler) -> { + requests.increment(); + requestProducerCapture.set(requestProducer); + }); + defaultHeaders = RestClientTestUtil.randomHeaders(getRandom(), "Header-default"); + node = new Node(new HttpHost("http", "localhost", 9200)); + failureListener = new HostsTrackingFailureListener(); + strictDeprecationMode = randomBoolean(); + restClient = new RestHttpClient( + this.httpClient, + defaultHeaders, + singletonList(node), + null, + failureListener, + NodeSelector.ANY, + strictDeprecationMode, + false + ); + } + + interface HttpClientListener { + void onExecute(HttpRequest requestProducer, BodyHandler bodyHandler); + } + + @SuppressWarnings("unchecked") + static HttpClient mockHttpClient(final ExecutorService exec, final HttpClientListener... listeners) { + HttpClient httpClient = new HttpClient() { + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.empty(); + } + + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional executor() { + return Optional.of(exec); + } + + @Override + public Redirect followRedirects() { + return null; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() { + return null; + } + + @Override + public SSLParameters sslParameters() { + return null; + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public boolean awaitTermination(Duration duration) throws InterruptedException { + return exec.awaitTermination(duration.toMillis(), TimeUnit.MILLISECONDS); + } + + @Override + public HttpResponse send(java.net.http.HttpRequest request, BodyHandler responseBodyHandler) throws IOException, + InterruptedException { + return sendAsync(request, responseBodyHandler).join(); + } + + @Override + public CompletableFuture> sendAsync( + java.net.http.HttpRequest request, + BodyHandler responseBodyHandler, + PushPromiseHandler pushPromiseHandler + ) { + return sendAsync(request, responseBodyHandler); + } + + @Override + public CompletableFuture> sendAsync(HttpRequest request, BodyHandler responseBodyHandler) { + Arrays.stream(listeners).forEach(l -> l.onExecute(request, responseBodyHandler)); + + final CompletableFuture> callback = new CompletableFuture<>(); + exec.submit(() -> { + try { + HttpResponse httpResponse = responseOrException(request); + callback.complete((HttpResponse) httpResponse); + return (T) httpResponse; + } catch (Exception e) { + callback.completeExceptionally(e); + return null; + } + }); + + return callback; + } + }; + + return httpClient; + } + + private static HttpResponse responseOrException(HttpRequest request) throws Exception { + final HttpHost httpHost = new HttpHost(request.uri().getScheme(), request.uri().getHost(), request.uri().getPort()); + // return the desired status code or exception depending on the path + switch (request.uri().getPath()) { + case "/soe": + throw new SocketTimeoutException(httpHost.toString()); + case "/coe": + throw new HttpTimeoutException(httpHost.toString()); + case "/ioe": + throw new IOException(httpHost.toString()); + case "/closed": + throw new ClosedChannelException(); + case "/handshake": + throw new SSLHandshakeException(""); + case "/uri": + throw new URISyntaxException("", ""); + case "/runtime": + throw new RuntimeException(); + default: + int statusCode = Integer.parseInt(request.uri().getPath().substring(1)); + + // return the same body that was sent + final Object entity = BodyUtils.getBody(request).map(List::of).cache().block(); + final HttpResponse httpResponse = new HttpResponse<>() { + @Override + public int statusCode() { + return statusCode; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return request.headers(); + } + + @Override + public Object body() { + return entity; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public Version version() { + return request.version().orElse(Version.HTTP_1_1); + } + }; + + return httpResponse; + } + } + + /** + * Shutdown the executor so we don't leak threads into other test runs. + */ + @After + public void shutdownExec() { + exec.shutdown(); + } + + /** + * Verifies the content of the {@link HttpRequest} that's internally created and passed through to the http client + */ + @SuppressWarnings("unchecked") + public void testInternalHttpRequest() throws Exception { + int times = 0; + for (String httpMethod : getHttpMethods()) { + HttpRequest expectedRequest = performRandomRequest(httpMethod); + assertThat(requests.intValue(), equalTo(++times)); + + HttpRequest actualRequest = requestProducerCapture.get(); + assertEquals(expectedRequest.uri(), actualRequest.uri()); + assertEquals(expectedRequest.method(), actualRequest.method()); + assertEquals(expectedRequest.headers(), actualRequest.headers()); + + Object expectedEntity = BodyUtils.getBody(expectedRequest).block(); + if (expectedEntity != null) { + Object actualEntity = BodyUtils.getBody(actualRequest).block(); + assertEquals(expectedEntity, actualEntity); + } + } + } + + /** + * End to end test for ok status codes + */ + public void testOkStatusCodes() throws Exception { + for (String method : getHttpMethods()) { + for (int okStatusCode : getOkStatusCodes()) { + Response response = performRequestSyncOrAsync(restClient, new Request(method, "/" + okStatusCode)); + assertThat(response.getStatusLine().getStatusCode(), equalTo(okStatusCode)); + } + } + failureListener.assertNotCalled(); + } + + /** + * End to end test for error status codes: they should cause an exception to be thrown, apart from 404 with HEAD requests + */ + public void testErrorStatusCodes() throws Exception { + for (String method : getHttpMethods()) { + Set expectedIgnores = new HashSet<>(); + String ignoreParam = ""; + if ("HEAD".equalsIgnoreCase(method)) { + expectedIgnores.add(404); + } + if (randomBoolean()) { + int numIgnores = randomIntBetween(1, 3); + for (int i = 0; i < numIgnores; i++) { + Integer code = randomFrom(getAllErrorStatusCodes()); + expectedIgnores.add(code); + ignoreParam += code; + if (i < numIgnores - 1) { + ignoreParam += ","; + } + } + } + // error status codes should cause an exception to be thrown + for (int errorStatusCode : getAllErrorStatusCodes()) { + try { + Request request = new Request(method, "/" + errorStatusCode); + if (false == ignoreParam.isEmpty()) { + request.addParameter("ignore", ignoreParam); + } + Response response = restClient.performRequest(request); + if (expectedIgnores.contains(errorStatusCode)) { + // no exception gets thrown although we got an error status code, as it was configured to be ignored + assertEquals(errorStatusCode, response.getStatusLine().getStatusCode()); + } else { + fail("request should have failed"); + } + } catch (ResponseException e) { + if (expectedIgnores.contains(errorStatusCode)) { + throw e; + } + assertEquals(errorStatusCode, e.getResponse().getStatusLine().getStatusCode()); + assertExceptionStackContainsCallingMethod(e); + } + if (errorStatusCode <= 500 || expectedIgnores.contains(errorStatusCode)) { + failureListener.assertNotCalled(); + } else { + failureListener.assertCalled(singletonList(node)); + } + } + } + } + + public void testPerformRequestIOExceptions() throws Exception { + for (String method : getHttpMethods()) { + // IOExceptions should be let bubble up + try { + restClient.performRequest(new Request(method, "/ioe")); + fail("request should have failed"); + } catch (IOException e) { + // And we do all that so the thrown exception has our method in the stacktrace + assertExceptionStackContainsCallingMethod(e); + } + failureListener.assertCalled(singletonList(node)); + try { + restClient.performRequest(new Request(method, "/coe")); + fail("request should have failed"); + } catch (HttpTimeoutException e) { + // And we do all that so the thrown exception has our method in the stacktrace + assertExceptionStackContainsCallingMethod(e); + } + failureListener.assertCalled(singletonList(node)); + try { + restClient.performRequest(new Request(method, "/soe")); + fail("request should have failed"); + } catch (SocketTimeoutException e) { + // And we do all that so the thrown exception has our method in the stacktrace + assertExceptionStackContainsCallingMethod(e); + } + failureListener.assertCalled(singletonList(node)); + try { + restClient.performRequest(new Request(method, "/closed")); + fail("request should have failed"); + } catch (ClosedChannelException e) { + // And we do all that so the thrown exception has our method in the stacktrace + assertExceptionStackContainsCallingMethod(e); + } + failureListener.assertCalled(singletonList(node)); + try { + restClient.performRequest(new Request(method, "/handshake")); + fail("request should have failed"); + } catch (SSLHandshakeException e) { + // And we do all that so the thrown exception has our method in the stacktrace + assertExceptionStackContainsCallingMethod(e); + } + failureListener.assertCalled(singletonList(node)); + } + } + + public void testPerformRequestRuntimeExceptions() throws Exception { + for (String method : getHttpMethods()) { + try { + restClient.performRequest(new Request(method, "/runtime")); + fail("request should have failed"); + } catch (RuntimeException e) { + // And we do all that so the thrown exception has our method in the stacktrace + assertExceptionStackContainsCallingMethod(e); + } + failureListener.assertCalled(singletonList(node)); + } + } + + public void testPerformRequestExceptions() throws Exception { + for (String method : getHttpMethods()) { + try { + restClient.performRequest(new Request(method, "/uri")); + fail("request should have failed"); + } catch (RuntimeException e) { + assertThat(e.getCause(), instanceOf(URISyntaxException.class)); + // And we do all that so the thrown exception has our method in the stacktrace + assertExceptionStackContainsCallingMethod(e); + } + failureListener.assertCalled(singletonList(node)); + } + } + + /** + * End to end test for request and response body. Exercises the mock http client ability to send back + * whatever body it has received. + */ + public void testBody() throws Exception { + String body = "{ \"field\": \"value\" }"; + BodyPublisher entity = BodyPublishers.ofString(body); + for (String method : Arrays.asList("DELETE", "GET", "PATCH", "POST", "PUT", "TRACE")) { + for (int okStatusCode : getOkStatusCodes()) { + Request request = new Request(method, "/" + okStatusCode); + request.setEntity(entity); + Response response = restClient.performRequest(request); + assertThat(response.getStatusLine().getStatusCode(), equalTo(okStatusCode)); + assertThat(BodyUtils.getBodyAsString(response), equalTo(body)); + } + for (int errorStatusCode : getAllErrorStatusCodes()) { + Request request = new Request(method, "/" + errorStatusCode); + request.setEntity(entity); + try { + restClient.performRequest(request); + fail("request should have failed"); + } catch (ResponseException e) { + Response response = e.getResponse(); + assertThat(response.getStatusLine().getStatusCode(), equalTo(errorStatusCode)); + assertThat(BodyUtils.getBodyAsString(response), equalTo(body)); + assertExceptionStackContainsCallingMethod(e); + } + } + } + } + + /** + * End to end test for request and response headers. Exercises the mock http client ability to send back + * whatever headers it has received. + */ + public void testHeaders() throws Exception { + for (String method : getHttpMethods()) { + final Map> requestHeaders = RestClientTestUtil.randomHeaders(getRandom(), "Header"); + final int statusCode = randomStatusCode(getRandom()); + Request request = new Request(method, "/" + statusCode); + RequestOptions.Builder options = request.getOptions().toBuilder(); + for (Map.Entry> requestHeader : requestHeaders.entrySet()) { + requestHeader.getValue().forEach(v -> options.addHeader(requestHeader.getKey(), v)); + } + request.setOptions(options); + Response esResponse; + try { + esResponse = performRequestSyncOrAsync(restClient, request); + } catch (ResponseException e) { + esResponse = e.getResponse(); + } + assertThat(esResponse.getStatusLine().getStatusCode(), equalTo(statusCode)); + assertHeaders(defaultHeaders, requestHeaders, esResponse.getHeaders(), Collections.emptySet()); + assertFalse(esResponse.hasWarnings()); + } + } + + public void testDeprecationWarnings() throws Exception { + String chars = randomAsciiAlphanumOfLength(5); + assertDeprecationWarnings(singletonList("poorly formatted " + chars), singletonList("poorly formatted " + chars)); + assertDeprecationWarnings(singletonList(formatWarningWithoutDate(chars)), singletonList(chars)); + assertDeprecationWarnings(singletonList(formatWarning(chars)), singletonList(chars)); + assertDeprecationWarnings( + Arrays.asList(formatWarning(chars), "another one", "and another"), + Arrays.asList(chars, "another one", "and another") + ); + assertDeprecationWarnings(Arrays.asList("ignorable one", "and another"), Arrays.asList("ignorable one", "and another")); + assertDeprecationWarnings(singletonList("exact"), singletonList("exact")); + assertDeprecationWarnings(Collections.emptyList(), Collections.emptyList()); + + String proxyWarning = "112 - \"network down\" \"Sat, 25 Aug 2012 23:34:45 GMT\""; + assertDeprecationWarnings(singletonList(proxyWarning), singletonList(proxyWarning)); + } + + private enum DeprecationWarningOption { + PERMISSIVE { + protected WarningsHandler warningsHandler() { + return WarningsHandler.PERMISSIVE; + } + }, + STRICT { + protected WarningsHandler warningsHandler() { + return WarningsHandler.STRICT; + } + }, + FILTERED { + protected WarningsHandler warningsHandler() { + return new WarningsHandler() { + @Override + public boolean warningsShouldFailRequest(List warnings) { + for (String warning : warnings) { + if (false == warning.startsWith("ignorable")) { + return true; + } + } + return false; + } + }; + } + }, + EXACT { + protected WarningsHandler warningsHandler() { + return new WarningsHandler() { + @Override + public boolean warningsShouldFailRequest(List warnings) { + return false == warnings.equals(Arrays.asList("exact")); + } + }; + } + }; + + protected abstract WarningsHandler warningsHandler(); + } + + private void assertDeprecationWarnings(List warningHeaderTexts, List warningBodyTexts) throws Exception { + String method = randomFrom(getHttpMethods()); + Request request = new Request(method, "/200"); + RequestOptions.Builder options = request.getOptions().toBuilder(); + for (String warningHeaderText : warningHeaderTexts) { + options.addHeader("Warning", warningHeaderText); + } + + final boolean expectFailure; + if (randomBoolean()) { + logger.info("checking strictWarningsMode=[" + strictDeprecationMode + "] and warnings=" + warningBodyTexts); + expectFailure = strictDeprecationMode && false == warningBodyTexts.isEmpty(); + } else { + DeprecationWarningOption warningOption = randomFrom(DeprecationWarningOption.values()); + logger.info("checking warningOption=" + warningOption + " and warnings=" + warningBodyTexts); + options.setWarningsHandler(warningOption.warningsHandler()); + expectFailure = warningOption.warningsHandler().warningsShouldFailRequest(warningBodyTexts); + } + request.setOptions(options); + + Response response; + if (expectFailure) { + try { + performRequestSyncOrAsync(restClient, request); + fail("expected WarningFailureException from warnings"); + return; + } catch (WarningFailureException e) { + if (false == warningBodyTexts.isEmpty()) { + assertThat(e.getMessage(), containsString("\nWarnings: " + warningBodyTexts)); + } + response = e.getResponse(); + } + } else { + response = performRequestSyncOrAsync(restClient, request); + } + assertEquals(false == warningBodyTexts.isEmpty(), response.hasWarnings()); + assertEquals(warningBodyTexts, response.getWarnings()); + } + + /** + * Emulates OpenSearch's HeaderWarningLogger.formatWarning in simple + * cases. We don't have that available because we're testing against 1.7. + */ + private static String formatWarningWithoutDate(String warningBody) { + final String hash = new String(new byte[40], StandardCharsets.UTF_8).replace('\0', 'e'); + return "299 OpenSearch-1.2.2-SNAPSHOT-" + hash + " \"" + warningBody + "\""; + } + + private static String formatWarning(String warningBody) { + return formatWarningWithoutDate(warningBody) + " \"Mon, 01 Jan 2001 00:00:00 GMT\""; + } + + private HttpRequest performRandomRequest(String method) throws Exception { + String uriAsString = "/" + randomStatusCode(getRandom()); + Request request = new Request(method, uriAsString); + + Map params = new HashMap<>(); + if (randomBoolean()) { + int numParams = randomIntBetween(1, 3); + for (int i = 0; i < numParams; i++) { + String name = "param-" + i; + String value = randomAsciiAlphanumOfLengthBetween(3, 10); + request.addParameter(name, value); + params.put(name, value); + } + } + if (randomBoolean()) { + // randomly add some ignore parameter, which doesn't get sent as part of the request + String ignore = Integer.toString(randomFrom(RestClientTestUtil.getAllErrorStatusCodes())); + if (randomBoolean()) { + ignore += "," + randomFrom(RestClientTestUtil.getAllErrorStatusCodes()); + } + request.addParameter("ignore", ignore); + } + + final String additionalQuery = params.entrySet() + .stream() + .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + + URI uri = new URI("http", null, "localhost", 9200, uriAsString, additionalQuery, null); + BodyPublisher bodyPublisher = BodyPublishers.noBody(); + if (getRandom().nextBoolean()) { + bodyPublisher = BodyPublishers.ofString(randomAsciiAlphanumOfLengthBetween(10, 100)); + request.setEntity(bodyPublisher); + } + + HttpRequest.Builder expectedRequest = HttpRequest.newBuilder(uri).method(method, bodyPublisher); + final Set uniqueNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + if (randomBoolean()) { + Map> headers = RestClientTestUtil.randomHeaders(getRandom(), "Header"); + RequestOptions.Builder options = request.getOptions().toBuilder(); + options.addHeaders(headers); + for (Map.Entry> header : headers.entrySet()) { + header.getValue().forEach(v -> expectedRequest.header(header.getKey(), v)); + uniqueNames.add(header.getKey()); + } + request.setOptions(options); + } + for (Map.Entry> defaultHeader : defaultHeaders.entrySet()) { + // request level headers override default headers + if (uniqueNames.contains(defaultHeader.getKey()) == false) { + defaultHeader.getValue().forEach(v -> expectedRequest.header(defaultHeader.getKey(), v)); + } + } + try { + performRequestSyncOrAsync(restClient, request); + } catch (Exception e) { + // all good + } + return expectedRequest.build(); + } + + static Response performRequestSyncOrAsync(RestHttpClient restClient, Request request) throws Exception { + // randomize between sync and async methods + if (randomBoolean()) { + return restClient.performRequest(request); + } else { + final AtomicReference exceptionRef = new AtomicReference<>(); + final AtomicReference responseRef = new AtomicReference<>(); + final CountDownLatch latch = new CountDownLatch(1); + restClient.performRequestAsync(request, new ResponseListener() { + @Override + public void onSuccess(Response response) { + responseRef.set(response); + latch.countDown(); + + } + + @Override + public void onFailure(Exception exception) { + exceptionRef.set(exception); + latch.countDown(); + } + }); + latch.await(); + if (exceptionRef.get() != null) { + throw exceptionRef.get(); + } + return responseRef.get(); + } + } + + /** + * Asserts that the provided {@linkplain Exception} contains the method + * that called this somewhere on its stack. This is + * normally the case for synchronous calls but {@link RestHttpClient} performs + * synchronous calls by performing asynchronous calls and blocking the + * current thread until the call returns so it has to take special care + * to make sure that the caller shows up in the exception. We use this + * assertion to make sure that we don't break that "special care". + */ + private static void assertExceptionStackContainsCallingMethod(Throwable t) { + // 0 is getStackTrace + // 1 is this method + // 2 is the caller, what we want + StackTraceElement myMethod = Thread.currentThread().getStackTrace()[2]; + for (StackTraceElement se : t.getStackTrace()) { + if (se.getClassName().equals(myMethod.getClassName()) && se.getMethodName().equals(myMethod.getMethodName())) { + return; + } + } + StringWriter stack = new StringWriter(); + t.printStackTrace(new PrintWriter(stack)); + fail("didn't find the calling method (looks like " + myMethod + ") in:\n" + stack); + } +} diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientTestCase.java b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientTestCase.java new file mode 100644 index 0000000000000..f343b74158c46 --- /dev/null +++ b/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientTestCase.java @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.httpclient; + +import com.carrotsearch.randomizedtesting.JUnit3MethodProvider; +import com.carrotsearch.randomizedtesting.MixWithSuiteName; +import com.carrotsearch.randomizedtesting.RandomizedTest; +import com.carrotsearch.randomizedtesting.annotations.SeedDecorators; +import com.carrotsearch.randomizedtesting.annotations.TestMethodProviders; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakAction; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakGroup; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakLingering; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakZombies; +import com.carrotsearch.randomizedtesting.annotations.TimeoutSuite; + +import java.net.http.HttpHeaders; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@TestMethodProviders({ JUnit3MethodProvider.class }) +@SeedDecorators({ MixWithSuiteName.class }) // See LUCENE-3995 for rationale. +@ThreadLeakScope(ThreadLeakScope.Scope.SUITE) +@ThreadLeakGroup(ThreadLeakGroup.Group.MAIN) +@ThreadLeakAction({ ThreadLeakAction.Action.WARN, ThreadLeakAction.Action.INTERRUPT }) +@ThreadLeakZombies(ThreadLeakZombies.Consequence.IGNORE_REMAINING_TESTS) +@ThreadLeakLingering(linger = 5000) // 5 sec lingering +@ThreadLeakFilters(filters = { HttpClientThreadLeakFilter.class, BouncyCastleThreadFilter.class }) +@TimeoutSuite(millis = 2 * 60 * 60 * 1000) +public abstract class RestHttpClientTestCase extends RandomizedTest { + /** + * Assert that the actual headers are the expected ones given the original default and request headers. Some headers can be ignored, + * for instance in case the http client is adding its own automatically. + * + * @param defaultHeaders the default headers set to the REST client instance + * @param requestHeaders the request headers sent with a particular request + * @param actualHeaders the actual headers as a result of the provided default and request headers + * @param ignoreHeaders header keys to be ignored as they are not part of default nor request headers, yet they + * will be part of the actual ones + */ + protected static void assertHeaders( + final Map> defaultHeaders, + final Map> requestHeaders, + final HttpHeaders actualHeaders, + final Set ignoreHeaders + ) { + final Map> expectedHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + final Set requestHeaderKeys = new HashSet<>(); + for (final Map.Entry> header : requestHeaders.entrySet()) { + final String name = header.getKey(); + addValueToListEntry(expectedHeaders, name, header.getValue()); + requestHeaderKeys.add(name); + } + for (final Map.Entry> defaultHeader : defaultHeaders.entrySet()) { + final String name = defaultHeader.getKey(); + if (requestHeaderKeys.contains(name) == false) { + addValueToListEntry(expectedHeaders, name, defaultHeader.getValue()); + } + } + Set actualIgnoredHeaders = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (final Map.Entry> responseHeader : actualHeaders.map().entrySet()) { + final String name = responseHeader.getKey(); + if (ignoreHeaders.contains(name)) { + expectedHeaders.remove(name); + actualIgnoredHeaders.add(name); + continue; + } + final String value = responseHeader.getValue().getFirst(); + final List values = expectedHeaders.get(name); + assertNotNull("found response header [" + name + "] that wasn't originally sent: " + value, values); + assertTrue("found incorrect response header [" + name + "]: " + value, values.remove(value)); + if (values.isEmpty()) { + expectedHeaders.remove(name); + } + } + assertEquals("some headers meant to be ignored were not part of the actual headers", ignoreHeaders, actualIgnoredHeaders); + assertTrue("some headers that were sent weren't returned " + expectedHeaders, expectedHeaders.isEmpty()); + } + + private static void addValueToListEntry(final Map> map, final String name, final List values) { + map.computeIfAbsent(name, k -> new ArrayList<>()).addAll(values); + } +} diff --git a/settings.gradle b/settings.gradle index 56051b83cc3e6..2d4b3d4db6755 100644 --- a/settings.gradle +++ b/settings.gradle @@ -49,6 +49,7 @@ List projects = [ 'rest-api-spec', 'docs', 'client:rest', + 'client:rest-http-client', 'client:rest-high-level', 'client:sniffer', 'client:test', From 2748cb6572cae444d143861694a07e011af54a80 Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Thu, 4 Jun 2026 11:48:17 -0400 Subject: [PATCH 2/9] Address code review comments Signed-off-by: Andriy Redko --- client/rest-http-client/build.gradle | 11 ----------- .../org/opensearch/httpclient/RestHttpClient.java | 1 + 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/client/rest-http-client/build.gradle b/client/rest-http-client/build.gradle index 197e339837945..2e73de4669771 100644 --- a/client/rest-http-client/build.gradle +++ b/client/rest-http-client/build.gradle @@ -66,10 +66,6 @@ tasks.named('forbiddenApisTest').configure { bundledSignatures += 'jdk-internal' } -// JarHell is part of es server, which we don't want to pull in -// TODO: Not anymore. Now in :libs:opensearch-core -jarHell.enabled = false - testingConventions { naming.clear() naming { @@ -133,13 +129,6 @@ thirdPartyAudit { ) } -tasks.withType(JavaCompile) { - // Suppressing '[options] target value 8 is obsolete and will be removed in a future release' - configure(options) { - options.compilerArgs << '-Xlint:-options' - } -} - tasks.named("missingJavadoc").configure { it.enabled = false } diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java index 92c1c271e59cf..d2cb46e0ff5a2 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java +++ b/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java @@ -699,6 +699,7 @@ static URI buildUri(String pathPrefix, String path, Map params) final String additionalQuery = params.entrySet() .stream() + .filter(e -> e.getValue() != null) .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) .collect(Collectors.joining("&")); final URI uri = URI.create(fullPath); From 9522d25e0dc5663132347ac01290338d0cbeca62 Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Thu, 4 Jun 2026 19:04:10 -0400 Subject: [PATCH 3/9] Address code review comments (move under org.opensearch.internal.httpclient package Signed-off-by: Andriy Redko --- client/rest-http-client/build.gradle | 2 +- .../httpclient/AsyncResponseProducer.java | 2 +- .../{ => internal}/httpclient/BodyUtils.java | 2 +- .../{ => internal}/httpclient/Cancellable.java | 2 +- .../{ => internal}/httpclient/DeadHostState.java | 2 +- .../{ => internal}/httpclient/HttpHost.java | 2 +- .../internal => internal/httpclient}/Node.java | 4 +--- .../httpclient}/NodeSelector.java | 5 +---- .../{ => internal}/httpclient/Request.java | 2 +- .../{ => internal}/httpclient/RequestLine.java | 2 +- .../{ => internal}/httpclient/RequestLogger.java | 3 +-- .../{ => internal}/httpclient/RequestOptions.java | 2 +- .../{ => internal}/httpclient/Response.java | 2 +- .../{ => internal}/httpclient/ResponseException.java | 2 +- .../{ => internal}/httpclient/ResponseListener.java | 2 +- .../httpclient/ResponseWarningsExtractor.java | 2 +- .../{ => internal}/httpclient/RestHttpClient.java | 4 +--- .../httpclient/RestHttpClientBuilder.java | 5 +---- .../{ => internal}/httpclient/StatusLine.java | 2 +- .../{ => internal}/httpclient/StreamingRequest.java | 2 +- .../{ => internal}/httpclient/StreamingResponse.java | 2 +- .../httpclient/WarningFailureException.java | 4 ++-- .../{ => internal}/httpclient/WarningsHandler.java | 2 +- .../httpclient/BouncyCastleThreadFilter.java | 2 +- .../httpclient/HostsTrackingFailureListener.java | 4 +--- .../httpclient/HttpClientThreadLeakFilter.java | 2 +- .../httpclient/RestClientTestUtil.java | 2 +- .../httpclient/RestHttpClientCompressionTests.java | 2 +- .../RestHttpClientGzipCompressionTests.java | 2 +- .../RestHttpClientMultipleHostsIntegTests.java | 10 ++++------ .../httpclient/RestHttpClientMultipleHostsTests.java | 12 +++++------- .../RestHttpClientSingleHostIntegTests.java | 2 +- .../RestHttpClientSingleHostStreamingIntegTests.java | 2 +- .../httpclient/RestHttpClientSingleHostTests.java | 12 +++++------- .../httpclient/RestHttpClientTestCase.java | 2 +- 35 files changed, 47 insertions(+), 66 deletions(-) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/AsyncResponseProducer.java (95%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/BodyUtils.java (99%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/Cancellable.java (99%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/DeadHostState.java (99%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/HttpHost.java (97%) rename client/rest-http-client/src/main/java/org/opensearch/{httpclient/internal => internal/httpclient}/Node.java (98%) rename client/rest-http-client/src/main/java/org/opensearch/{httpclient/internal => internal/httpclient}/NodeSelector.java (96%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/Request.java (99%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/RequestLine.java (98%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/RequestLogger.java (98%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/RequestOptions.java (99%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/Response.java (98%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/ResponseException.java (97%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/ResponseListener.java (97%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/ResponseWarningsExtractor.java (98%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/RestHttpClient.java (99%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/RestHttpClientBuilder.java (98%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/StatusLine.java (97%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/StreamingRequest.java (98%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/StreamingResponse.java (98%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/WarningFailureException.java (95%) rename client/rest-http-client/src/main/java/org/opensearch/{ => internal}/httpclient/WarningsHandler.java (98%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/BouncyCastleThreadFilter.java (94%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/HostsTrackingFailureListener.java (96%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/HttpClientThreadLeakFilter.java (93%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/RestClientTestUtil.java (98%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/RestHttpClientCompressionTests.java (99%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/RestHttpClientGzipCompressionTests.java (99%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/RestHttpClientMultipleHostsIntegTests.java (97%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/RestHttpClientMultipleHostsTests.java (97%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/RestHttpClientSingleHostIntegTests.java (99%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/RestHttpClientSingleHostStreamingIntegTests.java (99%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/RestHttpClientSingleHostTests.java (98%) rename client/rest-http-client/src/test/java/org/opensearch/{ => internal}/httpclient/RestHttpClientTestCase.java (99%) diff --git a/client/rest-http-client/build.gradle b/client/rest-http-client/build.gradle index 2e73de4669771..69a6813f0329e 100644 --- a/client/rest-http-client/build.gradle +++ b/client/rest-http-client/build.gradle @@ -70,7 +70,7 @@ testingConventions { naming.clear() naming { Tests { - baseClass 'org.opensearch.httpclient.RestHttpClientTestCase' + baseClass 'org.opensearch.internal.httpclient.RestHttpClientTestCase' } } } diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/AsyncResponseProducer.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/AsyncResponseProducer.java similarity index 95% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/AsyncResponseProducer.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/AsyncResponseProducer.java index 47d3f2916c8ad..aecc1345100c3 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/AsyncResponseProducer.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/AsyncResponseProducer.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.nio.ByteBuffer; import java.util.List; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/BodyUtils.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/BodyUtils.java similarity index 99% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/BodyUtils.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/BodyUtils.java index 6cb34bf2ba0fe..e5c1d78cc7713 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/BodyUtils.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/BodyUtils.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.io.EOFException; import java.io.IOException; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/Cancellable.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Cancellable.java similarity index 99% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/Cancellable.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Cancellable.java index 86ba31d1c265c..6357ac163e410 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/Cancellable.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Cancellable.java @@ -29,7 +29,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.io.IOException; import java.util.concurrent.Callable; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/DeadHostState.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/DeadHostState.java similarity index 99% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/DeadHostState.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/DeadHostState.java index 66ebc0f87716b..de2e5161ea9c9 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/DeadHostState.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/DeadHostState.java @@ -30,7 +30,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/HttpHost.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/HttpHost.java similarity index 97% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/HttpHost.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/HttpHost.java index 2c8fd25f89f3c..cb7d7d6106a20 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/HttpHost.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/HttpHost.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.net.URI; import java.net.URISyntaxException; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/Node.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Node.java similarity index 98% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/Node.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Node.java index 1f7fe024713f2..c892b6fae2b64 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/Node.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Node.java @@ -30,9 +30,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient.internal; - -import org.opensearch.httpclient.HttpHost; +package org.opensearch.internal.httpclient; import java.util.List; import java.util.Map; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/NodeSelector.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/NodeSelector.java similarity index 96% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/NodeSelector.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/NodeSelector.java index 12f628d8f02bd..4a4856a920b48 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/internal/NodeSelector.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/NodeSelector.java @@ -30,10 +30,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient.internal; - -import org.opensearch.httpclient.RestHttpClient; -import org.opensearch.httpclient.RestHttpClientBuilder; +package org.opensearch.internal.httpclient; import java.util.Iterator; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/Request.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Request.java similarity index 99% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/Request.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Request.java index 9a0b8781f6ea1..5e538f25ef0da 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/Request.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Request.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.net.http.HttpRequest.BodyPublisher; import java.net.http.HttpRequest.BodyPublishers; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLine.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java similarity index 98% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLine.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java index 665ba9d02506f..bac3985e66fc4 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLine.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.io.Serializable; import java.net.URI; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLogger.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLogger.java similarity index 98% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLogger.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLogger.java index 3c91f6693efba..1120669b889ba 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestLogger.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLogger.java @@ -30,11 +30,10 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.opensearch.httpclient.internal.Node; import java.io.IOException; import java.net.http.HttpRequest; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestOptions.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestOptions.java similarity index 99% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestOptions.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestOptions.java index d78538988eb85..c814b257c6100 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RequestOptions.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestOptions.java @@ -30,7 +30,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.time.Duration; import java.util.ArrayList; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/Response.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Response.java similarity index 98% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/Response.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Response.java index bb890d21ea94d..268a4821d7766 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/Response.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Response.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.io.InputStream; import java.net.http.HttpHeaders; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseException.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java similarity index 97% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseException.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java index f5889341bdedc..a771d4c373629 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseException.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.io.IOException; import java.nio.ByteBuffer; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseListener.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseListener.java similarity index 97% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseListener.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseListener.java index efed8fcdfd4b5..6775322ff5e71 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseListener.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseListener.java @@ -30,7 +30,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; /** * Listener to be provided when calling async performRequest methods provided by {@link RestHttpClient}. diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseWarningsExtractor.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseWarningsExtractor.java similarity index 98% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseWarningsExtractor.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseWarningsExtractor.java index 72b98056a3a00..296538333fea3 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/ResponseWarningsExtractor.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseWarningsExtractor.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.net.http.HttpResponse; import java.util.ArrayList; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java similarity index 99% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java index d2cb46e0ff5a2..b8679802de582 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClient.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java @@ -6,12 +6,10 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.opensearch.httpclient.internal.Node; -import org.opensearch.httpclient.internal.NodeSelector; import javax.net.ssl.SSLHandshakeException; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClientBuilder.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClientBuilder.java similarity index 98% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClientBuilder.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClientBuilder.java index 149e4e7a8f227..367e2efc05da4 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/RestHttpClientBuilder.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClientBuilder.java @@ -11,10 +11,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient; - -import org.opensearch.httpclient.internal.Node; -import org.opensearch.httpclient.internal.NodeSelector; +package org.opensearch.internal.httpclient; import javax.net.ssl.SSLContext; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/StatusLine.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StatusLine.java similarity index 97% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/StatusLine.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StatusLine.java index d06416bf4da43..93d7841408f0e 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/StatusLine.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StatusLine.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.net.http.HttpClient.Version; import java.net.http.HttpResponse; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingRequest.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingRequest.java similarity index 98% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingRequest.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingRequest.java index 8e8f6d04a0abe..a65e72c30dbdd 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingRequest.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingRequest.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.nio.ByteBuffer; import java.util.HashMap; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingResponse.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java similarity index 98% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingResponse.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java index de20156d57587..ea60cdb3144b3 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/StreamingResponse.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.net.http.HttpHeaders; import java.net.http.HttpResponse; diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningFailureException.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/WarningFailureException.java similarity index 95% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningFailureException.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/WarningFailureException.java index 23e0f779cac0f..3a6443dbe0bf1 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningFailureException.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/WarningFailureException.java @@ -30,11 +30,11 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.io.IOException; -import static org.opensearch.httpclient.ResponseException.buildMessage; +import static org.opensearch.internal.httpclient.ResponseException.buildMessage; /** * This exception is used to indicate that one or more {@link Response#getWarnings()} exist diff --git a/client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningsHandler.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/WarningsHandler.java similarity index 98% rename from client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningsHandler.java rename to client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/WarningsHandler.java index 2d23ec7ec3720..1d59a185a6a3e 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/httpclient/WarningsHandler.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/WarningsHandler.java @@ -30,7 +30,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import java.util.List; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/BouncyCastleThreadFilter.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/BouncyCastleThreadFilter.java similarity index 94% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/BouncyCastleThreadFilter.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/BouncyCastleThreadFilter.java index 2c4a6ea8217b8..814cf4bfaff11 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/BouncyCastleThreadFilter.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/BouncyCastleThreadFilter.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import com.carrotsearch.randomizedtesting.ThreadFilter; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/HostsTrackingFailureListener.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/HostsTrackingFailureListener.java similarity index 96% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/HostsTrackingFailureListener.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/HostsTrackingFailureListener.java index 2611e87cc30a5..c18c6539f8bc4 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/HostsTrackingFailureListener.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/HostsTrackingFailureListener.java @@ -30,9 +30,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient; - -import org.opensearch.httpclient.internal.Node; +package org.opensearch.internal.httpclient; import java.util.HashSet; import java.util.List; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/HttpClientThreadLeakFilter.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/HttpClientThreadLeakFilter.java similarity index 93% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/HttpClientThreadLeakFilter.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/HttpClientThreadLeakFilter.java index 1b0a38f37b92d..9ca72d417775c 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/HttpClientThreadLeakFilter.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/HttpClientThreadLeakFilter.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import com.carrotsearch.randomizedtesting.ThreadFilter; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestClientTestUtil.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestClientTestUtil.java similarity index 98% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/RestClientTestUtil.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestClientTestUtil.java index 391c5842beaaa..ac1dbcb4b8a2e 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestClientTestUtil.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestClientTestUtil.java @@ -30,7 +30,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import com.carrotsearch.randomizedtesting.generators.RandomNumbers; import com.carrotsearch.randomizedtesting.generators.RandomPicks; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientCompressionTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java similarity index 99% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientCompressionTests.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java index 40a150c171f5c..b5b66fba41651 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientCompressionTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientGzipCompressionTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java similarity index 99% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientGzipCompressionTests.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java index 1f58a56101ada..5766b15e3d851 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientGzipCompressionTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java @@ -30,7 +30,7 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java similarity index 97% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsIntegTests.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java index d816db4d38517..c54fa991b8674 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java @@ -30,14 +30,12 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; -import org.opensearch.httpclient.internal.Node; -import org.opensearch.httpclient.internal.NodeSelector; import org.junit.AfterClass; import org.junit.Before; import org.junit.BeforeClass; @@ -56,9 +54,9 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import static org.opensearch.httpclient.RestClientTestUtil.getAllStatusCodes; -import static org.opensearch.httpclient.RestClientTestUtil.randomErrorNoRetryStatusCode; -import static org.opensearch.httpclient.RestClientTestUtil.randomOkStatusCode; +import static org.opensearch.internal.httpclient.RestClientTestUtil.getAllStatusCodes; +import static org.opensearch.internal.httpclient.RestClientTestUtil.randomErrorNoRetryStatusCode; +import static org.opensearch.internal.httpclient.RestClientTestUtil.randomOkStatusCode; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.instanceOf; import static org.junit.Assert.assertEquals; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java similarity index 97% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsTests.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java index 5c0a454d5b350..68638ac19696e 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientMultipleHostsTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java @@ -30,12 +30,10 @@ * GitHub history for details. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import com.carrotsearch.randomizedtesting.generators.RandomNumbers; -import org.opensearch.httpclient.internal.Node; -import org.opensearch.httpclient.internal.NodeSelector; import org.junit.After; import java.io.IOException; @@ -52,10 +50,10 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import static org.opensearch.httpclient.RestClientTestUtil.randomErrorNoRetryStatusCode; -import static org.opensearch.httpclient.RestClientTestUtil.randomErrorRetryStatusCode; -import static org.opensearch.httpclient.RestClientTestUtil.randomHttpMethod; -import static org.opensearch.httpclient.RestClientTestUtil.randomOkStatusCode; +import static org.opensearch.internal.httpclient.RestClientTestUtil.randomErrorNoRetryStatusCode; +import static org.opensearch.internal.httpclient.RestClientTestUtil.randomErrorRetryStatusCode; +import static org.opensearch.internal.httpclient.RestClientTestUtil.randomHttpMethod; +import static org.opensearch.internal.httpclient.RestClientTestUtil.randomOkStatusCode; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.instanceOf; import static org.junit.Assert.assertEquals; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java similarity index 99% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostIntegTests.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java index 7bcf1c8252d90..1e73b67a62583 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostStreamingIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java similarity index 99% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostStreamingIntegTests.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java index 61b5761f6a97e..e0c32ed22a064 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostStreamingIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java similarity index 98% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostTests.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java index 6a583477ea027..a6b34ef4b0176 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientSingleHostTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java @@ -6,12 +6,10 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.opensearch.httpclient.internal.Node; -import org.opensearch.httpclient.internal.NodeSelector; import org.junit.After; import org.junit.Before; @@ -63,10 +61,10 @@ import java.util.stream.Collectors; import static java.util.Collections.singletonList; -import static org.opensearch.httpclient.RestClientTestUtil.getAllErrorStatusCodes; -import static org.opensearch.httpclient.RestClientTestUtil.getHttpMethods; -import static org.opensearch.httpclient.RestClientTestUtil.getOkStatusCodes; -import static org.opensearch.httpclient.RestClientTestUtil.randomStatusCode; +import static org.opensearch.internal.httpclient.RestClientTestUtil.getAllErrorStatusCodes; +import static org.opensearch.internal.httpclient.RestClientTestUtil.getHttpMethods; +import static org.opensearch.internal.httpclient.RestClientTestUtil.getOkStatusCodes; +import static org.opensearch.internal.httpclient.RestClientTestUtil.randomStatusCode; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.instanceOf; diff --git a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientTestCase.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientTestCase.java similarity index 99% rename from client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientTestCase.java rename to client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientTestCase.java index f343b74158c46..e7ddb131b4205 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/httpclient/RestHttpClientTestCase.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientTestCase.java @@ -6,7 +6,7 @@ * compatible open source license. */ -package org.opensearch.httpclient; +package org.opensearch.internal.httpclient; import com.carrotsearch.randomizedtesting.JUnit3MethodProvider; import com.carrotsearch.randomizedtesting.MixWithSuiteName; From 80ebee9ec9d66de77fcb7974a8c70ea3098a0c07 Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Thu, 4 Jun 2026 21:48:14 -0400 Subject: [PATCH 4/9] Address code review comments (convert some classes to records) Signed-off-by: Andriy Redko --- .../internal/httpclient/Request.java | 261 +++++++----------- .../internal/httpclient/RequestLine.java | 1 - .../internal/httpclient/RestHttpClient.java | 22 +- .../internal/httpclient/StatusLine.java | 55 +--- .../RestHttpClientCompressionTests.java | 6 +- .../RestHttpClientGzipCompressionTests.java | 20 +- ...RestHttpClientMultipleHostsIntegTests.java | 12 +- .../RestHttpClientMultipleHostsTests.java | 35 +-- .../RestHttpClientSingleHostIntegTests.java | 70 +++-- ...tpClientSingleHostStreamingIntegTests.java | 2 +- .../RestHttpClientSingleHostTests.java | 72 +++-- 11 files changed, 233 insertions(+), 323 deletions(-) diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Request.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Request.java index 5e538f25ef0da..ed4cc1e8f19d4 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Request.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Request.java @@ -20,71 +20,22 @@ * HTTP Request to OpenSearch. * Note: This is an experimental API. */ -public final class Request { - private final String method; - private final String endpoint; - private final Map parameters = new HashMap<>(); +public record Request(String method, String endpoint, Map parameters, BodyPublisher entity, RequestOptions options) { - private BodyPublisher entity; - private RequestOptions options = RequestOptions.DEFAULT; - - /** - * Create the {@linkplain Request}. - * @param method the HTTP method - * @param endpoint the path of the request (without scheme, host, port, or prefix) - */ - public Request(String method, String endpoint) { - this.method = Objects.requireNonNull(method, "method cannot be null"); - this.endpoint = Objects.requireNonNull(endpoint, "endpoint cannot be null"); - } - - /** - * The HTTP method. - */ - public String getMethod() { - return method; - } - - /** - * The path of the request (without scheme, host, port, or prefix). - */ - public String getEndpoint() { - return endpoint; - } - - /** - * Add a query string parameter. - * @param name the name of the url parameter. Must not be null. - * @param value the value of the url url parameter. If {@code null} then - * the parameter is sent as {@code name} rather than {@code name=value} - * @throws IllegalArgumentException if a parameter with that name has - * already been set - */ - public void addParameter(String name, String value) { - Objects.requireNonNull(name, "url parameter name cannot be null"); - if (parameters.containsKey(name)) { - throw new IllegalArgumentException("url parameter [" + name + "] has already been set to [" + parameters.get(name) + "]"); - } else { - parameters.put(name, value); - } - } - - /** - * Add query parameters using the provided map of key value pairs. - * - * @param paramSource a map of key value pairs where the key is the url parameter. - * @throws IllegalArgumentException if a parameter with that name has already been set. - */ - public void addParameters(Map paramSource) { - paramSource.forEach(this::addParameter); + public Request { + method = Objects.requireNonNull(method, "method cannot be null"); + endpoint = Objects.requireNonNull(endpoint, "endpoint cannot be null"); + parameters = parameters == null ? new HashMap<>() : parameters; + options = options == null ? RequestOptions.DEFAULT : options; } /** * Query string parameters. The returned map is an unmodifiable view of the - * map in the request so calls to {@link #addParameter(String, String)} + * map in the request so calls to {@link Map#put(Object, Object)} * will change it. */ - public Map getParameters() { + @Override + public Map parameters() { if (options.getParameters().isEmpty()) { return unmodifiableMap(parameters); } else { @@ -94,114 +45,116 @@ public Map getParameters() { } } - /** - * Set the body of the request. If not set or set to {@code null} then no - * body is sent with the request. - * - * @param entity the {@link BodyPublisher} to be set as the body of the request. - */ - public void setEntity(BodyPublisher entity) { - this.entity = entity; + public static Request.Builder newRequest(String method, String endpoint) { + return new Request.Builder(method, endpoint); } - /** - * Set the body of the request to a string. If not set or set to - * {@code null} then no body is sent with the request. The - * {@code Content-Type} will be sent as {@code application/json}. - * If you need a different content type then use - * {@link #setEntity(BodyPublisher)}. - * - * @param entity JSON string to be set as the entity body of the request. - */ - public void setJsonEntity(String entity) { - setEntity(entity == null ? BodyPublishers.noBody() : BodyPublishers.ofString(entity)); - } - - /** - * The body of the request. If {@code null} then no body - * is sent with the request. - */ - public BodyPublisher getEntity() { - return entity; - } + public static final class Builder { + private final String method; + private final String endpoint; + private final Map parameters = new HashMap<>(); + private BodyPublisher entity; + private RequestOptions options = RequestOptions.DEFAULT; - /** - * Set the portion of an HTTP request to OpenSearch that can be - * manipulated without changing OpenSearch's behavior. - * - * @param options the options to be set. - * @throws NullPointerException if {@code options} is null. - */ - public void setOptions(RequestOptions options) { - Objects.requireNonNull(options, "options cannot be null"); - this.options = options; - } + private Builder(String method, String endpoint) { + this.method = Objects.requireNonNull(method, "method cannot be null"); + this.endpoint = Objects.requireNonNull(endpoint, "endpoint cannot be null"); + } - /** - * Set the portion of an HTTP request to OpenSearch that can be - * manipulated without changing OpenSearch's behavior. - * - * @param options the options to be set. - * @throws NullPointerException if {@code options} is null. - */ - public void setOptions(RequestOptions.Builder options) { - Objects.requireNonNull(options, "options cannot be null"); - this.options = options.build(); - } + /** + * Set the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * + * @param options the options to be set. + * @throws NullPointerException if {@code options} is null. + */ + public void setOptions(RequestOptions options) { + Objects.requireNonNull(options, "options cannot be null"); + this.options = options; + } - /** - * Get the portion of an HTTP request to OpenSearch that can be - * manipulated without changing OpenSearch's behavior. - */ - public RequestOptions getOptions() { - return options; - } + /** + * Add a query string parameter. + * @param name the name of the url parameter. Must not be null. + * @param value the value of the url url parameter. If {@code null} then + * the parameter is sent as {@code name} rather than {@code name=value} + * @throws IllegalArgumentException if a parameter with that name has + * already been set + */ + public Builder withParameter(String name, String value) { + Objects.requireNonNull(name, "url parameter name cannot be null"); + if (parameters.containsKey(name)) { + throw new IllegalArgumentException("url parameter [" + name + "] has already been set to [" + parameters.get(name) + "]"); + } else { + parameters.put(name, value); + } + return this; + } - /** - * Convert request to string representation - */ - @Override - public String toString() { - StringBuilder b = new StringBuilder(); - b.append("Request{"); - b.append("method='").append(method).append('\''); - b.append(", endpoint='").append(endpoint).append('\''); - if (false == parameters.isEmpty()) { - b.append(", params=").append(parameters); + /** + * Add query parameters using the provided map of key value pairs. + * + * @param paramSource a map of key value pairs where the key is the url parameter. + * @throws IllegalArgumentException if a parameter with that name has already been set. + */ + public Builder withParameters(Map paramSource) { + paramSource.forEach(this::withParameter); + return this; } - if (entity != null) { - b.append(", entity=").append(entity); + + /** + * Set the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * + * @param options the options to be set. + * @throws NullPointerException if {@code options} is null. + */ + public Builder withOptions(RequestOptions.Builder options) { + Objects.requireNonNull(options, "options cannot be null"); + this.options = options.build(); + return this; } - b.append(", options=").append(options); - return b.append('}').toString(); - } - /** - * Compare two requests for equality - * @param obj request instance to compare with - */ - @Override - public boolean equals(Object obj) { - if (obj == null || (obj.getClass() != getClass())) { - return false; + /** + * Set the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * + * @param options the options to be set. + * @throws NullPointerException if {@code options} is null. + */ + public Builder withOptions(RequestOptions options) { + Objects.requireNonNull(options, "options cannot be null"); + this.options = options; + return this; } - if (obj == this) { - return true; + + /** + * Set the body of the request. If not set or set to {@code null} then no + * body is sent with the request. + * + * @param entity the {@link BodyPublisher} to be set as the body of the request. + */ + public Builder withEntity(BodyPublisher entity) { + this.entity = entity; + return this; } - Request other = (Request) obj; - return method.equals(other.method) - && endpoint.equals(other.endpoint) - && parameters.equals(other.parameters) - && Objects.equals(entity, other.entity) - && options.equals(other.options); - } + /** + * Set the body of the request to a string. If not set or set to + * {@code null} then no body is sent with the request. The + * {@code Content-Type} will be sent as {@code application/json}. + * If you need a different content type then use + * {@link #withEntity(BodyPublisher)}. + * + * @param entity JSON string to be set as the entity body of the request. + */ + public Builder withEntity(String entity) { + withEntity(entity == null ? BodyPublishers.noBody() : BodyPublishers.ofString(entity)); + return this; + } - /** - * Calculate the hash code of the request - */ - @Override - public int hashCode() { - return Objects.hash(method, endpoint, parameters, entity, options); + public Request build() { + return new Request(method, endpoint, parameters, entity, options); + } } } diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java index bac3985e66fc4..68d555576979e 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java @@ -53,7 +53,6 @@ private static String buildUri(URI uri) { * @param version HTTP protocol */ public RequestLine(final String method, final URI uri, final Version version) { - super(); this.method = Objects.requireNonNull(method, "Method"); this.uri = Objects.requireNonNull(uri, "URI").getPath(); this.protoversion = version != null ? version : Version.HTTP_1_1; diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java index b8679802de582..be4fdf9b6d40c 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java @@ -353,7 +353,7 @@ private ResponseOrResponseException convertResponse(InternalRequest request, Nod int statusCode = httpResponse.statusCode(); Response response = Response.from(new RequestLine(httpRequest), node.getHost(), httpResponse); - if (isSuccessfulResponse(statusCode) || request.ignoreErrorCodes.contains(response.getStatusLine().getStatusCode())) { + if (isSuccessfulResponse(statusCode) || request.ignoreErrorCodes.contains(response.getStatusLine().statusCode())) { onResponse(node); if (request.warningsHandler.warningsShouldFailRequest(response.getWarnings())) { throw new WarningFailureException(response); @@ -384,7 +384,7 @@ private ResponseOrResponseException convertResponse( RequestLogger.logStreamingResponse(logger, request.httpRequest.apply(node), node.getHost(), httpResponse); int statusCode = httpResponse.statusCode(); - if (isSuccessfulResponse(statusCode) || request.ignoreErrorCodes.contains(response.getStatusLine().getStatusCode())) { + if (isSuccessfulResponse(statusCode) || request.ignoreErrorCodes.contains(response.getStatusLine().statusCode())) { onResponse(node); if (request.warningsHandler.warningsShouldFailRequest(response.getWarnings())) { throw new WarningFailureException(response); @@ -886,26 +886,26 @@ private class InternalRequest { private volatile Cancellable cancellable = Cancellable.fromFuture(new CompletableFuture<>()); InternalRequest(Request request) { - Map params = new HashMap<>(request.getParameters()); + Map params = new HashMap<>(request.parameters()); // ignore is a special parameter supported by the clients, shouldn't be sent to es String ignoreString = params.remove("ignore"); - this.ignoreErrorCodes = getIgnoreErrorCodes(ignoreString, request.getMethod()); + this.ignoreErrorCodes = getIgnoreErrorCodes(ignoreString, request.method()); this.httpRequest = node -> { - URI uri = buildUri(pathPrefix, request.getEndpoint(), params); + URI uri = buildUri(pathPrefix, request.endpoint(), params); final HttpRequest.Builder builder = createHttpRequest( node, - request.getMethod(), + request.method(), uri, - request.getEntity(), - request.getOptions().getTimeout(), + request.entity(), + request.options().getTimeout(), compressionEnabled ); - setHeaders(builder, request.getOptions().getHeaders()); + setHeaders(builder, request.options().getHeaders()); return builder.build(); }; - this.warningsHandler = request.getOptions().getWarningsHandler() == null + this.warningsHandler = request.options().getWarningsHandler() == null ? RestHttpClient.this.warningsHandler - : request.getOptions().getWarningsHandler(); + : request.options().getWarningsHandler(); } private void setCancellable(Future f) { diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StatusLine.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StatusLine.java index 93d7841408f0e..120b042c01357 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StatusLine.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StatusLine.java @@ -16,62 +16,23 @@ * Response status line (protocol, status code) * Note: This is an experimental API. */ -public final class StatusLine { - /** - * The protocol version. - */ - private final Version protoVersion; - - /** - * The status code. - */ - private final int statusCode; - - /** - * Creates a new status line from the response - * - * @param response HTTP response - */ - public StatusLine(final HttpResponse response) { - Objects.requireNonNull(response, "Response"); - this.protoVersion = response.version(); - this.statusCode = response.statusCode(); - } - +public record StatusLine(Version protoVersion, int statusCode) { /** * Creates a new status line with the given version and status. * * @param protoVersion the protocol version of the response * @param statusCode the status code of the response */ - public StatusLine(final Version protoVersion, final int statusCode) { - this.statusCode = statusCode; - this.protoVersion = protoVersion != null ? protoVersion : Version.HTTP_1_1; + public StatusLine { + protoVersion = protoVersion != null ? protoVersion : Version.HTTP_1_1; } /** - * Gets the response HTTP status code - * @return HTTP status code - */ - public int getStatusCode() { - return this.statusCode; - } - - /** - * Gets the response HTTP protocol - * @return HTTP protocol - */ - public Version getProtocolVersion() { - return this.protoVersion; - } - - /** - * Converts the status line to string + * Creates a new status line from the response + * + * @param response HTTP response */ - @Override - public String toString() { - final StringBuilder buf = new StringBuilder(); - buf.append(this.protoVersion).append(" ").append(this.statusCode).append(" "); - return buf.toString(); + public StatusLine(final HttpResponse response) { + this(Objects.requireNonNull(response, "Response").version(), Objects.requireNonNull(response, "Response").statusCode()); } } diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java index b5b66fba41651..150b1fa89bbf9 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java @@ -100,8 +100,7 @@ private RestHttpClient createClient(boolean enableCompression) { public void testCompressingClientWithContentLengthSync() throws Exception { try (RestHttpClient restClient = createClient(true)) { - Request request = new Request("POST", "/"); - request.setEntity(BodyPublishers.ofString("compressing client")); + Request request = Request.newRequest("POST", "/").withEntity(BodyPublishers.ofString("compressing client")).build(); Response response = restClient.performRequest(request); @@ -114,8 +113,7 @@ public void testCompressingClientWithContentLengthSync() throws Exception { public void testCompressingClientContentLengthAsync() throws Exception { try (RestHttpClient restClient = createClient(true)) { - Request request = new Request("POST", "/"); - request.setEntity(BodyPublishers.ofString("compressing client")); + Request request = Request.newRequest("POST", "/").withEntity(BodyPublishers.ofString("compressing client")).build(); FutureResponse futureResponse = new FutureResponse(); restClient.performRequestAsync(request, futureResponse); diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java index 5766b15e3d851..cb38103e88bce 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java @@ -118,9 +118,10 @@ private RestHttpClient createClient(boolean enableCompression) { public void testGzipHeaderSync() throws Exception { try (RestHttpClient restClient = createClient(false)) { // Send non-compressed request, expect compressed response - Request request = new Request("POST", "/"); - request.setEntity(BodyPublishers.ofString("plain request, gzip response")); - request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Accept-Encoding", "gzip").build()); + Request request = Request.newRequest("POST", "/") + .withEntity(BodyPublishers.ofString("plain request, gzip response")) + .withOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Accept-Encoding", "gzip")) + .build(); Response response = restClient.performRequest(request); @@ -132,9 +133,10 @@ public void testGzipHeaderSync() throws Exception { public void testGzipHeaderAsync() throws Exception { try (RestHttpClient restClient = createClient(false)) { // Send non-compressed request, expect compressed response - Request request = new Request("POST", "/"); - request.setEntity(BodyPublishers.ofString("plain request, gzip response")); - request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Accept-Encoding", "gzip").build()); + Request request = Request.newRequest("POST", "/") + .withEntity(BodyPublishers.ofString("plain request, gzip response")) + .withOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Accept-Encoding", "gzip")) + .build(); FutureResponse futureResponse = new FutureResponse(); restClient.performRequestAsync(request, futureResponse); @@ -147,8 +149,7 @@ public void testGzipHeaderAsync() throws Exception { public void testCompressingClientSync() throws Exception { try (RestHttpClient restClient = createClient(true)) { - Request request = new Request("POST", "/"); - request.setEntity(BodyPublishers.ofString("compressing client")); + Request request = Request.newRequest("POST", "/").withEntity(BodyPublishers.ofString("compressing client")).build(); Response response = restClient.performRequest(request); @@ -164,8 +165,7 @@ public void testCompressingClientAsync() throws Exception { .setCompressionEnabled(true) .build() ) { - Request request = new Request("POST", "/"); - request.setEntity(BodyPublishers.ofString("compressing client")); + Request request = Request.newRequest("POST", "/").withEntity(BodyPublishers.ofString("compressing client")).build(); FutureResponse futureResponse = new FutureResponse(); restClient.performRequestAsync(request, futureResponse); diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java index c54fa991b8674..a0e2dbcae1817 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java @@ -201,12 +201,12 @@ public void testSyncRequests() throws IOException { final int statusCode = randomBoolean() ? randomOkStatusCode(getRandom()) : randomErrorNoRetryStatusCode(getRandom()); Response response; try { - response = restClient.performRequest(new Request(method, "/" + statusCode)); + response = restClient.performRequest(Request.newRequest(method, "/" + statusCode).build()); } catch (ResponseException responseException) { response = responseException.getResponse(); } assertEquals(method, response.getRequestLine().getMethod()); - assertEquals(statusCode, response.getStatusLine().getStatusCode()); + assertEquals(statusCode, response.getStatusLine().statusCode()); assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + statusCode, response.getRequestLine().getUri()); } } @@ -219,7 +219,7 @@ public void testAsyncRequests() throws Exception { final String method = RestClientTestUtil.randomHttpMethod(getRandom()); // we don't test status codes that are subject to retries as they interfere with hosts being stopped final int statusCode = randomBoolean() ? randomOkStatusCode(getRandom()) : randomErrorNoRetryStatusCode(getRandom()); - restClient.performRequestAsync(new Request(method, "/" + statusCode), new ResponseListener() { + restClient.performRequestAsync(Request.newRequest(method, "/" + statusCode).build(), new ResponseListener() { @Override public void onSuccess(Response response) { responses.add(new TestResponse(method, statusCode, response)); @@ -239,7 +239,7 @@ public void onFailure(Exception exception) { for (TestResponse testResponse : responses) { Response response = testResponse.getResponse(); assertEquals(testResponse.method, response.getRequestLine().getMethod()); - assertEquals(testResponse.statusCode, response.getStatusLine().getStatusCode()); + assertEquals(testResponse.statusCode, response.getStatusLine().statusCode()); assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + testResponse.statusCode, response.getRequestLine().getUri()); } } @@ -252,7 +252,7 @@ public void testCancelAsyncRequests() throws Exception { for (int i = 0; i < numRequests; i++) { CountDownLatch latch = new CountDownLatch(1); waitForCancelHandler.reset(); - Cancellable cancellable = restClient.performRequestAsync(new Request("GET", "/wait"), new ResponseListener() { + Cancellable cancellable = restClient.performRequestAsync(Request.newRequest("GET", "/wait").build(), new ResponseListener() { @Override public void onSuccess(Response response) { responses.add(response); @@ -287,7 +287,7 @@ public void onFailure(Exception exception) { */ public void testNodeSelector() throws Exception { try (RestHttpClient restClient = buildRestClient(firstPositionNodeSelector())) { - Request request = new Request("GET", "/200"); + Request request = Request.newRequest("GET", "/200").build(); int rounds = between(1, 10); for (int i = 0; i < rounds; i++) { /* diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java index 68638ac19696e..0dd22b5a3ad0a 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java @@ -100,9 +100,9 @@ public void testRoundRobinOkStatusCodes() throws Exception { int statusCode = randomOkStatusCode(getRandom()); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync( restClient, - new Request(randomHttpMethod(getRandom()), "/" + statusCode) + Request.newRequest(randomHttpMethod(getRandom()), "/" + statusCode).build() ); - assertEquals(statusCode, response.getStatusLine().getStatusCode()); + assertEquals(statusCode, response.getStatusLine().statusCode()); assertTrue("host not found: " + response.getHost(), hostsSet.remove(response.getHost())); } assertEquals("every host should have been used but some weren't: " + hostsSet, 0, hostsSet.size()); @@ -121,12 +121,12 @@ public void testRoundRobinNoRetryErrors() throws Exception { try { Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync( restClient, - new Request(method, "/" + statusCode) + Request.newRequest(method, "/" + statusCode).build() ); if (method.equals("HEAD") && statusCode == 404) { // no exception gets thrown although we got a 404 - assertEquals(404, response.getStatusLine().getStatusCode()); - assertEquals(statusCode, response.getStatusLine().getStatusCode()); + assertEquals(404, response.getStatusLine().statusCode()); + assertEquals(statusCode, response.getStatusLine().statusCode()); assertTrue("host not found: " + response.getHost(), hostsSet.remove(response.getHost())); } else { fail("request should have failed"); @@ -136,7 +136,7 @@ public void testRoundRobinNoRetryErrors() throws Exception { throw e; } Response response = e.getResponse(); - assertEquals(statusCode, response.getStatusLine().getStatusCode()); + assertEquals(statusCode, response.getStatusLine().statusCode()); assertTrue("host not found: " + response.getHost(), hostsSet.remove(response.getHost())); assertEquals(0, e.getSuppressed().length); } @@ -150,7 +150,10 @@ public void testRoundRobinRetryErrors() throws Exception { RestHttpClient restClient = createRestClient(NodeSelector.ANY); String retryEndpoint = randomErrorRetryEndpoint(); try { - RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, new Request(randomHttpMethod(getRandom()), retryEndpoint)); + RestHttpClientSingleHostTests.performRequestSyncOrAsync( + restClient, + Request.newRequest(randomHttpMethod(getRandom()), retryEndpoint).build() + ); fail("request should have failed"); } catch (ResponseException e) { Set hostsSet = hostsSet(); @@ -158,7 +161,7 @@ public void testRoundRobinRetryErrors() throws Exception { failureListener.assertCalled(nodes); do { Response response = e.getResponse(); - assertEquals(Integer.parseInt(retryEndpoint.substring(1)), response.getStatusLine().getStatusCode()); + assertEquals(Integer.parseInt(retryEndpoint.substring(1)), response.getStatusLine().statusCode()); assertTrue( "host [" + response.getHost() + "] not found, most likely used multiple times", hostsSet.remove(response.getHost()) @@ -201,12 +204,12 @@ public void testRoundRobinRetryErrors() throws Exception { try { RestHttpClientSingleHostTests.performRequestSyncOrAsync( restClient, - new Request(randomHttpMethod(getRandom()), retryEndpoint) + Request.newRequest(randomHttpMethod(getRandom()), retryEndpoint).build() ); fail("request should have failed"); } catch (ResponseException e) { Response response = e.getResponse(); - assertThat(response.getStatusLine().getStatusCode(), equalTo(Integer.parseInt(retryEndpoint.substring(1)))); + assertThat(response.getStatusLine().statusCode(), equalTo(Integer.parseInt(retryEndpoint.substring(1)))); assertTrue( "host [" + response.getHost() + "] not found, most likely used multiple times", hostsSet.remove(response.getHost()) @@ -233,12 +236,12 @@ public void testRoundRobinRetryErrors() throws Exception { try { response = RestHttpClientSingleHostTests.performRequestSyncOrAsync( restClient, - new Request(randomHttpMethod(getRandom()), "/" + statusCode) + Request.newRequest(randomHttpMethod(getRandom()), "/" + statusCode).build() ); } catch (ResponseException e) { response = e.getResponse(); } - assertThat(response.getStatusLine().getStatusCode(), equalTo(statusCode)); + assertThat(response.getStatusLine().statusCode(), equalTo(statusCode)); if (selectedHost == null) { selectedHost = response.getHost(); } else { @@ -253,12 +256,12 @@ public void testRoundRobinRetryErrors() throws Exception { try { RestHttpClientSingleHostTests.performRequestSyncOrAsync( restClient, - new Request(randomHttpMethod(getRandom()), retryEndpoint) + Request.newRequest(randomHttpMethod(getRandom()), retryEndpoint).build() ); fail("request should have failed"); } catch (ResponseException e) { Response response = e.getResponse(); - assertThat(response.getStatusLine().getStatusCode(), equalTo(Integer.parseInt(retryEndpoint.substring(1)))); + assertThat(response.getStatusLine().statusCode(), equalTo(Integer.parseInt(retryEndpoint.substring(1)))); assertThat(response.getHost(), equalTo(selectedHost)); failureListener.assertCalled(selectedHost); } catch (IOException e) { @@ -290,7 +293,7 @@ public void testNodeSelector() throws Exception { * Run the request more than once to verify that the * NodeSelector overrides the round robin behavior. */ - Request request = new Request("GET", "/200"); + Request request = Request.newRequest("GET", "/200").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(nodes.get(0).getHost(), response.getHost()); } @@ -312,7 +315,7 @@ public void testSetNodes() throws Exception { * Run the request more than once to verify that the * NodeSelector overrides the round robin behavior. */ - Request request = new Request("GET", "/200"); + Request request = Request.newRequest("GET", "/200").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(newNodes.get(0).getHost(), response.getHost()); } diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java index 1e73b67a62583..4de3db5421298 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java @@ -216,8 +216,7 @@ public void testManyAsyncRequests() throws Exception { final CountDownLatch latch = new CountDownLatch(iters); final List exceptions = new CopyOnWriteArrayList<>(); for (int i = 0; i < iters; i++) { - Request request = new Request("PUT", "/200"); - request.setEntity(BodyPublishers.ofString("{}")); + Request request = Request.newRequest("PUT", "/200").withEntity(BodyPublishers.ofString("{}")).build(); // Add random jitter so HttpServer will not refuse the connections LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(randomLongBetween(1, 5))); restClient.performRequestAsync(request, new ResponseListener() { @@ -247,7 +246,7 @@ public void onFailure(Exception exception) { } public void testCancelAsyncRequest() throws Exception { - Request request = new Request(RestClientTestUtil.randomHttpMethod(getRandom()), "/wait"); + Request request = Request.newRequest(RestClientTestUtil.randomHttpMethod(getRandom()), "/wait").build(); CountDownLatch requestLatch = new CountDownLatch(1); AtomicReference error = new AtomicReference<>(); Cancellable cancellable = restClient.performRequestAsync(request, new ResponseListener() { @@ -282,10 +281,9 @@ public void testHeaders() throws Exception { final Map> requestHeaders = RestClientTestUtil.randomHeaders(getRandom(), "Header"); final int statusCode = RestClientTestUtil.randomStatusCode(getRandom()); - Request request = new Request(method, "/" + statusCode); - RequestOptions.Builder options = request.getOptions().toBuilder(); - options.addHeaders(requestHeaders); - request.setOptions(options); + Request request = Request.newRequest(method, "/" + statusCode) + .withOptions(RequestOptions.DEFAULT.toBuilder().addHeaders(requestHeaders)) + .build(); Response esResponse; try { esResponse = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); @@ -294,7 +292,7 @@ public void testHeaders() throws Exception { } assertEquals(method, esResponse.getRequestLine().getMethod()); - assertEquals(statusCode, esResponse.getStatusLine().getStatusCode()); + assertEquals(statusCode, esResponse.getStatusLine().statusCode()); assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().getUri()); assertHeaders(defaultHeaders, requestHeaders, esResponse.getHeaders(), standardHeaders); @@ -331,50 +329,42 @@ public void testGetWithBody() throws Exception { public void testEncodeParams() throws Exception { { - Request request = new Request("PUT", "/200"); - request.addParameter("routing", "this/is/the/routing"); + Request request = Request.newRequest("PUT", "/200").withParameter("routing", "this/is/the/routing").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(pathPrefix + "/200?routing=this%2Fis%2Fthe%2Frouting", response.getRequestLine().getUri()); } { - Request request = new Request("PUT", "/200"); - request.addParameter("routing", "this|is|the|routing"); + Request request = Request.newRequest("PUT", "/200").withParameter("routing", "this|is|the|routing").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(pathPrefix + "/200?routing=this%7Cis%7Cthe%7Crouting", response.getRequestLine().getUri()); } { - Request request = new Request("PUT", "/200"); - request.addParameter("routing", "routing#1"); + Request request = Request.newRequest("PUT", "/200").withParameter("routing", "routing#1").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(pathPrefix + "/200?routing=routing%231", response.getRequestLine().getUri()); } { - Request request = new Request("PUT", "/200"); - request.addParameter("routing", "中文"); + Request request = Request.newRequest("PUT", "/200").withParameter("routing", "中文").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(pathPrefix + "/200?routing=%E4%B8%AD%E6%96%87", response.getRequestLine().getUri()); } { - Request request = new Request("PUT", "/200"); - request.addParameter("routing", "foo bar"); + Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(pathPrefix + "/200?routing=foo+bar", response.getRequestLine().getUri()); } { - Request request = new Request("PUT", "/200"); - request.addParameter("routing", "foo+bar"); + Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo+bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(pathPrefix + "/200?routing=foo%2Bbar", response.getRequestLine().getUri()); } { - Request request = new Request("PUT", "/200"); - request.addParameter("routing", "foo/bar"); + Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo/bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(pathPrefix + "/200?routing=foo%2Fbar", response.getRequestLine().getUri()); } { - Request request = new Request("PUT", "/200"); - request.addParameter("routing", "foo^bar"); + Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo^bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(pathPrefix + "/200?routing=foo%5Ebar", response.getRequestLine().getUri()); } @@ -434,14 +424,20 @@ public void testAuthCredentialsAreNotClearedOnAuthChallenge() throws Exception { public void testUrlWithoutLeadingSlash() throws Exception { if (pathPrefix.length() == 0) { - Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, new Request("GET", "200")); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync( + restClient, + Request.newRequest("GET", "200").build() + ); // a trailing slash gets automatically added even if a pathPrefix is not configured (HttpClient uses full URI) - assertEquals(200, response.getStatusLine().getStatusCode()); + assertEquals(200, response.getStatusLine().statusCode()); } else { { - Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, new Request("GET", "200")); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync( + restClient, + Request.newRequest("GET", "200").build() + ); // a trailing slash gets automatically added if a pathPrefix is configured - assertEquals(200, response.getStatusLine().getStatusCode()); + assertEquals(200, response.getStatusLine().statusCode()); } { // pathPrefix is not required to start with '/', will be added automatically @@ -450,9 +446,12 @@ public void testUrlWithoutLeadingSlash() throws Exception { new HttpHost("http", httpServer.getAddress().getHostString(), httpServer.getAddress().getPort()) ).setPathPrefix(pathPrefix.substring(1)).build() ) { - Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, new Request("GET", "200")); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync( + restClient, + Request.newRequest("GET", "200").build() + ); // a trailing slash gets automatically added if a pathPrefix is configured - assertEquals(200, response.getStatusLine().getStatusCode()); + assertEquals(200, response.getStatusLine().statusCode()); } } } @@ -470,21 +469,20 @@ private Response bodyTest(final RestHttpClient restClient, final String method) private Response bodyTest(RestHttpClient restClient, String method, int statusCode, Map> headers) throws Exception { String requestBody = "{ \"field\": \"value\" }"; - Request request = new Request(method, "/" + statusCode); - request.setJsonEntity(requestBody); - RequestOptions.Builder options = request.getOptions().toBuilder(); + Request.Builder builder = Request.newRequest(method, "/" + statusCode).withEntity(requestBody); + RequestOptions.Builder options = RequestOptions.DEFAULT.toBuilder(); for (Map.Entry> header : headers.entrySet()) { header.getValue().forEach(v -> options.addHeader(header.getKey(), v)); } - request.setOptions(options); + builder = builder.withOptions(options); Response esResponse; try { - esResponse = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + esResponse = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, builder.build()); } catch (ResponseException e) { esResponse = e.getResponse(); } assertEquals(method, esResponse.getRequestLine().getMethod()); - assertEquals(statusCode, esResponse.getStatusLine().getStatusCode()); + assertEquals(statusCode, esResponse.getStatusLine().statusCode()); assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().getUri()); assertEquals(requestBody, BodyUtils.getBodyAsString(esResponse)); diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java index e0c32ed22a064..a9ae5e559b1ba 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java @@ -159,7 +159,7 @@ private StreamingResponse bodyTest(RestHttpClient restClient, String method, int StreamingResponse esResponse = restClient.streamRequest(request); assertEquals(method, esResponse.getRequestLine().getMethod()); - assertEquals(statusCode, esResponse.getStatusLine().getStatusCode()); + assertEquals(statusCode, esResponse.getStatusLine().statusCode()); assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().getUri()); if (statusCode >= 200 && statusCode < 400) { diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java index a6b34ef4b0176..afa11285121fc 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java @@ -316,8 +316,8 @@ public void testInternalHttpRequest() throws Exception { public void testOkStatusCodes() throws Exception { for (String method : getHttpMethods()) { for (int okStatusCode : getOkStatusCodes()) { - Response response = performRequestSyncOrAsync(restClient, new Request(method, "/" + okStatusCode)); - assertThat(response.getStatusLine().getStatusCode(), equalTo(okStatusCode)); + Response response = performRequestSyncOrAsync(restClient, Request.newRequest(method, "/" + okStatusCode).build()); + assertThat(response.getStatusLine().statusCode(), equalTo(okStatusCode)); } } failureListener.assertNotCalled(); @@ -347,14 +347,14 @@ public void testErrorStatusCodes() throws Exception { // error status codes should cause an exception to be thrown for (int errorStatusCode : getAllErrorStatusCodes()) { try { - Request request = new Request(method, "/" + errorStatusCode); + Request.Builder builder = Request.newRequest(method, "/" + errorStatusCode); if (false == ignoreParam.isEmpty()) { - request.addParameter("ignore", ignoreParam); + builder = builder.withParameter("ignore", ignoreParam); } - Response response = restClient.performRequest(request); + Response response = restClient.performRequest(builder.build()); if (expectedIgnores.contains(errorStatusCode)) { // no exception gets thrown although we got an error status code, as it was configured to be ignored - assertEquals(errorStatusCode, response.getStatusLine().getStatusCode()); + assertEquals(errorStatusCode, response.getStatusLine().statusCode()); } else { fail("request should have failed"); } @@ -362,7 +362,7 @@ public void testErrorStatusCodes() throws Exception { if (expectedIgnores.contains(errorStatusCode)) { throw e; } - assertEquals(errorStatusCode, e.getResponse().getStatusLine().getStatusCode()); + assertEquals(errorStatusCode, e.getResponse().getStatusLine().statusCode()); assertExceptionStackContainsCallingMethod(e); } if (errorStatusCode <= 500 || expectedIgnores.contains(errorStatusCode)) { @@ -378,7 +378,7 @@ public void testPerformRequestIOExceptions() throws Exception { for (String method : getHttpMethods()) { // IOExceptions should be let bubble up try { - restClient.performRequest(new Request(method, "/ioe")); + restClient.performRequest(Request.newRequest(method, "/ioe").build()); fail("request should have failed"); } catch (IOException e) { // And we do all that so the thrown exception has our method in the stacktrace @@ -386,7 +386,7 @@ public void testPerformRequestIOExceptions() throws Exception { } failureListener.assertCalled(singletonList(node)); try { - restClient.performRequest(new Request(method, "/coe")); + restClient.performRequest(Request.newRequest(method, "/coe").build()); fail("request should have failed"); } catch (HttpTimeoutException e) { // And we do all that so the thrown exception has our method in the stacktrace @@ -394,7 +394,7 @@ public void testPerformRequestIOExceptions() throws Exception { } failureListener.assertCalled(singletonList(node)); try { - restClient.performRequest(new Request(method, "/soe")); + restClient.performRequest(Request.newRequest(method, "/soe").build()); fail("request should have failed"); } catch (SocketTimeoutException e) { // And we do all that so the thrown exception has our method in the stacktrace @@ -402,7 +402,7 @@ public void testPerformRequestIOExceptions() throws Exception { } failureListener.assertCalled(singletonList(node)); try { - restClient.performRequest(new Request(method, "/closed")); + restClient.performRequest(Request.newRequest(method, "/closed").build()); fail("request should have failed"); } catch (ClosedChannelException e) { // And we do all that so the thrown exception has our method in the stacktrace @@ -410,7 +410,7 @@ public void testPerformRequestIOExceptions() throws Exception { } failureListener.assertCalled(singletonList(node)); try { - restClient.performRequest(new Request(method, "/handshake")); + restClient.performRequest(Request.newRequest(method, "/handshake").build()); fail("request should have failed"); } catch (SSLHandshakeException e) { // And we do all that so the thrown exception has our method in the stacktrace @@ -423,7 +423,7 @@ public void testPerformRequestIOExceptions() throws Exception { public void testPerformRequestRuntimeExceptions() throws Exception { for (String method : getHttpMethods()) { try { - restClient.performRequest(new Request(method, "/runtime")); + restClient.performRequest(Request.newRequest(method, "/runtime").build()); fail("request should have failed"); } catch (RuntimeException e) { // And we do all that so the thrown exception has our method in the stacktrace @@ -436,7 +436,7 @@ public void testPerformRequestRuntimeExceptions() throws Exception { public void testPerformRequestExceptions() throws Exception { for (String method : getHttpMethods()) { try { - restClient.performRequest(new Request(method, "/uri")); + restClient.performRequest(Request.newRequest(method, "/uri").build()); fail("request should have failed"); } catch (RuntimeException e) { assertThat(e.getCause(), instanceOf(URISyntaxException.class)); @@ -456,21 +456,19 @@ public void testBody() throws Exception { BodyPublisher entity = BodyPublishers.ofString(body); for (String method : Arrays.asList("DELETE", "GET", "PATCH", "POST", "PUT", "TRACE")) { for (int okStatusCode : getOkStatusCodes()) { - Request request = new Request(method, "/" + okStatusCode); - request.setEntity(entity); + Request request = Request.newRequest(method, "/" + okStatusCode).withEntity(entity).build(); Response response = restClient.performRequest(request); - assertThat(response.getStatusLine().getStatusCode(), equalTo(okStatusCode)); + assertThat(response.getStatusLine().statusCode(), equalTo(okStatusCode)); assertThat(BodyUtils.getBodyAsString(response), equalTo(body)); } for (int errorStatusCode : getAllErrorStatusCodes()) { - Request request = new Request(method, "/" + errorStatusCode); - request.setEntity(entity); + Request request = Request.newRequest(method, "/" + errorStatusCode).withEntity(entity).build(); try { restClient.performRequest(request); fail("request should have failed"); } catch (ResponseException e) { Response response = e.getResponse(); - assertThat(response.getStatusLine().getStatusCode(), equalTo(errorStatusCode)); + assertThat(response.getStatusLine().statusCode(), equalTo(errorStatusCode)); assertThat(BodyUtils.getBodyAsString(response), equalTo(body)); assertExceptionStackContainsCallingMethod(e); } @@ -486,19 +484,19 @@ public void testHeaders() throws Exception { for (String method : getHttpMethods()) { final Map> requestHeaders = RestClientTestUtil.randomHeaders(getRandom(), "Header"); final int statusCode = randomStatusCode(getRandom()); - Request request = new Request(method, "/" + statusCode); - RequestOptions.Builder options = request.getOptions().toBuilder(); + Request.Builder builder = Request.newRequest(method, "/" + statusCode); + RequestOptions.Builder options = RequestOptions.DEFAULT.toBuilder(); for (Map.Entry> requestHeader : requestHeaders.entrySet()) { requestHeader.getValue().forEach(v -> options.addHeader(requestHeader.getKey(), v)); } - request.setOptions(options); + builder = builder.withOptions(options); Response esResponse; try { - esResponse = performRequestSyncOrAsync(restClient, request); + esResponse = performRequestSyncOrAsync(restClient, builder.build()); } catch (ResponseException e) { esResponse = e.getResponse(); } - assertThat(esResponse.getStatusLine().getStatusCode(), equalTo(statusCode)); + assertThat(esResponse.getStatusLine().statusCode(), equalTo(statusCode)); assertHeaders(defaultHeaders, requestHeaders, esResponse.getHeaders(), Collections.emptySet()); assertFalse(esResponse.hasWarnings()); } @@ -563,8 +561,8 @@ public boolean warningsShouldFailRequest(List warnings) { private void assertDeprecationWarnings(List warningHeaderTexts, List warningBodyTexts) throws Exception { String method = randomFrom(getHttpMethods()); - Request request = new Request(method, "/200"); - RequestOptions.Builder options = request.getOptions().toBuilder(); + Request.Builder builder = Request.newRequest(method, "/200"); + RequestOptions.Builder options = RequestOptions.DEFAULT.toBuilder(); for (String warningHeaderText : warningHeaderTexts) { options.addHeader("Warning", warningHeaderText); } @@ -579,12 +577,12 @@ private void assertDeprecationWarnings(List warningHeaderTexts, List warningHeaderTexts, List params = new HashMap<>(); if (randomBoolean()) { @@ -623,7 +621,7 @@ private HttpRequest performRandomRequest(String method) throws Exception { for (int i = 0; i < numParams; i++) { String name = "param-" + i; String value = randomAsciiAlphanumOfLengthBetween(3, 10); - request.addParameter(name, value); + builder = builder.withParameter(name, value); params.put(name, value); } } @@ -633,7 +631,7 @@ private HttpRequest performRandomRequest(String method) throws Exception { if (randomBoolean()) { ignore += "," + randomFrom(RestClientTestUtil.getAllErrorStatusCodes()); } - request.addParameter("ignore", ignore); + builder = builder.withParameter("ignore", ignore); } final String additionalQuery = params.entrySet() @@ -645,20 +643,20 @@ private HttpRequest performRandomRequest(String method) throws Exception { BodyPublisher bodyPublisher = BodyPublishers.noBody(); if (getRandom().nextBoolean()) { bodyPublisher = BodyPublishers.ofString(randomAsciiAlphanumOfLengthBetween(10, 100)); - request.setEntity(bodyPublisher); + builder = builder.withEntity(bodyPublisher); } HttpRequest.Builder expectedRequest = HttpRequest.newBuilder(uri).method(method, bodyPublisher); final Set uniqueNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); if (randomBoolean()) { Map> headers = RestClientTestUtil.randomHeaders(getRandom(), "Header"); - RequestOptions.Builder options = request.getOptions().toBuilder(); + RequestOptions.Builder options = RequestOptions.DEFAULT.toBuilder(); options.addHeaders(headers); for (Map.Entry> header : headers.entrySet()) { header.getValue().forEach(v -> expectedRequest.header(header.getKey(), v)); uniqueNames.add(header.getKey()); } - request.setOptions(options); + builder = builder.withOptions(options); } for (Map.Entry> defaultHeader : defaultHeaders.entrySet()) { // request level headers override default headers @@ -667,7 +665,7 @@ private HttpRequest performRandomRequest(String method) throws Exception { } } try { - performRequestSyncOrAsync(restClient, request); + performRequestSyncOrAsync(restClient, builder.build()); } catch (Exception e) { // all good } From b556ee98a264fb3467f25acfe5eab0c1a5eb91e0 Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Sat, 6 Jun 2026 12:25:34 -0400 Subject: [PATCH 5/9] Address code review comments Signed-off-by: Andriy Redko --- .../internal/httpclient/BodyUtils.java | 2 +- .../httpclient/CompressedResponse.java | 41 +++++++ .../httpclient/NonCompressedResponse.java | 32 ++++++ .../internal/httpclient/Response.java | 103 ++++++++---------- .../httpclient/ResponseException.java | 12 +- .../internal/httpclient/RestHttpClient.java | 8 +- .../httpclient/StreamingResponse.java | 8 +- .../httpclient/WarningFailureException.java | 2 +- .../RestHttpClientCompressionTests.java | 4 +- .../RestHttpClientGzipCompressionTests.java | 8 +- ...RestHttpClientMultipleHostsIntegTests.java | 14 +-- .../RestHttpClientMultipleHostsTests.java | 43 ++++---- .../RestHttpClientSingleHostIntegTests.java | 46 ++++---- .../RestHttpClientSingleHostTests.java | 16 +-- 14 files changed, 201 insertions(+), 138 deletions(-) create mode 100644 client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/CompressedResponse.java create mode 100644 client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/NonCompressedResponse.java diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/BodyUtils.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/BodyUtils.java index e5c1d78cc7713..4b1ef84f8a702 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/BodyUtils.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/BodyUtils.java @@ -37,7 +37,7 @@ static Mono getBody(HttpRequest httpRequest) { } static String getBodyAsString(Response response) { - return getBodyAsString(response.getEntity()); + return getBodyAsString(response.entity()); } static Mono getBodyAsString(HttpRequest httpRequest) { diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/CompressedResponse.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/CompressedResponse.java new file mode 100644 index 0000000000000..4060891dd48dd --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/CompressedResponse.java @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.internal.httpclient; + +import java.io.InputStream; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Objects; + +final record CompressedResponse(RequestLine requestLine, HttpHost host, HttpResponse httpResponse, List entity) + implements + Response { + CompressedResponse { + requestLine = Objects.requireNonNull(requestLine, "requestLine cannot be null"); + host = Objects.requireNonNull(host, "host cannot be null"); + httpResponse = Objects.requireNonNull(httpResponse, "response cannot be null"); + } + + /** + * Returns the response body available, null otherwise + * @see InputStream + */ + public List entity() { + return BodyUtils.decompress(entity); + } + + /** + * Convert response to string representation + */ + @Override + public String toString() { + return "Response{" + "requestLine=" + requestLine() + ", host=" + host() + ", response=" + statusLine() + '}'; + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/NonCompressedResponse.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/NonCompressedResponse.java new file mode 100644 index 0000000000000..67137c468d2d6 --- /dev/null +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/NonCompressedResponse.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.internal.httpclient; + +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Objects; + +final record NonCompressedResponse(RequestLine requestLine, HttpHost host, HttpResponse httpResponse, List entity) + implements + Response { + NonCompressedResponse { + requestLine = Objects.requireNonNull(requestLine, "requestLine cannot be null"); + host = Objects.requireNonNull(host, "host cannot be null"); + httpResponse = Objects.requireNonNull(httpResponse, "response cannot be null"); + } + + /** + * Convert response to string representation + */ + @Override + public String toString() { + return "Response{" + "requestLine=" + requestLine() + ", host=" + host() + ", response=" + statusLine() + '}'; + } +} diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Response.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Response.java index 268a4821d7766..dccfb8d24a79b 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Response.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/Response.java @@ -13,68 +13,75 @@ import java.net.http.HttpResponse; import java.nio.ByteBuffer; import java.util.List; -import java.util.Objects; import java.util.concurrent.Flow; /** - * Holds an opensearch response. It wraps the {@link HttpResponse} returned and associates it with + * Holds an OpenSearch response. It wraps the {@link HttpResponse} returned and associates it with * its corresponding {@link RequestLine} and {@link HttpHost}. * Note: This is an experimental API. */ -public class Response { - private final RequestLine requestLine; - private final HttpHost host; - private final HttpResponse response; - private final List body; - private final boolean compressed; - - private Response(RequestLine requestLine, HttpHost host, HttpResponse response, List body) { - Objects.requireNonNull(requestLine, "requestLine cannot be null"); - Objects.requireNonNull(host, "host cannot be null"); - Objects.requireNonNull(response, "response cannot be null"); - this.requestLine = requestLine; - this.host = host; - this.response = response; - this.body = body; - this.compressed = response.headers().firstValue("Content-Encoding").filter("gzip"::equalsIgnoreCase).map(h -> true).orElse(false); - } - +public sealed interface Response permits CompressedResponse, NonCompressedResponse { + /** + * Create response from streaming conversation + * @param requestLine request line + * @param host host + * @param response underlying HTTP response + * @return new response instance + */ static Response fromStreaming(RequestLine requestLine, HttpHost host, HttpResponse>> response) { - return new Response(requestLine, host, response, List.of() /* streaming body could be very large */); + return new NonCompressedResponse(requestLine, host, response, List.of() /* streaming body could be very large */); } + /** + * Create response from non-streaming conversation + * @param requestLine request line + * @param host host + * @param response underlying HTTP response + * @return new response instance + */ static Response from(RequestLine requestLine, HttpHost host, HttpResponse> response) { - return new Response(requestLine, host, response, response.body()); + final boolean compressed = response.headers() + .firstValue("Content-Encoding") + .filter("gzip"::equalsIgnoreCase) + .map(h -> true) + .orElse(false); + if (compressed == false) { + return new NonCompressedResponse(requestLine, host, response, response.body()); + } else { + return new CompressedResponse(requestLine, host, response, response.body()); + } } /** * Returns the request line that generated this response */ - public RequestLine getRequestLine() { - return requestLine; - } + RequestLine requestLine(); /** * Returns the node that returned this response */ - public HttpHost getHost() { - return host; - } + HttpHost host(); /** * Returns the status line of the current response */ - public StatusLine getStatusLine() { - return new StatusLine(response); + default StatusLine statusLine() { + return new StatusLine(httpResponse()); } /** * Returns all the response headers */ - public HttpHeaders getHeaders() { - return response.headers(); + default HttpHeaders headers() { + return httpResponse().headers(); } + /** + * Returns the response body available, null otherwise + * @see InputStream + */ + List entity(); + /** * Returns the value of the first header with a specified name of this message. * If there is more than one matching header in the message the first element is returned. @@ -82,43 +89,29 @@ public HttpHeaders getHeaders() { * * @param name header name */ - public String getHeader(String name) { - return response.headers().firstValue(name).orElse(null); - } - - /** - * Returns the response body available, null otherwise - * @see InputStream - */ - public List getEntity() { - return (compressed == false) ? body : BodyUtils.decompress(body); + default String header(String name) { + return headers().firstValue(name).orElse(null); } /** * Returns a list of all warning headers returned in the response. */ - public List getWarnings() { - return ResponseWarningsExtractor.getWarnings(response); + default List warnings() { + return ResponseWarningsExtractor.getWarnings(httpResponse()); } /** * Returns true if there is at least one warning header returned in the * response. */ - public boolean hasWarnings() { - List warnings = response.headers().allValues("Warning"); + default boolean hasWarnings() { + List warnings = headers().allValues("Warning"); return warnings != null && warnings.size() > 0; } - HttpResponse getHttpResponse() { - return response; - } - /** - * Convert response to string representation + * Returns underlying HTTP response instance + * @return */ - @Override - public String toString() { - return "Response{" + "requestLine=" + requestLine + ", host=" + host + ", response=" + getStatusLine() + '}'; - } + HttpResponse httpResponse(); } diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java index a771d4c373629..7aa85f09160fc 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java @@ -35,17 +35,17 @@ static String buildMessage(Response response) throws IOException { String message = String.format( Locale.ROOT, "method [%s], host [%s], URI [%s], status line [%s]", - response.getRequestLine().getMethod(), - response.getHost(), - response.getRequestLine().getUri(), - response.getStatusLine().toString() + response.requestLine().getMethod(), + response.host(), + response.requestLine().getUri(), + response.statusLine().toString() ); if (response.hasWarnings()) { - message += "\n" + "Warnings: " + response.getWarnings(); + message += "\n" + "Warnings: " + response.warnings(); } - List entity = response.getEntity(); + List entity = response.entity(); if (entity != null) { message += "\n" + BodyUtils.getBodyAsString(response); } diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java index be4fdf9b6d40c..4c4967d50dc76 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java @@ -353,9 +353,9 @@ private ResponseOrResponseException convertResponse(InternalRequest request, Nod int statusCode = httpResponse.statusCode(); Response response = Response.from(new RequestLine(httpRequest), node.getHost(), httpResponse); - if (isSuccessfulResponse(statusCode) || request.ignoreErrorCodes.contains(response.getStatusLine().statusCode())) { + if (isSuccessfulResponse(statusCode) || request.ignoreErrorCodes.contains(response.statusLine().statusCode())) { onResponse(node); - if (request.warningsHandler.warningsShouldFailRequest(response.getWarnings())) { + if (request.warningsHandler.warningsShouldFailRequest(response.warnings())) { throw new WarningFailureException(response); } return new ResponseOrResponseException(response); @@ -384,9 +384,9 @@ private ResponseOrResponseException convertResponse( RequestLogger.logStreamingResponse(logger, request.httpRequest.apply(node), node.getHost(), httpResponse); int statusCode = httpResponse.statusCode(); - if (isSuccessfulResponse(statusCode) || request.ignoreErrorCodes.contains(response.getStatusLine().statusCode())) { + if (isSuccessfulResponse(statusCode) || request.ignoreErrorCodes.contains(response.statusLine().statusCode())) { onResponse(node); - if (request.warningsHandler.warningsShouldFailRequest(response.getWarnings())) { + if (request.warningsHandler.warningsShouldFailRequest(response.warnings())) { throw new WarningFailureException(response); } return new ResponseOrResponseException(response); diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java index ea60cdb3144b3..f26c142c2db15 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java @@ -92,7 +92,7 @@ public StatusLine getStatusLine() { return new StatusLine( publisher.onErrorResume( ResponseException.class, - e -> Mono.just((HttpResponse>>) e.getResponse().getHttpResponse()) + e -> Mono.just((HttpResponse>>) e.getResponse().httpResponse()) ).block() ); } @@ -105,7 +105,7 @@ public List getWarnings() { return ResponseWarningsExtractor.getWarnings( publisher.onErrorResume( ResponseException.class, - e -> Mono.just((HttpResponse>>) e.getResponse().getHttpResponse()) + e -> Mono.just((HttpResponse>>) e.getResponse().httpResponse()) ).block() ); } @@ -117,7 +117,7 @@ public List getWarnings() { public HttpHeaders getHeaders() { return publisher.onErrorResume( ResponseException.class, - e -> Mono.just((HttpResponse>>) e.getResponse().getHttpResponse()) + e -> Mono.just((HttpResponse>>) e.getResponse().httpResponse()) ).map(HttpResponse::headers).block(); } @@ -132,7 +132,7 @@ public HttpHeaders getHeaders() { public String getHeader(String name) { return publisher.onErrorResume( ResponseException.class, - e -> Mono.just((HttpResponse>>) e.getResponse().getHttpResponse()) + e -> Mono.just((HttpResponse>>) e.getResponse().httpResponse()) ).mapNotNull(response -> response.headers().firstValue(name).orElse(null)).block(); } } diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/WarningFailureException.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/WarningFailureException.java index 3a6443dbe0bf1..53a0a1f97fe4b 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/WarningFailureException.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/WarningFailureException.java @@ -37,7 +37,7 @@ import static org.opensearch.internal.httpclient.ResponseException.buildMessage; /** - * This exception is used to indicate that one or more {@link Response#getWarnings()} exist + * This exception is used to indicate that one or more {@link Response#warnings()} exist * and is typically used when the {@link RestHttpClient} is set to fail by setting * {@link RestHttpClientBuilder#setStrictDeprecationMode(boolean)} to `true`. */ diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java index 150b1fa89bbf9..6a74a752fddd0 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientCompressionTests.java @@ -104,7 +104,7 @@ public void testCompressingClientWithContentLengthSync() throws Exception { Response response = restClient.performRequest(request); - String content = BodyUtils.getBodyAsString(response.getEntity()); + String content = BodyUtils.getBodyAsString(response.entity()); // Content-Encoding#Accept-Encoding#Content-Length#Content // With HttpClient, we don't sent Content-Length so it is always null Assert.assertEquals("gzip#gzip#null#compressing client", content); @@ -120,7 +120,7 @@ public void testCompressingClientContentLengthAsync() throws Exception { Response response = futureResponse.get(); // Server should report it had a compressed request and sent back a compressed response - String content = BodyUtils.getBodyAsString(response.getEntity()); + String content = BodyUtils.getBodyAsString(response.entity()); // Content-Encoding#Accept-Encoding#Content-Length#Content // With HttpClient, we don't sent Content-Length so it is always null diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java index cb38103e88bce..64a7bb01f2b7a 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientGzipCompressionTests.java @@ -125,7 +125,7 @@ public void testGzipHeaderSync() throws Exception { Response response = restClient.performRequest(request); - String content = BodyUtils.getBodyAsString(response.getEntity()); + String content = BodyUtils.getBodyAsString(response.entity()); Assert.assertEquals("null#gzip#plain request, gzip response", content); } } @@ -142,7 +142,7 @@ public void testGzipHeaderAsync() throws Exception { restClient.performRequestAsync(request, futureResponse); Response response = futureResponse.get(); - String content = BodyUtils.getBodyAsString(response.getEntity()); + String content = BodyUtils.getBodyAsString(response.entity()); Assert.assertEquals("null#gzip#plain request, gzip response", content); } } @@ -153,7 +153,7 @@ public void testCompressingClientSync() throws Exception { Response response = restClient.performRequest(request); - String content = BodyUtils.getBodyAsString(response.getEntity()); + String content = BodyUtils.getBodyAsString(response.entity()); Assert.assertEquals("gzip#gzip#compressing client", content); } } @@ -172,7 +172,7 @@ public void testCompressingClientAsync() throws Exception { Response response = futureResponse.get(); // Server should report it had a compressed request and sent back a compressed response - String content = BodyUtils.getBodyAsString(response.getEntity()); + String content = BodyUtils.getBodyAsString(response.entity()); Assert.assertEquals("gzip#gzip#compressing client", content); } } diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java index a0e2dbcae1817..ae1de26bd74f5 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java @@ -205,9 +205,9 @@ public void testSyncRequests() throws IOException { } catch (ResponseException responseException) { response = responseException.getResponse(); } - assertEquals(method, response.getRequestLine().getMethod()); - assertEquals(statusCode, response.getStatusLine().statusCode()); - assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + statusCode, response.getRequestLine().getUri()); + assertEquals(method, response.requestLine().getMethod()); + assertEquals(statusCode, response.statusLine().statusCode()); + assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + statusCode, response.requestLine().getUri()); } } @@ -238,9 +238,9 @@ public void onFailure(Exception exception) { assertEquals(numRequests, responses.size()); for (TestResponse testResponse : responses) { Response response = testResponse.getResponse(); - assertEquals(testResponse.method, response.getRequestLine().getMethod()); - assertEquals(testResponse.statusCode, response.getStatusLine().statusCode()); - assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + testResponse.statusCode, response.getRequestLine().getUri()); + assertEquals(testResponse.method, response.requestLine().getMethod()); + assertEquals(testResponse.statusCode, response.statusLine().statusCode()); + assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + testResponse.statusCode, response.requestLine().getUri()); } } @@ -306,7 +306,7 @@ public void testNodeSelector() throws Exception { } } else { Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(httpHosts[0], response.getHost()); + assertEquals(httpHosts[0], response.host()); } } } diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java index 0dd22b5a3ad0a..2e644f427c4ee 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsTests.java @@ -102,8 +102,8 @@ public void testRoundRobinOkStatusCodes() throws Exception { restClient, Request.newRequest(randomHttpMethod(getRandom()), "/" + statusCode).build() ); - assertEquals(statusCode, response.getStatusLine().statusCode()); - assertTrue("host not found: " + response.getHost(), hostsSet.remove(response.getHost())); + assertEquals(statusCode, response.statusLine().statusCode()); + assertTrue("host not found: " + response.host(), hostsSet.remove(response.host())); } assertEquals("every host should have been used but some weren't: " + hostsSet, 0, hostsSet.size()); } @@ -125,9 +125,9 @@ public void testRoundRobinNoRetryErrors() throws Exception { ); if (method.equals("HEAD") && statusCode == 404) { // no exception gets thrown although we got a 404 - assertEquals(404, response.getStatusLine().statusCode()); - assertEquals(statusCode, response.getStatusLine().statusCode()); - assertTrue("host not found: " + response.getHost(), hostsSet.remove(response.getHost())); + assertEquals(404, response.statusLine().statusCode()); + assertEquals(statusCode, response.statusLine().statusCode()); + assertTrue("host not found: " + response.host(), hostsSet.remove(response.host())); } else { fail("request should have failed"); } @@ -136,8 +136,8 @@ public void testRoundRobinNoRetryErrors() throws Exception { throw e; } Response response = e.getResponse(); - assertEquals(statusCode, response.getStatusLine().statusCode()); - assertTrue("host not found: " + response.getHost(), hostsSet.remove(response.getHost())); + assertEquals(statusCode, response.statusLine().statusCode()); + assertTrue("host not found: " + response.host(), hostsSet.remove(response.host())); assertEquals(0, e.getSuppressed().length); } } @@ -161,11 +161,8 @@ public void testRoundRobinRetryErrors() throws Exception { failureListener.assertCalled(nodes); do { Response response = e.getResponse(); - assertEquals(Integer.parseInt(retryEndpoint.substring(1)), response.getStatusLine().statusCode()); - assertTrue( - "host [" + response.getHost() + "] not found, most likely used multiple times", - hostsSet.remove(response.getHost()) - ); + assertEquals(Integer.parseInt(retryEndpoint.substring(1)), response.statusLine().statusCode()); + assertTrue("host [" + response.host() + "] not found, most likely used multiple times", hostsSet.remove(response.host())); if (e.getSuppressed().length > 0) { assertEquals(1, e.getSuppressed().length); Throwable suppressed = e.getSuppressed()[0]; @@ -209,13 +206,13 @@ public void testRoundRobinRetryErrors() throws Exception { fail("request should have failed"); } catch (ResponseException e) { Response response = e.getResponse(); - assertThat(response.getStatusLine().statusCode(), equalTo(Integer.parseInt(retryEndpoint.substring(1)))); + assertThat(response.statusLine().statusCode(), equalTo(Integer.parseInt(retryEndpoint.substring(1)))); assertTrue( - "host [" + response.getHost() + "] not found, most likely used multiple times", - hostsSet.remove(response.getHost()) + "host [" + response.host() + "] not found, most likely used multiple times", + hostsSet.remove(response.host()) ); // after the first request, all hosts are denylisted, a single one gets resurrected each time - failureListener.assertCalled(response.getHost()); + failureListener.assertCalled(response.host()); assertEquals(0, e.getSuppressed().length); } catch (IOException e) { HttpHost httpHost = HttpHost.create(e.getMessage()); @@ -241,11 +238,11 @@ public void testRoundRobinRetryErrors() throws Exception { } catch (ResponseException e) { response = e.getResponse(); } - assertThat(response.getStatusLine().statusCode(), equalTo(statusCode)); + assertThat(response.statusLine().statusCode(), equalTo(statusCode)); if (selectedHost == null) { - selectedHost = response.getHost(); + selectedHost = response.host(); } else { - assertThat(response.getHost(), equalTo(selectedHost)); + assertThat(response.host(), equalTo(selectedHost)); } } failureListener.assertNotCalled(); @@ -261,8 +258,8 @@ public void testRoundRobinRetryErrors() throws Exception { fail("request should have failed"); } catch (ResponseException e) { Response response = e.getResponse(); - assertThat(response.getStatusLine().statusCode(), equalTo(Integer.parseInt(retryEndpoint.substring(1)))); - assertThat(response.getHost(), equalTo(selectedHost)); + assertThat(response.statusLine().statusCode(), equalTo(Integer.parseInt(retryEndpoint.substring(1)))); + assertThat(response.host(), equalTo(selectedHost)); failureListener.assertCalled(selectedHost); } catch (IOException e) { HttpHost httpHost = HttpHost.create(e.getMessage()); @@ -295,7 +292,7 @@ public void testNodeSelector() throws Exception { */ Request request = Request.newRequest("GET", "/200").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(nodes.get(0).getHost(), response.getHost()); + assertEquals(nodes.get(0).getHost(), response.host()); } } @@ -317,7 +314,7 @@ public void testSetNodes() throws Exception { */ Request request = Request.newRequest("GET", "/200").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(newNodes.get(0).getHost(), response.getHost()); + assertEquals(newNodes.get(0).getHost(), response.host()); } } diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java index 4de3db5421298..7542b2742f9b2 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java @@ -291,13 +291,13 @@ public void testHeaders() throws Exception { esResponse = e.getResponse(); } - assertEquals(method, esResponse.getRequestLine().getMethod()); - assertEquals(statusCode, esResponse.getStatusLine().statusCode()); - assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().getUri()); + assertEquals(method, esResponse.requestLine().getMethod()); + assertEquals(statusCode, esResponse.statusLine().statusCode()); + assertEquals(pathPrefix + "/" + statusCode, esResponse.requestLine().getUri()); - assertHeaders(defaultHeaders, requestHeaders, esResponse.getHeaders(), standardHeaders); + assertHeaders(defaultHeaders, requestHeaders, esResponse.headers(), standardHeaders); final Set removedHeaders = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); - for (final Map.Entry> responseHeader : esResponse.getHeaders().map().entrySet()) { + for (final Map.Entry> responseHeader : esResponse.headers().map().entrySet()) { String name = responseHeader.getKey().toLowerCase(Locale.ROOT); // Some headers could be returned multiple times in response, like Connection fe. if (name.startsWith("header") == false && removedHeaders.contains(name) == false) { @@ -331,42 +331,42 @@ public void testEncodeParams() throws Exception { { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "this/is/the/routing").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=this%2Fis%2Fthe%2Frouting", response.getRequestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=this%2Fis%2Fthe%2Frouting", response.requestLine().getUri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "this|is|the|routing").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=this%7Cis%7Cthe%7Crouting", response.getRequestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=this%7Cis%7Cthe%7Crouting", response.requestLine().getUri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "routing#1").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=routing%231", response.getRequestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=routing%231", response.requestLine().getUri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "中文").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=%E4%B8%AD%E6%96%87", response.getRequestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=%E4%B8%AD%E6%96%87", response.requestLine().getUri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=foo+bar", response.getRequestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=foo+bar", response.requestLine().getUri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo+bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=foo%2Bbar", response.getRequestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=foo%2Bbar", response.requestLine().getUri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo/bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=foo%2Fbar", response.getRequestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=foo%2Fbar", response.requestLine().getUri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo^bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=foo%5Ebar", response.getRequestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=foo%5Ebar", response.requestLine().getUri()); } } @@ -379,7 +379,7 @@ public void testPreemptiveAuthEnabled() throws Exception { try (RestHttpClient restClient = createRestClient(true, true)) { for (final String method : methods) { final Response response = bodyTest(restClient, method); - assertThat(response.getHeader("Authorization"), startsWith("Basic")); + assertThat(response.header("Authorization"), startsWith("Basic")); } } } @@ -398,7 +398,7 @@ public void testPreemptiveAuthDisabled() throws Exception { assertThat(ex.getMessage(), equalTo("WWW-Authenticate header missing for response code 401")); } else { final Response response = bodyTest(restClient, method, statusCode, Map.of()); - assertThat(response.getHeader("Authorization"), nullValue()); + assertThat(response.header("Authorization"), nullValue()); } } } @@ -414,10 +414,10 @@ public void testAuthCredentialsAreNotClearedOnAuthChallenge() throws Exception { for (final String method : methods) { Map> realmHeader = Map.of("WWW-Authenticate", List.of("Basic realm=\"test\"")); final Response response401 = bodyTest(restClient, method, 401, realmHeader); - assertThat(response401.getHeader("Authorization"), startsWith("Basic")); + assertThat(response401.header("Authorization"), startsWith("Basic")); final Response response200 = bodyTest(restClient, method, 200, Map.of()); - assertThat(response200.getHeader("Authorization"), startsWith("Basic")); + assertThat(response200.header("Authorization"), startsWith("Basic")); } } } @@ -429,7 +429,7 @@ public void testUrlWithoutLeadingSlash() throws Exception { Request.newRequest("GET", "200").build() ); // a trailing slash gets automatically added even if a pathPrefix is not configured (HttpClient uses full URI) - assertEquals(200, response.getStatusLine().statusCode()); + assertEquals(200, response.statusLine().statusCode()); } else { { Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync( @@ -437,7 +437,7 @@ public void testUrlWithoutLeadingSlash() throws Exception { Request.newRequest("GET", "200").build() ); // a trailing slash gets automatically added if a pathPrefix is configured - assertEquals(200, response.getStatusLine().statusCode()); + assertEquals(200, response.statusLine().statusCode()); } { // pathPrefix is not required to start with '/', will be added automatically @@ -451,7 +451,7 @@ public void testUrlWithoutLeadingSlash() throws Exception { Request.newRequest("GET", "200").build() ); // a trailing slash gets automatically added if a pathPrefix is configured - assertEquals(200, response.getStatusLine().statusCode()); + assertEquals(200, response.statusLine().statusCode()); } } } @@ -481,9 +481,9 @@ private Response bodyTest(RestHttpClient restClient, String method, int statusCo } catch (ResponseException e) { esResponse = e.getResponse(); } - assertEquals(method, esResponse.getRequestLine().getMethod()); - assertEquals(statusCode, esResponse.getStatusLine().statusCode()); - assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().getUri()); + assertEquals(method, esResponse.requestLine().getMethod()); + assertEquals(statusCode, esResponse.statusLine().statusCode()); + assertEquals(pathPrefix + "/" + statusCode, esResponse.requestLine().getUri()); assertEquals(requestBody, BodyUtils.getBodyAsString(esResponse)); return esResponse; diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java index afa11285121fc..eb8a16d257cc0 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostTests.java @@ -317,7 +317,7 @@ public void testOkStatusCodes() throws Exception { for (String method : getHttpMethods()) { for (int okStatusCode : getOkStatusCodes()) { Response response = performRequestSyncOrAsync(restClient, Request.newRequest(method, "/" + okStatusCode).build()); - assertThat(response.getStatusLine().statusCode(), equalTo(okStatusCode)); + assertThat(response.statusLine().statusCode(), equalTo(okStatusCode)); } } failureListener.assertNotCalled(); @@ -354,7 +354,7 @@ public void testErrorStatusCodes() throws Exception { Response response = restClient.performRequest(builder.build()); if (expectedIgnores.contains(errorStatusCode)) { // no exception gets thrown although we got an error status code, as it was configured to be ignored - assertEquals(errorStatusCode, response.getStatusLine().statusCode()); + assertEquals(errorStatusCode, response.statusLine().statusCode()); } else { fail("request should have failed"); } @@ -362,7 +362,7 @@ public void testErrorStatusCodes() throws Exception { if (expectedIgnores.contains(errorStatusCode)) { throw e; } - assertEquals(errorStatusCode, e.getResponse().getStatusLine().statusCode()); + assertEquals(errorStatusCode, e.getResponse().statusLine().statusCode()); assertExceptionStackContainsCallingMethod(e); } if (errorStatusCode <= 500 || expectedIgnores.contains(errorStatusCode)) { @@ -458,7 +458,7 @@ public void testBody() throws Exception { for (int okStatusCode : getOkStatusCodes()) { Request request = Request.newRequest(method, "/" + okStatusCode).withEntity(entity).build(); Response response = restClient.performRequest(request); - assertThat(response.getStatusLine().statusCode(), equalTo(okStatusCode)); + assertThat(response.statusLine().statusCode(), equalTo(okStatusCode)); assertThat(BodyUtils.getBodyAsString(response), equalTo(body)); } for (int errorStatusCode : getAllErrorStatusCodes()) { @@ -468,7 +468,7 @@ public void testBody() throws Exception { fail("request should have failed"); } catch (ResponseException e) { Response response = e.getResponse(); - assertThat(response.getStatusLine().statusCode(), equalTo(errorStatusCode)); + assertThat(response.statusLine().statusCode(), equalTo(errorStatusCode)); assertThat(BodyUtils.getBodyAsString(response), equalTo(body)); assertExceptionStackContainsCallingMethod(e); } @@ -496,8 +496,8 @@ public void testHeaders() throws Exception { } catch (ResponseException e) { esResponse = e.getResponse(); } - assertThat(esResponse.getStatusLine().statusCode(), equalTo(statusCode)); - assertHeaders(defaultHeaders, requestHeaders, esResponse.getHeaders(), Collections.emptySet()); + assertThat(esResponse.statusLine().statusCode(), equalTo(statusCode)); + assertHeaders(defaultHeaders, requestHeaders, esResponse.headers(), Collections.emptySet()); assertFalse(esResponse.hasWarnings()); } } @@ -595,7 +595,7 @@ private void assertDeprecationWarnings(List warningHeaderTexts, List Date: Sat, 6 Jun 2026 13:31:17 -0400 Subject: [PATCH 6/9] Address code review comments Signed-off-by: Andriy Redko --- .../internal/httpclient/RequestLine.java | 48 +++++-------------- .../httpclient/ResponseException.java | 4 +- ...RestHttpClientMultipleHostsIntegTests.java | 8 ++-- .../RestHttpClientSingleHostIntegTests.java | 24 +++++----- ...tpClientSingleHostStreamingIntegTests.java | 4 +- 5 files changed, 31 insertions(+), 57 deletions(-) diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java index 68d555576979e..06899bbf86d5a 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RequestLine.java @@ -18,23 +18,19 @@ * Request line (protocol, method, uri) * Note: This is an experimental API. */ -public final class RequestLine implements Serializable { - +public record RequestLine(String method, String uri, Version protocolVersion) implements Serializable { private static final long serialVersionUID = 2810581718468737193L; - private final Version protoversion; - private final String method; - private final String uri; + public RequestLine { + method = Objects.requireNonNull(method, "Method"); + } /** * Create a new instance from the request * @param request HTTP request */ public RequestLine(final HttpRequest request) { - Objects.requireNonNull(request, "Request"); - this.method = request.method(); - this.uri = buildUri(request.uri()); - this.protoversion = request.version().orElse(Version.HTTP_1_1); + this(Objects.requireNonNull(request, "Request").method(), buildUri(request.uri()), request.version().orElse(Version.HTTP_1_1)); } private static String buildUri(URI uri) { @@ -53,33 +49,11 @@ private static String buildUri(URI uri) { * @param version HTTP protocol */ public RequestLine(final String method, final URI uri, final Version version) { - this.method = Objects.requireNonNull(method, "Method"); - this.uri = Objects.requireNonNull(uri, "URI").getPath(); - this.protoversion = version != null ? version : Version.HTTP_1_1; - } - - /** - * Gets the request HTTP method - * @return HTTP method - */ - public String getMethod() { - return this.method; - } - - /** - * Gets the request HTTP protocol - * @return HTTP protocol - */ - public Version getProtocolVersion() { - return this.protoversion; - } - - /** - * Gets the request uri - * @return request uri - */ - public String getUri() { - return this.uri; + this( + Objects.requireNonNull(method, "Method"), + Objects.requireNonNull(uri, "URI").getPath(), + version != null ? version : Version.HTTP_1_1 + ); } /** @@ -88,7 +62,7 @@ public String getUri() { @Override public String toString() { final StringBuilder buf = new StringBuilder(); - buf.append(this.method).append(" ").append(this.uri).append(" ").append(this.protoversion); + buf.append(this.method).append(" ").append(this.uri).append(" ").append(this.protocolVersion); return buf.toString(); } } diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java index 7aa85f09160fc..05d95bd9014ab 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/ResponseException.java @@ -35,9 +35,9 @@ static String buildMessage(Response response) throws IOException { String message = String.format( Locale.ROOT, "method [%s], host [%s], URI [%s], status line [%s]", - response.requestLine().getMethod(), + response.requestLine().method(), response.host(), - response.requestLine().getUri(), + response.requestLine().uri(), response.statusLine().toString() ); diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java index ae1de26bd74f5..74799a6c5159b 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientMultipleHostsIntegTests.java @@ -205,9 +205,9 @@ public void testSyncRequests() throws IOException { } catch (ResponseException responseException) { response = responseException.getResponse(); } - assertEquals(method, response.requestLine().getMethod()); + assertEquals(method, response.requestLine().method()); assertEquals(statusCode, response.statusLine().statusCode()); - assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + statusCode, response.requestLine().getUri()); + assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + statusCode, response.requestLine().uri()); } } @@ -238,9 +238,9 @@ public void onFailure(Exception exception) { assertEquals(numRequests, responses.size()); for (TestResponse testResponse : responses) { Response response = testResponse.getResponse(); - assertEquals(testResponse.method, response.requestLine().getMethod()); + assertEquals(testResponse.method, response.requestLine().method()); assertEquals(testResponse.statusCode, response.statusLine().statusCode()); - assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + testResponse.statusCode, response.requestLine().getUri()); + assertEquals((pathPrefix.length() > 0 ? pathPrefix : "") + "/" + testResponse.statusCode, response.requestLine().uri()); } } diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java index 7542b2742f9b2..57a05c5a7f25b 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java @@ -291,9 +291,9 @@ public void testHeaders() throws Exception { esResponse = e.getResponse(); } - assertEquals(method, esResponse.requestLine().getMethod()); + assertEquals(method, esResponse.requestLine().method()); assertEquals(statusCode, esResponse.statusLine().statusCode()); - assertEquals(pathPrefix + "/" + statusCode, esResponse.requestLine().getUri()); + assertEquals(pathPrefix + "/" + statusCode, esResponse.requestLine().uri()); assertHeaders(defaultHeaders, requestHeaders, esResponse.headers(), standardHeaders); final Set removedHeaders = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); @@ -331,42 +331,42 @@ public void testEncodeParams() throws Exception { { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "this/is/the/routing").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=this%2Fis%2Fthe%2Frouting", response.requestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=this%2Fis%2Fthe%2Frouting", response.requestLine().uri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "this|is|the|routing").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=this%7Cis%7Cthe%7Crouting", response.requestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=this%7Cis%7Cthe%7Crouting", response.requestLine().uri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "routing#1").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=routing%231", response.requestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=routing%231", response.requestLine().uri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "中文").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=%E4%B8%AD%E6%96%87", response.requestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=%E4%B8%AD%E6%96%87", response.requestLine().uri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=foo+bar", response.requestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=foo+bar", response.requestLine().uri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo+bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=foo%2Bbar", response.requestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=foo%2Bbar", response.requestLine().uri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo/bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=foo%2Fbar", response.requestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=foo%2Fbar", response.requestLine().uri()); } { Request request = Request.newRequest("PUT", "/200").withParameter("routing", "foo^bar").build(); Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); - assertEquals(pathPrefix + "/200?routing=foo%5Ebar", response.requestLine().getUri()); + assertEquals(pathPrefix + "/200?routing=foo%5Ebar", response.requestLine().uri()); } } @@ -481,9 +481,9 @@ private Response bodyTest(RestHttpClient restClient, String method, int statusCo } catch (ResponseException e) { esResponse = e.getResponse(); } - assertEquals(method, esResponse.requestLine().getMethod()); + assertEquals(method, esResponse.requestLine().method()); assertEquals(statusCode, esResponse.statusLine().statusCode()); - assertEquals(pathPrefix + "/" + statusCode, esResponse.requestLine().getUri()); + assertEquals(pathPrefix + "/" + statusCode, esResponse.requestLine().uri()); assertEquals(requestBody, BodyUtils.getBodyAsString(esResponse)); return esResponse; diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java index a9ae5e559b1ba..ab71cf191d18a 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java @@ -158,9 +158,9 @@ private StreamingResponse bodyTest(RestHttpClient restClient, String method, int request.setOptions(options); StreamingResponse esResponse = restClient.streamRequest(request); - assertEquals(method, esResponse.getRequestLine().getMethod()); + assertEquals(method, esResponse.getRequestLine().method()); assertEquals(statusCode, esResponse.getStatusLine().statusCode()); - assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().getUri()); + assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().uri()); if (statusCode >= 200 && statusCode < 400) { StepVerifier.create(Flux.from(esResponse.getBody()).map(StandardCharsets.UTF_8::decode).map(CharBuffer::toString)) From eac3f23e47f2c7ad565d4f08f99c3cf0c0ff8cf5 Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Sat, 6 Jun 2026 14:04:17 -0400 Subject: [PATCH 7/9] Address code review comments Signed-off-by: Andriy Redko --- .../internal/httpclient/RestHttpClient.java | 18 +- .../internal/httpclient/StreamingRequest.java | 186 ++++++++++++------ .../httpclient/StreamingResponse.java | 31 +-- ...tpClientSingleHostStreamingIntegTests.java | 20 +- 4 files changed, 157 insertions(+), 98 deletions(-) diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java index 4c4967d50dc76..f64f97dcf5579 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java @@ -828,28 +828,28 @@ private class InternalStreamingRequest { private volatile Cancellable cancellable = Cancellable.fromFuture(new CompletableFuture<>()); InternalStreamingRequest(StreamingRequest request) { - Map params = new HashMap<>(request.getParameters()); + Map params = new HashMap<>(request.parameters()); // ignore is a special parameter supported by the clients, shouldn't be sent to es String ignoreString = params.remove("ignore"); - this.ignoreErrorCodes = getIgnoreErrorCodes(ignoreString, request.getMethod()); + this.ignoreErrorCodes = getIgnoreErrorCodes(ignoreString, request.method()); this.httpRequest = node -> { - URI uri = buildUri(pathPrefix, request.getEndpoint(), params); + URI uri = buildUri(pathPrefix, request.endpoint(), params); HttpRequest.Builder builder = createStreamingHttpRequest( node, - request.getMethod(), + request.method(), uri, - request.getBody(), - request.getOptions().getTimeout(), + request.body(), + request.options().getTimeout(), compressionEnabled ); - setHeaders(builder, request.getOptions().getHeaders()); + setHeaders(builder, request.options().getHeaders()); return builder.build(); }; - this.warningsHandler = request.getOptions().getWarningsHandler() == null + this.warningsHandler = request.options().getWarningsHandler() == null ? RestHttpClient.this.warningsHandler - : request.getOptions().getWarningsHandler(); + : request.options().getWarningsHandler(); } private void setHeaders(HttpRequest.Builder httpRequest, Map> requestHeaders) { diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingRequest.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingRequest.java index a65e72c30dbdd..69c943a6123f3 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingRequest.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingRequest.java @@ -21,12 +21,11 @@ * HTTP Streaming Request to OpenSearch. * Note: This is an experimental API. */ -public class StreamingRequest { +public final class StreamingRequest { private final String method; private final String endpoint; private final Map parameters = new HashMap<>(); - - private RequestOptions options = RequestOptions.DEFAULT; + private final RequestOptions options; private final Publisher publisher; /** @@ -35,17 +34,56 @@ public class StreamingRequest { * @param endpoint endpoint * @param publisher publisher */ - public StreamingRequest(String method, String endpoint, Publisher publisher) { + private StreamingRequest(String method, String endpoint, Publisher publisher) { + this(method, endpoint, Map.of(), publisher, RequestOptions.DEFAULT); + } + + /** + * Constructor + * @param method method + * @param endpoint endpoint + * @param builder request options builder + * @param publisher publisher + */ + private StreamingRequest( + String method, + String endpoint, + Map parameters, + Publisher publisher, + RequestOptions.Builder builder + ) { + this(method, endpoint, parameters, publisher, builder.build()); + } + + /** + * Constructor + * @param method method + * @param endpoint endpoint + * @param options request options + * @param publisher publisher + */ + private StreamingRequest( + String method, + String endpoint, + Map parameters, + Publisher publisher, + RequestOptions options + ) { this.method = method; this.endpoint = endpoint; this.publisher = publisher; + this.options = options; + } + + public static StreamingRequest.Builder newRequest(String method, String endpoint, Publisher publisher) { + return new StreamingRequest.Builder(method, endpoint, publisher); } /** * Get endpoint * @return endpoint */ - public String getEndpoint() { + public String endpoint() { return endpoint; } @@ -53,7 +91,7 @@ public String getEndpoint() { * Get method * @return method */ - public String getMethod() { + public String method() { return method; } @@ -61,7 +99,7 @@ public String getMethod() { * Get options * @return options */ - public RequestOptions getOptions() { + public RequestOptions options() { return options; } @@ -69,7 +107,7 @@ public RequestOptions getOptions() { * Get parameters * @return parameters */ - public Map getParameters() { + public Map parameters() { if (options.getParameters().isEmpty()) { return unmodifiableMap(parameters); } else { @@ -80,61 +118,95 @@ public Map getParameters() { } /** - * Set the portion of an HTTP request to OpenSearch that can be - * manipulated without changing OpenSearch's behavior. - * - * @param options the options to be set. - * @throws NullPointerException if {@code options} is null. + * Body publisher + * @return body publisher */ - public void setOptions(RequestOptions options) { - Objects.requireNonNull(options, "options cannot be null"); - this.options = options; + public Publisher body() { + return publisher; } - /** - * Set the portion of an HTTP request to OpenSearch that can be - * manipulated without changing OpenSearch's behavior. - * - * @param options the options to be set. - * @throws NullPointerException if {@code options} is null. - */ - public void setOptions(RequestOptions.Builder options) { - Objects.requireNonNull(options, "options cannot be null"); - this.options = options.build(); - } + public static final class Builder { + private final String method; + private final String endpoint; + private final Map parameters = new HashMap<>(); + private final Publisher publisher; + private RequestOptions options = RequestOptions.DEFAULT; + + private Builder(String method, String endpoint, Publisher publisher) { + this.method = Objects.requireNonNull(method, "method cannot be null"); + this.endpoint = Objects.requireNonNull(endpoint, "endpoint cannot be null"); + this.publisher = Objects.requireNonNull(publisher, "publisher cannot be null"); + } - /** - * Add a query string parameter. - * @param name the name of the url parameter. Must not be null. - * @param value the value of the url url parameter. If {@code null} then - * the parameter is sent as {@code name} rather than {@code name=value} - * @throws IllegalArgumentException if a parameter with that name has - * already been set - */ - public void addParameter(String name, String value) { - Objects.requireNonNull(name, "url parameter name cannot be null"); - if (parameters.containsKey(name)) { - throw new IllegalArgumentException("url parameter [" + name + "] has already been set to [" + parameters.get(name) + "]"); - } else { - parameters.put(name, value); + /** + * Set the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * + * @param options the options to be set. + * @throws NullPointerException if {@code options} is null. + */ + public void setOptions(RequestOptions options) { + Objects.requireNonNull(options, "options cannot be null"); + this.options = options; } - } - /** - * Add query parameters using the provided map of key value pairs. - * - * @param paramSource a map of key value pairs where the key is the url parameter. - * @throws IllegalArgumentException if a parameter with that name has already been set. - */ - public void addParameters(Map paramSource) { - paramSource.forEach(this::addParameter); - } + /** + * Add a query string parameter. + * @param name the name of the url parameter. Must not be null. + * @param value the value of the url url parameter. If {@code null} then + * the parameter is sent as {@code name} rather than {@code name=value} + * @throws IllegalArgumentException if a parameter with that name has + * already been set + */ + public Builder withParameter(String name, String value) { + Objects.requireNonNull(name, "url parameter name cannot be null"); + if (parameters.containsKey(name)) { + throw new IllegalArgumentException("url parameter [" + name + "] has already been set to [" + parameters.get(name) + "]"); + } else { + parameters.put(name, value); + } + return this; + } - /** - * Body publisher - * @return body publisher - */ - public Publisher getBody() { - return publisher; + /** + * Add query parameters using the provided map of key value pairs. + * + * @param paramSource a map of key value pairs where the key is the url parameter. + * @throws IllegalArgumentException if a parameter with that name has already been set. + */ + public Builder withParameters(Map paramSource) { + paramSource.forEach(this::withParameter); + return this; + } + + /** + * Set the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * + * @param options the options to be set. + * @throws NullPointerException if {@code options} is null. + */ + public Builder withOptions(RequestOptions.Builder options) { + Objects.requireNonNull(options, "options cannot be null"); + this.options = options.build(); + return this; + } + + /** + * Set the portion of an HTTP request to OpenSearch that can be + * manipulated without changing OpenSearch's behavior. + * + * @param options the options to be set. + * @throws NullPointerException if {@code options} is null. + */ + public Builder withOptions(RequestOptions options) { + Objects.requireNonNull(options, "options cannot be null"); + this.options = options; + return this; + } + + public StreamingRequest build() { + return new StreamingRequest(method, endpoint, parameters, publisher, options); + } } } diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java index f26c142c2db15..629adf2a4b532 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/StreamingResponse.java @@ -22,10 +22,9 @@ * HTTP Streaming Response from OpenSearch. * Note: This is an experimental API. */ -public class StreamingResponse { +public final class StreamingResponse { private final RequestLine requestLine; private final Mono>>> publisher; - private volatile HttpHost host; /** * Constructor @@ -39,35 +38,19 @@ public StreamingResponse(RequestLine requestLine, Publisher getBody() { + public Publisher body() { return publisher.flatMapMany(m -> { final boolean compressed = m.headers() .firstValue("Content-Encoding") @@ -88,7 +71,7 @@ public Publisher getBody() { * Returns the status line of the current response */ @SuppressWarnings("unchecked") - public StatusLine getStatusLine() { + public StatusLine statusLine() { return new StatusLine( publisher.onErrorResume( ResponseException.class, @@ -101,7 +84,7 @@ public StatusLine getStatusLine() { * Returns a list of all warning headers returned in the response. */ @SuppressWarnings("unchecked") - public List getWarnings() { + public List warnings() { return ResponseWarningsExtractor.getWarnings( publisher.onErrorResume( ResponseException.class, @@ -114,7 +97,7 @@ public List getWarnings() { * Returns a list of all headers returned in the response. */ @SuppressWarnings("unchecked") - public HttpHeaders getHeaders() { + public HttpHeaders headers() { return publisher.onErrorResume( ResponseException.class, e -> Mono.just((HttpResponse>>) e.getResponse().httpResponse()) @@ -129,7 +112,7 @@ public HttpHeaders getHeaders() { * @param name header name */ @SuppressWarnings("unchecked") - public String getHeader(String name) { + public String header(String name) { return publisher.onErrorResume( ResponseException.class, e -> Mono.just((HttpResponse>>) e.getResponse().httpResponse()) diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java index ab71cf191d18a..6439826eac37b 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostStreamingIntegTests.java @@ -150,25 +150,29 @@ private StreamingResponse bodyTest(final String method) throws Exception { private StreamingResponse bodyTest(RestHttpClient restClient, String method, int statusCode, Map> headers) throws Exception { String requestBody = "{ \"field\": \"value\" }"; - StreamingRequest request = new StreamingRequest(method, "/" + statusCode, Mono.just(StandardCharsets.UTF_8.encode(requestBody))); - RequestOptions.Builder options = request.getOptions().toBuilder(); + RequestOptions.Builder options = RequestOptions.DEFAULT.toBuilder(); for (Map.Entry> header : headers.entrySet()) { header.getValue().forEach(v -> options.addHeader(header.getKey(), v)); } - request.setOptions(options); + + final StreamingRequest request = StreamingRequest.newRequest( + method, + "/" + statusCode, + Mono.just(StandardCharsets.UTF_8.encode(requestBody)) + ).withOptions(options).build(); StreamingResponse esResponse = restClient.streamRequest(request); - assertEquals(method, esResponse.getRequestLine().method()); - assertEquals(statusCode, esResponse.getStatusLine().statusCode()); - assertEquals(pathPrefix + "/" + statusCode, esResponse.getRequestLine().uri()); + assertEquals(method, esResponse.requestLine().method()); + assertEquals(statusCode, esResponse.statusLine().statusCode()); + assertEquals(pathPrefix + "/" + statusCode, esResponse.requestLine().uri()); if (statusCode >= 200 && statusCode < 400) { - StepVerifier.create(Flux.from(esResponse.getBody()).map(StandardCharsets.UTF_8::decode).map(CharBuffer::toString)) + StepVerifier.create(Flux.from(esResponse.body()).map(StandardCharsets.UTF_8::decode).map(CharBuffer::toString)) .expectNextMatches(s -> s.equals(requestBody)) .expectComplete() .verify(Duration.ofSeconds(5)); } else { - StepVerifier.create(Flux.from(esResponse.getBody())).expectError(ResponseException.class).verify(Duration.ofSeconds(5)); + StepVerifier.create(Flux.from(esResponse.body())).expectError(ResponseException.class).verify(Duration.ofSeconds(5)); } return esResponse; From 1cd960255b1641f711a041c8553e131e2f0509a1 Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Wed, 10 Jun 2026 11:44:08 -0400 Subject: [PATCH 8/9] Address code review comments Signed-off-by: Andriy Redko --- .../internal/httpclient/RestHttpClient.java | 12 +++++++----- .../RestHttpClientSingleHostIntegTests.java | 10 ++++++++++ .../client/RestClientSingleHostIntegTests.java | 12 ++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java index f64f97dcf5579..446b537d68826 100644 --- a/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java +++ b/client/rest-http-client/src/main/java/org/opensearch/internal/httpclient/RestHttpClient.java @@ -695,11 +695,13 @@ static URI buildUri(String pathPrefix, String path, Map params) fullPath = path; } - final String additionalQuery = params.entrySet() - .stream() - .filter(e -> e.getValue() != null) - .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) - .collect(Collectors.joining("&")); + final String additionalQuery = params.entrySet().stream().map(e -> { + if (e.getValue() == null) { + return e.getKey(); + } else { + return e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8); + } + }).collect(Collectors.joining("&")); final URI uri = URI.create(fullPath); String newQuery = uri.getQuery(); diff --git a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java index 57a05c5a7f25b..b76081bd684e4 100644 --- a/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java +++ b/client/rest-http-client/src/test/java/org/opensearch/internal/httpclient/RestHttpClientSingleHostIntegTests.java @@ -368,6 +368,16 @@ public void testEncodeParams() throws Exception { Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(pathPrefix + "/200?routing=foo%5Ebar", response.requestLine().uri()); } + { + Request request = Request.newRequest("PUT", "/200").withParameter("pretty", null).build(); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?pretty", response.requestLine().uri()); + } + { + Request request = Request.newRequest("PUT", "/200").withParameter("pretty", "").build(); + Response response = RestHttpClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?pretty=", response.requestLine().uri()); + } } /** diff --git a/client/rest/src/test/java/org/opensearch/client/RestClientSingleHostIntegTests.java b/client/rest/src/test/java/org/opensearch/client/RestClientSingleHostIntegTests.java index fc73e03201e84..92b7e740da0e1 100644 --- a/client/rest/src/test/java/org/opensearch/client/RestClientSingleHostIntegTests.java +++ b/client/rest/src/test/java/org/opensearch/client/RestClientSingleHostIntegTests.java @@ -483,6 +483,18 @@ public void testEncodeParams() throws Exception { Response response = RestClientSingleHostTests.performRequestSyncOrAsync(restClient, request); assertEquals(pathPrefix + "/200?routing=foo%5Ebar", response.getRequestLine().getUri()); } + { + Request request = new Request("PUT", "/200"); + request.addParameter("pretty", null); + Response response = RestClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?pretty", response.getRequestLine().getUri()); + } + { + Request request = new Request("PUT", "/200"); + request.addParameter("pretty", ""); + Response response = RestClientSingleHostTests.performRequestSyncOrAsync(restClient, request); + assertEquals(pathPrefix + "/200?pretty=", response.getRequestLine().getUri()); + } } /** From 1fd8358d528e6d031ed0ed212ad61c13645a09be Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Wed, 10 Jun 2026 12:48:31 -0400 Subject: [PATCH 9/9] Add missed SHAs Signed-off-by: Andriy Redko --- client/rest-http-client/licenses/reactor-core-3.8.5.jar.sha1 | 1 - client/rest-http-client/licenses/reactor-core-3.8.6.jar.sha1 | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 client/rest-http-client/licenses/reactor-core-3.8.5.jar.sha1 create mode 100644 client/rest-http-client/licenses/reactor-core-3.8.6.jar.sha1 diff --git a/client/rest-http-client/licenses/reactor-core-3.8.5.jar.sha1 b/client/rest-http-client/licenses/reactor-core-3.8.5.jar.sha1 deleted file mode 100644 index 19e8d37abd9d7..0000000000000 --- a/client/rest-http-client/licenses/reactor-core-3.8.5.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -e3afac59d35d67364b64389e2ed7cd274829df71 \ No newline at end of file diff --git a/client/rest-http-client/licenses/reactor-core-3.8.6.jar.sha1 b/client/rest-http-client/licenses/reactor-core-3.8.6.jar.sha1 new file mode 100644 index 0000000000000..6cdefa9580bdb --- /dev/null +++ b/client/rest-http-client/licenses/reactor-core-3.8.6.jar.sha1 @@ -0,0 +1 @@ +76285d63d5da4ed8679357628dd5309b0feb77ff \ No newline at end of file