diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4901a8c..c2641db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,28 +1,33 @@ -# This workflow will build a Java project with Maven -# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven - name: build - on: push: - branches: [ master, /\d\.0\.0-RC/ ] + branches: + - master + - /\d\.0\.0-RC/ pull_request: - branches: [ master, /\d\.0\.0-RC/ ] - + branches: + - master + - /\d\.0\.0-RC/ jobs: - build-test-coverage: - + builds-tests-coverage: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'adopt' - - uses: actions/cache@v4 - with: - path: ~/.m2 - key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} - restore-keys: ${{ runner.os }}-m2 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'adopt' + - uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + - name: Run build steps and generate coverage report + run: | + mvn verify javadoc:javadoc jacoco:report -Pcoverage -B -V + - name: Upload coverage report to Codecov + uses: codecov/codecov-action@v5 + with: + file: ./**/target/site/jacoco/jacoco.xml + name: codecov + fail_ci_if_error: false \ No newline at end of file diff --git a/NOTICE.txt b/NOTICE.txt index 17f67e0..d9aed60 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -8,41 +8,42 @@ This project includes a number of subcomponents with separate copyright notices ================================================================================ DEPENDENCIES -- Apache Commons Lang (3.12.0) [Apache-2.0] -- Apache Commons Text (1.10.0) [Apache-2.0] -- Apache HttpClient 5 (5.5) [Apache-2.0] -- Apache HttpCore 5 (5.3.4) [Apache-2.0] -- Apache HttpCore 5 H2 (5.3.4) [Apache-2.0] -- Byte Buddy (without dependencies) (1.14.4) [Apache-2.0] -- Byte Buddy agent (1.14.4) [Apache-2.0] +- Apache Commons Lang (3.20.0) [Apache-2.0] +- Apache Commons Text (1.14.0) [Apache-2.0] +- Byte Buddy (without dependencies) (1.11.13) [BSD-3-Clause, Apache-2.0] +- Byte Buddy agent (1.11.13) [Apache-2.0] - commons-collections (3.2.2) [Apache-2.0] -- commons-io (2.11.0) [Apache-2.0] -- commons-lang (2.6) [Apache-2.0] -- Hamcrest (2.2) [BSD-3-Clause] -- Jackson-annotations (2.14.2) [Apache-2.0] -- Jackson-core (2.14.2) [Apache-2.0] -- jackson-databind (2.14.2) [Apache-2.0] +- commons-io (2.21.0) [Apache-2.0] +- Hamcrest (3.0) [BSD-3-Clause] +- HttpClient5 (5.5.1) [Apache-2.0] +- HttpCore5 (5.3.6) [Apache-2.0] +- Jackson-annotations (2.20) [Apache-2.0] +- Jackson-core (3.0.2) [Apache-2.0] +- jackson-databind (3.0.2) [Apache-2.0] +- jadler-all (1.3.1) [MIT] - jadler-core (1.3.1) [MIT] - jadler-jetty (1.3.1) [MIT] +- jadler-junit (1.3.1) [MIT] - Jetty :: Continuation (8.1.11.v20130520) [Multi-license: EPL-1.0 OR Apache-2.0] - Jetty :: Http Utility (8.1.11.v20130520) [Multi-license: EPL-1.0 OR Apache-2.0] - Jetty :: IO Utility (8.1.11.v20130520) [Multi-license: EPL-1.0 OR Apache-2.0] - Jetty :: Server Core (8.1.11.v20130520) [Multi-license: EPL-1.0 OR Apache-2.0] - Jetty :: Utilities (8.1.11.v20130520) [Multi-license: EPL-1.0 OR Apache-2.0, MIT] - Jetty Orbit :: Servlet API (3.0.0.v201112011016) [Multi-license: CDDL-1.0 OR GPL-2.0-with-classpath-exception, EPL-1.0, Apache-2.0] -- JUnit Jupiter API (5.10.2) [EPL-2.0] -- JUnit Jupiter Engine (5.10.2) [EPL-2.0] -- Mockito Core (5.3.1) [MIT] -- Objenesis (3.3) [Apache-2.0] -- SLF4J API Module (2.0.7) [MIT] -- SLF4J Simple Binding (2.0.7) [MIT] +- JUnit (4.13.2) [CPL-1.0] +- Lang (2.6) [Apache-2.0] +- Mockito (3.12.4) [MIT, Apache-2.0] +- Objenesis (3.2) [MIT] +- reload4j (1.2.22) [Apache-2.0] +- SLF4J API Module (2.0.17) [MIT] +- SLF4J reload4j Binding (2.0.17) [MIT] APPENDIX: LICENSES Apache-2.0 BSD-3-Clause CDDL-1.0 +CPL-1.0 EPL-1.0 -EPL-2.0 GPL-2.0-with-classpath-exception MIT [End of Table of Contents] @@ -54,11 +55,14 @@ DEPENDENCIES -------------------------------------------------------------------------------- Apache Commons Codec (1.11) -------------------------------------------------------------------------------- + * Declared Licenses * + * Other Licenses * Apache-2.0, BSD-3-Clause + Copyright 2022, Apache Commons Codec Contributors Licensed under the Apache License, Version 2.0 (the "License"); @@ -73,6 +77,7 @@ 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. + * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. @@ -91,18 +96,28 @@ See the License for the specific language governing permissions and limitations /* * Some portions of this file Copyright (c) 2004-2006 Intel Corportation * and licensed under the BSD license. + + -------------------------------------------------------------------------------- Apache Commons Lang (3.12.0) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + + + + -------------------------------------------------------------------------------- Apache Log4j (1.2.17) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + Apache log4j Copyright 2007 The Apache Software Foundation + This product includes software developed at The Apache Software Foundation (http://www.apache.org/). ------------ @@ -120,12 +135,19 @@ The Apache Software Foundation (http://www.apache.org/). * 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. + + -------------------------------------------------------------------------------- Byte Buddy (without dependencies) (1.11.13) -------------------------------------------------------------------------------- + * Declared Licenses * + + * Other Licenses * BSD-3-Clause, Apache-2.0 + + // ASM: a very small and fast Java bytecode manipulation framework // Copyright (c) 2000-2011 INRIA, France Telecom // All rights reserved. @@ -154,6 +176,7 @@ BSD-3-Clause, Apache-2.0 // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF // THE POSSIBILITY OF SUCH DAMAGE. + * Copyright 2014 - Present Rafael Winterhalter * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -172,26 +195,44 @@ BSD-3-Clause, Apache-2.0 -------------------------------------------------------------------------------- Byte Buddy agent (1.11.13) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + + -------------------------------------------------------------------------------- Commons Logging (1.2) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + + + -------------------------------------------------------------------------------- commons-collections (3.2.2) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + + -------------------------------------------------------------------------------- commons-io (2.11.0) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + + + + + + -------------------------------------------------------------------------------- Hamcrest (2.2) -------------------------------------------------------------------------------- + * Declared Licenses * BSD-3-Clause @@ -221,9 +262,12 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + -------------------------------------------------------------------------------- Hamcrest Core (1.3) -------------------------------------------------------------------------------- + * Declared Licenses * BSD-3-Clause @@ -258,8 +302,10 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- Hamcrest library (1.3) -------------------------------------------------------------------------------- + * Declared Licenses * BSD-3-clause + BSD License Copyright (c) 2000-2015 www.hamcrest.org @@ -291,31 +337,56 @@ DAMAGE. -------------------------------------------------------------------------------- HttpClient (4.5.13) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + + + + -------------------------------------------------------------------------------- HttpCore (4.4.14) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + + + + -------------------------------------------------------------------------------- Jackson-annotations (2.13.0) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + + + + -------------------------------------------------------------------------------- Jackson-core (2.13.0) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + + + + -------------------------------------------------------------------------------- jackson-databind (2.13.0) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 + + + -------------------------------------------------------------------------------- jadler-all (1.3.0) -------------------------------------------------------------------------------- + * Declared Licenses * MIT @@ -338,9 +409,11 @@ 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. + -------------------------------------------------------------------------------- jadler-core (1.3.0) -------------------------------------------------------------------------------- + * Declared Licenses * MIT @@ -362,9 +435,12 @@ 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. + + -------------------------------------------------------------------------------- jadler-jetty (1.3.0) -------------------------------------------------------------------------------- + * Declared Licenses * MIT @@ -386,9 +462,12 @@ 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. + + -------------------------------------------------------------------------------- jadler-junit (1.3.0) -------------------------------------------------------------------------------- + * Declared Licenses * MIT @@ -410,13 +489,19 @@ 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. + + -------------------------------------------------------------------------------- Jetty :: Continuation (8.1.11.v20130520) -------------------------------------------------------------------------------- -* Declared Licenses + +* Declared Licenses * + + * Other Licenses * Multi-license: EPL-1.0 OR Apache-2.0 + Copyright (c) 1995-2013 Mort Bay Consulting Pty. Ltd. // ------------------------------------------------------------------------ // All rights reserved. This program and the accompanying materials @@ -430,13 +515,19 @@ Multi-license: EPL-1.0 OR Apache-2.0 // http://www.opensource.org/licenses/apache2.0.php // // You may elect to redistribute this code under either of these licenses. + + -------------------------------------------------------------------------------- Jetty :: Http Utility (8.1.11.v20130520) -------------------------------------------------------------------------------- + * Declared Licenses * + + * Other Licenses * Multi-license: EPL-1.0 OR Apache-2.0 + // Copyright (c) 1995-2013 Mort Bay Consulting Pty. Ltd. // ------------------------------------------------------------------------ // All rights reserved. This program and the accompanying materials @@ -450,13 +541,19 @@ Multi-license: EPL-1.0 OR Apache-2.0 // http://www.opensource.org/licenses/apache2.0.php // // You may elect to redistribute this code under either of these licenses. + + -------------------------------------------------------------------------------- Jetty :: IO Utility (8.1.11.v20130520) -------------------------------------------------------------------------------- + * Declared Licenses * + + * Other Licenses * Multi-license: EPL-1.0 OR Apache-2.0 + // Copyright (c) 1995-2013 Mort Bay Consulting Pty. Ltd. // ------------------------------------------------------------------------ // All rights reserved. This program and the accompanying materials @@ -475,10 +572,14 @@ Multi-license: EPL-1.0 OR Apache-2.0 -------------------------------------------------------------------------------- Jetty :: Server Core (8.1.11.v20130520) -------------------------------------------------------------------------------- + * Declared Licenses * + + * Other Licenses * Multi-license: EPL-1.0 OR Apache-2.0 + // Copyright (c) 1995-2013 Mort Bay Consulting Pty. Ltd. // ------------------------------------------------------------------------ // All rights reserved. This program and the accompanying materials @@ -492,13 +593,19 @@ Multi-license: EPL-1.0 OR Apache-2.0 // http://www.opensource.org/licenses/apache2.0.php // // You may elect to redistribute this code under either of these licenses. + + -------------------------------------------------------------------------------- Jetty :: Utilities (8.1.11.v20130520) -------------------------------------------------------------------------------- + * Declared Licenses * + + * Other Licenses * Multi-license: EPL-1.0 OR Apache-2.0, MIT + // Copyright (c) 1995-2013 Mort Bay Consulting Pty. Ltd. // ------------------------------------------------------------------------ // All rights reserved. This program and the accompanying materials @@ -513,6 +620,7 @@ Multi-license: EPL-1.0 OR Apache-2.0, MIT // // You may elect to redistribute this code under either of these licenses. + * License information for Bjoern Hoehrmann's code: * * Copyright (c) 2008-2009 Bjoern Hoehrmann @@ -526,13 +634,20 @@ Multi-license: EPL-1.0 OR Apache-2.0, MIT * 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. **/ + + -------------------------------------------------------------------------------- Jetty Orbit :: Servlet API (3.0.0.v201112011016) -------------------------------------------------------------------------------- + * Declared Licenses * + + * Other Licenses * Multi-license: CDDL-1.0 OR GPL-2.0-with-classpath-exception, EPL-1.0, Apache-2.0 + + DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. Copyright (c) 1997-2010 Oracle and/or its affiliates. All rights reserved. @@ -589,6 +704,8 @@ 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. + + >The Eclipse Foundation makes available all content in this plug-in ("Content"). Unless otherwise indicated below, the Content is provided to you under the terms and conditions of the Eclipse Public License Version 1.0 ("EPL"). A copy of the EPL is available @@ -634,14 +751,18 @@ 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. + -------------------------------------------------------------------------------- JUnit (4.13.2) -------------------------------------------------------------------------------- + * Declared Licenses * CPL-1.0 + -------------------------------------------------------------------------------- Lang (2.6) -------------------------------------------------------------------------------- + * Declared Licenses * Apache-2.0 @@ -660,11 +781,18 @@ Licensed to the Apache Software Foundation (ASF) under one or more * See the License for the specific language governing permissions and * limitations under the License. + -------------------------------------------------------------------------------- -Mockito (3.12.4)-------------------------------------------------------------------------------- +Mockito (3.12.4) +-------------------------------------------------------------------------------- + * Declared Licenses * + + * Other Licenses * MIT, Apache-2.0 + + The MIT License Copyright (c) 2007 Mockito contributors @@ -701,9 +829,12 @@ 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. + + -------------------------------------------------------------------------------- Objenesis (3.2) -------------------------------------------------------------------------------- + * Declared Licenses * MIT @@ -725,9 +856,12 @@ 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. + + -------------------------------------------------------------------------------- SLF4J API Module (1.7.32) -------------------------------------------------------------------------------- + * Declared Licenses * MIT @@ -757,6 +891,7 @@ Copyright (c) 2004-2017 QOS.ch -------------------------------------------------------------------------------- SLF4J LOG4J-12 Binding relocated (1.7.32) -------------------------------------------------------------------------------- + * Declared Licenses * MIT @@ -778,9 +913,13 @@ 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. + + + ================================================================================ APPENDIX: LICENSES ================================================================================ + * Apache-2.0 * Apache License Version 2.0, January 2004 diff --git a/README.md b/README.md index 8f298a5..cb2d7a1 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,20 @@ # GoodData HTTP Client [![Build Status](https://github.com/gooddata/gooddata-http-client/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/gooddata/gooddata-http-client/actions/workflows/build.yml) [![Javadocs](http://javadoc.io/badge/com.gooddata/gooddata-http-client.svg)](http://javadoc.io/doc/com.gooddata/gooddata-http-client) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.gooddata/gooddata-http-client/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.gooddata/gooddata-http-client) [![Release](https://img.shields.io/github/v/release/gooddata/gooddata-http-client.svg)](https://search.maven.org/artifact/com.gooddata/gooddata-http-client) -GoodData HTTP Client is an extension of [Apache HTTP Client 5.x](https://hc.apache.org/httpcomponents-client-5.3.x/index.html). +GoodData HTTP Client is an extension of [Apache HTTP Client 5](https://hc.apache.org/httpcomponents-client-5.x/index.html). This specialized Java client transparently handles [GoodData authentication](https://help.gooddata.com/display/doc/API+Reference#/reference/authentication/log-in) so you can focus on writing logic on top of [GoodData API](https://help.gooddata.com/display/doc/API+Reference). -## ⚠️ Version 2.0+ Breaking Changes - -**Version 2.0.0** introduces a major update migrating from Apache HttpClient 4.x to 5.x. See the [Migration Guide](#migration-guide) below for upgrade instructions. - -## Requirements - -- **Java 17+** (updated from Java 8) -- **Apache HttpClient 5.5+** (migrated from 4.x) -- **Maven 3.6+** (for building) +**Version 3.0+ Migration Notice**: Starting from version 3.0.0, this library has been migrated from Apache HttpClient 4 to HttpClient 5. +This is a **breaking change** that requires code updates. See [Migration Guide](#migration) for details. ## Design -`com.gooddata.http.client.GoodDataHttpClient` is a thread-safe HTTP client that wraps Apache HttpClient 5.x and provides transparent GoodData authentication handling. The client automatically manages SST (Super Secure Token) and TT (Temporary Token) lifecycle, including: - -- Automatic token refresh on expiration -- Retry logic for authentication failures -- Thread-safe token management -- Support for all HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) - -Business logic should use the `GoodDataHttpClient` class directly, which handles all authentication concerns internally. +```com.gooddata.http.client.GoodDataHttpClient``` central class implements [org.apache.hc.client5.http.classic.HttpClient interface](https://hc.apache.org/httpcomponents-client-5.x/current/httpclient5/apidocs/org/apache/hc/client5/http/classic/HttpClient.html) +It allows seamless switch for existing code base currently using ```org.apache.hc.client5.http.classic.HttpClient```. Business logic encapsulating +access to [GoodData API](https://help.gooddata.com/display/doc/API+Reference) should use ```org.apache.hc.client5.http.classic.HttpClient``` interface +and keep ```com.gooddata.http.client.GoodDataHttpClient``` inside a factory class. ```com.gooddata.http.client.GoodDataHttpClient``` uses underlying ```org.apache.hc.client5.http.classic.HttpClient```. A HTTP client +instance can be passed via the constructor. ## Usage @@ -45,26 +35,25 @@ If your project is managed by Maven you can add GoodData HTTP client as a new de ### Authentication using credentials -```java +```Java import com.gooddata.http.client.*; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.core5.http.*; -import org.apache.hc.core5.http.io.entity.EntityUtils; +import java.io.IOException; +import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.EntityUtils; HttpHost hostGoodData = new HttpHost("https", "secure.gooddata.com", 443); -// Create login strategy, which will obtain SST via credentials +// create login strategy, which will obtain SST via credentials SSTRetrievalStrategy sstStrategy = new LoginSSTRetrievalStrategy(login, password); -// Create GoodData HTTP client -GoodDataHttpClient client = new GoodDataHttpClient( - HttpClients.createDefault(), - hostGoodData, - sstStrategy -); +HttpClient client = new GoodDataHttpClient(HttpClientBuilder.create().build(), hostGoodData, sstStrategy); -// Use HTTP client with transparent GoodData authentication +// use HTTP client with transparent GoodData authentication HttpGet getProject = new HttpGet("/gdc/projects"); getProject.addHeader("Accept", ContentType.APPLICATION_JSON.getMimeType()); ClassicHttpResponse getProjectResponse = client.execute(hostGoodData, getProject); @@ -74,26 +63,29 @@ System.out.println(EntityUtils.toString(getProjectResponse.getEntity())); ### Authentication using super-secure token (SST) -```java +```Java import com.gooddata.http.client.*; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.core5.http.*; -import org.apache.hc.core5.http.io.entity.EntityUtils; +import java.io.IOException; +import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.EntityUtils; + +// create HTTP client +HttpClient httpClient = HttpClientBuilder.create().build(); HttpHost hostGoodData = new HttpHost("https", "secure.gooddata.com", 443); -// Create login strategy (you must somehow obtain SST) +// create login strategy (you must somehow obtain SST) SSTRetrievalStrategy sstStrategy = new SimpleSSTRetrievalStrategy("my super-secure token"); -// Create GoodData HTTP client -GoodDataHttpClient client = new GoodDataHttpClient( - HttpClients.createDefault(), - hostGoodData, - sstStrategy -); +// wrap your HTTP client into GoodData HTTP client +HttpClient client = new GoodDataHttpClient(httpClient, hostGoodData, sstStrategy); -// Use GoodData HTTP client +// use GoodData HTTP client HttpGet getProject = new HttpGet("/gdc/projects"); getProject.addHeader("Accept", ContentType.APPLICATION_JSON.getMimeType()); ClassicHttpResponse getProjectResponse = client.execute(hostGoodData, getProject); @@ -101,133 +93,70 @@ ClassicHttpResponse getProjectResponse = client.execute(hostGoodData, getProject System.out.println(EntityUtils.toString(getProjectResponse.getEntity())); ``` -## Migration Guide - -### Migrating from 1.x to 2.0+ (Apache HttpClient 4.x to 5.x) +## Migration from HttpClient 4 to HttpClient 5 -Version 2.0.0 introduces breaking changes due to the Apache HttpClient 5.x migration. Follow these steps to upgrade: +**Version 3.0.0** introduces breaking changes due to the migration from Apache HttpClient 4 to HttpClient 5. -#### 1. Update Dependencies - -**Maven:** -```xml - - com.gooddata - gooddata-http-client - 2.0.0 - -``` - -#### 2. Update Java Version - -Ensure your project uses **Java 17 or higher**: -```xml -17 -17 -``` +### Key Changes -#### 3. Update Imports +#### Package Structure +- **HttpClient 4**: `org.apache.http.*` +- **HttpClient 5**: `org.apache.hc.client5.http.*` and `org.apache.hc.core5.http.*` -Replace Apache HttpClient 4.x imports with 5.x equivalents: +#### Interface Changes +- `HttpClient` → `org.apache.hc.client5.http.classic.HttpClient` +- `HttpResponse` → `org.apache.hc.core5.http.ClassicHttpResponse` +- `HttpRequest` → `org.apache.hc.core5.http.ClassicHttpRequest` -**Before (1.x):** +#### HttpHost Constructor ```java -import org.apache.http.HttpHost; -import org.apache.http.HttpResponse; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.util.EntityUtils; -``` +// HttpClient 4 +HttpHost host = new HttpHost("hostname", 443, "https"); -**After (2.0+):** -```java -import org.apache.hc.core5.http.HttpHost; -import org.apache.hc.core5.http.ClassicHttpResponse; -import org.apache.hc.client5.http.classic.HttpClient; -import org.apache.hc.client5.http.classic.methods.HttpGet; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.core5.http.io.entity.EntityUtils; +// HttpClient 5 +HttpHost host = new HttpHost("https", "hostname", 443); ``` -#### 4. Update HttpHost Construction - -**Before (1.x):** +#### Entity Creation ```java -HttpHost host = new HttpHost("secure.gooddata.com", 443, "https"); -``` +// HttpClient 4 +BasicHttpEntity entity = new BasicHttpEntity(); +entity.setContent(new ByteArrayInputStream(content)); -**After (2.0+):** -```java -HttpHost host = new HttpHost("https", "secure.gooddata.com", 443); -// Note: scheme is now the first parameter +// HttpClient 5 +StringEntity entity = new StringEntity(content, ContentType.TEXT_PLAIN); ``` -#### 5. Update Response Handling - -**Before (1.x):** +#### Response Status ```java -HttpResponse response = client.execute(host, request); -``` +// HttpClient 4 +int status = response.getStatusLine().getStatusCode(); -**After (2.0+):** -```java -ClassicHttpResponse response = client.execute(host, request); +// HttpClient 5 +int status = response.getCode(); ``` -#### 6. Update HttpClient Creation +### Required Dependencies -**Before (1.x):** -```java -HttpClient httpClient = HttpClientBuilder.create().build(); -``` +Update your `pom.xml`: -**After (2.0+):** -```java -HttpClient httpClient = HttpClients.createDefault(); -// Or with custom configuration: -HttpClient httpClient = HttpClients.custom() - .setDefaultRequestConfig(RequestConfig.custom() - .setConnectionRequestTimeout(Timeout.ofSeconds(30)) - .build()) - .build(); +```xml + + org.apache.httpcomponents.client5 + httpclient5 + 5.5.1 + + + org.apache.httpcomponents.core5 + httpcore5 + 5.3.6 + ``` -#### 7. Key Behavioral Changes - -- **Thread Safety**: All requests now use write locks for consistency. This may reduce throughput under high concurrency but ensures reliable token management. -- **Entity Handling**: Non-repeatable request entities are automatically buffered for retry scenarios. -- **Error Handling**: More specific exceptions for authentication failures. -- **HTTP Methods**: Full support for POST, PUT, PATCH in addition to GET and DELETE. - -#### 8. Testing Your Migration - -After updating your code: - -1. **Compile**: Ensure no compilation errors -2. **Test**: Run your existing test suite -3. **Integration Test**: Test against GoodData API with real credentials -4. **Monitor**: Watch for authentication issues or performance changes - -#### Common Migration Issues - -**Issue: NoClassDefFoundError** -- **Cause**: Conflicting HttpClient versions in classpath -- **Fix**: Use `mvn dependency:tree` to identify conflicts and exclude old HttpClient 4.x dependencies - -**Issue: Method not found errors** -- **Cause**: Using old HttpClient 4.x APIs -- **Fix**: Update all imports and method calls to HttpClient 5.x equivalents - -**Issue: Authentication failures** -- **Cause**: Token handling differences -- **Fix**: Ensure SST/TT tokens are being passed correctly; check logs for authentication errors - -### Need Help? +### Compatibility -- Review the [complete API documentation](http://javadoc.io/doc/com.gooddata/gooddata-http-client) -- Check [Apache HttpClient 5.x migration guide](https://hc.apache.org/httpcomponents-client-5.3.x/migration-guide/index.html) -- Report issues on [GitHub](https://github.com/gooddata/gooddata-http-client/issues) +- **Minimum Java Version**: Java 11+ +- **HttpClient 4 compatibility**: Not maintained. Use version 1.x for HttpClient 4 compatibility. ## Build diff --git a/pom.xml b/pom.xml index dd0b47d..b07aff0 100644 --- a/pom.xml +++ b/pom.xml @@ -1,15 +1,16 @@ - + 4.0.0 gooddata-http-client - 2.0.2-SNAPSHOT + 3.0.0-SNAPSHOT jar ${project.artifactId} HTTP client with ability to handle GoodData authentication @@ -26,13 +27,13 @@ Adam Stulpa adam.stulpa@gooddata.com GoodData Corporation - http://www.gooddata.com/ + https://www.gooddata.com/ Martin Caslavsky martin.caslavsky@gooddata.com GoodData Corporation - http://www.gooddata.com/ + https://www.gooddata.com/ @@ -40,132 +41,115 @@ scm:git:git@github.com:gooddata/gooddata-http-client.git scm:git:git@github.com:gooddata/gooddata-http-client.git https://github.com/gooddata/gooddata-http-client - HEAD - + HEAD + BSD License - http://opensource.org/licenses/BSD-3-Clause + https://opensource.org/licenses/BSD-3-Clause - org.apache.httpcomponents.client5 httpclient5 - 5.5 + 5.5.1 org.apache.httpcomponents.core5 httpcore5 - 5.3.4 - - - org.apache.httpcomponents.core5 - httpcore5-h2 - 5.3.4 - runtime + 5.3.6 org.apache.commons commons-text - 1.10.0 + 1.14.0 org.apache.commons commons-lang3 - 3.12.0 + 3.20.0 org.slf4j slf4j-api ${slf4j.version} + org.slf4j - slf4j-simple - 2.0.7 - test - - - org.junit.jupiter - junit-jupiter-api - 5.10.2 + slf4j-log4j12 + ${slf4j.version} test - org.junit.jupiter - junit-jupiter-engine - 5.10.2 + junit + junit + 4.13.2 test + + + org.hamcrest + hamcrest-core + + org.mockito mockito-core - 5.3.1 + 5.14.2 test commons-io commons-io - 2.11.0 + 2.21.0 test org.hamcrest hamcrest - 2.2 + 3.0 test net.jadler - jadler-core + jadler-all ${jadler.version} test - com.fasterxml.jackson.core - jackson-databind - 2.14.2 + net.jadler + jadler-core + ${jadler.version} test - net.jadler - jadler-jetty - ${jadler.version} + tools.jackson.core + jackson-databind + 3.0.2 test 1.3.1 - 2.0.7 - 17 - 17 - 17 - 2022 + 2.0.17 + 2025 maven-javadoc-plugin - 3.5.0 + 3.12.0 false - - org.apache.maven.plugins - maven-surefire-plugin - 3.2.5 - - false - - @@ -174,20 +158,10 @@ at - - maven-javadoc-plugin - 3.5.0 - - false - - - org.apache.maven.plugins maven-surefire-plugin - 3.2.5 - false - + true @@ -216,7 +190,7 @@ org.jacoco jacoco-maven-plugin - 0.8.9 + 0.8.14 jacoco-prepare-agent @@ -246,7 +220,7 @@ check - + diff --git a/src/main/java/com/gooddata/http/client/GoodDataAuthException.java b/src/main/java/com/gooddata/http/client/GoodDataAuthException.java index 2452ba2..6f211c2 100644 --- a/src/main/java/com/gooddata/http/client/GoodDataAuthException.java +++ b/src/main/java/com/gooddata/http/client/GoodDataAuthException.java @@ -1,15 +1,18 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; + /** * GoodData authentication exception. */ public class GoodDataAuthException extends RuntimeException { - public GoodDataAuthException() { } + public GoodDataAuthException() { + } + public GoodDataAuthException(String message) { super(message); } diff --git a/src/main/java/com/gooddata/http/client/GoodDataHttpClient.java b/src/main/java/com/gooddata/http/client/GoodDataHttpClient.java index 8857826..2d9ac52 100644 --- a/src/main/java/com/gooddata/http/client/GoodDataHttpClient.java +++ b/src/main/java/com/gooddata/http/client/GoodDataHttpClient.java @@ -1,79 +1,200 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; -import static com.gooddata.http.client.LoginSSTRetrievalStrategy.LOGIN_URL; -import static org.apache.commons.lang3.Validate.notNull; -import org.apache.hc.client5.http.classic.methods.HttpGet; + import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; -import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.http.io.HttpClientResponseHandler; -import org.apache.hc.core5.http.io.entity.ByteArrayEntity; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; import org.apache.hc.core5.http.protocol.HttpContext; -import org.apache.hc.core5.http.Header; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; +import static com.gooddata.http.client.LoginSSTRetrievalStrategy.LOGIN_URL; +import static java.util.Objects.requireNonNull; + /** - * Http client with ability to handle GoodData authentication. - * Fully migrated to Apache HttpClient 5.x "response handler" style. + *

Http client with ability to handle GoodData authentication.

+ * + *

Usage

+ * + *

Authentication using login

+ *
+ * import org.apache.hc.client5.http.classic.HttpClient;
+ * import org.apache.hc.client5.http.classic.methods.HttpGet;
+ * import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+ * import org.apache.hc.core5.http.ClassicHttpResponse;
+ * import org.apache.hc.core5.http.ContentType;
+ * import org.apache.hc.core5.http.HttpHost;
+ *
+ * // create HTTP client with your settings
+ * HttpClient httpClient = HttpClientBuilder.create().build();
+ *
+ * // create login strategy, which will obtain SST via login
+ * SSTRetrievalStrategy sstStrategy = new LoginSSTRetrievalStrategy("user@domain.com", "my secret");
+ *
+ * // create host (note: scheme, hostname, port order in HttpClient 5)
+ * HttpHost httpHost = new HttpHost("https", "server.com", 443);
+ *
+ * // wrap your HTTP client into GoodData HTTP client
+ * HttpClient client = new GoodDataHttpClient(httpClient, httpHost, sstStrategy);
+ *
+ * // use GoodData HTTP client
+ * HttpGet getProject = new HttpGet("/gdc/projects");
+ * getProject.addHeader("Accept", ContentType.APPLICATION_JSON.getMimeType());
+ * ClassicHttpResponse getProjectResponse = client.execute(httpHost, getProject);
+ * 
+ * + *

Authentication using super-secure token (SST)

+ * + *
+ * import org.apache.hc.client5.http.classic.HttpClient;
+ * import org.apache.hc.client5.http.classic.methods.HttpGet;
+ * import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+ * import org.apache.hc.core5.http.ClassicHttpResponse;
+ * import org.apache.hc.core5.http.ContentType;
+ * import org.apache.hc.core5.http.HttpHost;
+ *
+ * // create HTTP client
+ * HttpClient httpClient = HttpClientBuilder.create().build();
+ *
+ * // create host (note: scheme, hostname, port order in HttpClient 5)
+ * HttpHost httpHost = new HttpHost("https", "server.com", 443);
+ *
+ * // create login strategy (you must somehow obtain SST)
+ * SSTRetrievalStrategy sstStrategy = new SimpleSSTRetrievalStrategy("my super-secure token");
+ *
+ * // wrap your HTTP client into GoodData HTTP client
+ * HttpClient client = new GoodDataHttpClient(httpClient, httpHost, sstStrategy);
+ *
+ * // use GoodData HTTP client
+ * HttpGet getProject = new HttpGet("/gdc/projects");
+ * getProject.addHeader("Accept", ContentType.APPLICATION_JSON.getMimeType());
+ * ClassicHttpResponse getProjectResponse = client.execute(httpHost, getProject);
+ * 
*/ -public class GoodDataHttpClient { - private static final String TOKEN_URL = "/gdc/account/token"; +public class GoodDataHttpClient implements HttpClient { + public static final String COOKIE_GDC_AUTH_TT = "cookie=GDCAuthTT"; public static final String COOKIE_GDC_AUTH_SST = "cookie=GDCAuthSST"; - private volatile boolean tokenRefreshing = false; - private final Object tokenRefreshMonitor = new Object(); static final String TT_HEADER = "X-GDC-AuthTT"; static final String SST_HEADER = "X-GDC-AuthSST"; - private enum GoodDataChallengeType { - SST, TT, UNKNOWN - } - + private static final String TOKEN_URL = "/gdc/account/token"; private final Logger log = LoggerFactory.getLogger(getClass()); private final HttpClient httpClient; private final SSTRetrievalStrategy sstStrategy; + /** + * Host performing authentication - eg. issuing TT tokens + */ private final HttpHost authHost; + /** + * this lock is used to ensure that no threads will try to send requests while authentication is performed + */ private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); + /** + * This lock guards that only one thread enters the authentication (obtaining TT/SST) section. + * We need second lock we cannot call tryLock() on ReadWriteLock.writeLock as it returns false not only when another thread + * holds the write lock already (what we want here) but also when another thread holds a read lock (what we do NOT want) + */ + private final Lock authLock = new ReentrantLock(); + /** + * current SST (or null if not yet obtained) + */ private String sst; + /** + * TT to be set into the header (or null if not yet obtained) + */ private String tt; - // Constructors remain unchanged (just update parameter types to HttpClient 5.x classes if needed) + + /** + * Construct object. + * + * @param httpClient Http client + * @param sstStrategy super-secure token (SST) obtaining strategy + * @throws IllegalArgumentException if {@code sstStrategy} argument is not an instance of {@link LoginSSTRetrievalStrategy} + * @deprecated use {@link #GoodDataHttpClient(HttpClient, HttpHost, SSTRetrievalStrategy)} + */ + @Deprecated + public GoodDataHttpClient(final HttpClient httpClient, final SSTRetrievalStrategy sstStrategy) { + requireNonNull(httpClient); + this.httpClient = httpClient; + if (sstStrategy instanceof LoginSSTRetrievalStrategy) { + this.sstStrategy = sstStrategy; + this.authHost = ((LoginSSTRetrievalStrategy) sstStrategy).getHttpHost(); + requireNonNull(authHost, "HTTP host cannot be null"); + } else { + throw new IllegalArgumentException("This constructor is deprecated and works with LoginSSTRetrievalStrategy argument only!"); + } + } + + + /** + * Construct object. + * + * @param sstStrategy super-secure token (SST) obtaining strategy + * @deprecated use {@link #GoodDataHttpClient(HttpHost, SSTRetrievalStrategy)} + */ + @Deprecated + public GoodDataHttpClient(final SSTRetrievalStrategy sstStrategy) { + this(HttpClientBuilder.create().build(), sstStrategy); + } + + /** + * Construct object. + * + * @param httpClient Http client + * @param authHost http host + * @param sstStrategy super-secure token (SST) obtaining strategy + */ public GoodDataHttpClient(final HttpClient httpClient, final HttpHost authHost, final SSTRetrievalStrategy sstStrategy) { - notNull(httpClient); - notNull(authHost, "HTTP host cannot be null"); - notNull(sstStrategy); + requireNonNull(httpClient); + requireNonNull(authHost, "HTTP host cannot be null"); + requireNonNull(sstStrategy); this.httpClient = httpClient; this.authHost = authHost; this.sstStrategy = sstStrategy; } - - public GoodDataHttpClient(final HttpHost authHost, final SSTRetrievalStrategy sstStrategy) { - this(org.apache.hc.client5.http.impl.classic.HttpClients.createDefault(), authHost, sstStrategy); - } /** - * Identify the type of GoodData authentication challenge from the response. + * Construct object. + * + * @param authHost http host + * @param sstStrategy super-secure token (SST) obtaining strategy */ - private GoodDataChallengeType identifyGoodDataChallenge(final ClassicHttpResponse response) { + public GoodDataHttpClient(final HttpHost authHost, final SSTRetrievalStrategy sstStrategy) { + this(HttpClientBuilder.create().build(), authHost, sstStrategy); + } + + private GoodDataChallengeType identifyGoodDataChallenge(final HttpResponse response) { if (response.getCode() == HttpStatus.SC_UNAUTHORIZED) { - Header[] headers = response.getHeaders("WWW-Authenticate"); + final Header[] headers = response.getHeaders(HttpHeaders.WWW_AUTHENTICATE); if (headers != null) { - for (Header header : headers) { + for (final Header header : headers) { final String challenge = header.getValue(); if (challenge.contains(COOKIE_GDC_AUTH_SST)) { + // this is actually not used as in refreshTT() we rely on status code only return GoodDataChallengeType.SST; } else if (challenge.contains(COOKIE_GDC_AUTH_TT)) { return GoodDataChallengeType.TT; @@ -84,301 +205,181 @@ private GoodDataChallengeType identifyGoodDataChallenge(final ClassicHttpRespons return GoodDataChallengeType.UNKNOWN; } - /** - * Handles the authentication challenge and returns a refreshed response. - */ - private ClassicHttpResponse handleResponse( - final HttpHost httpHost, - final ClassicHttpRequest originalRequest, - final ClassicHttpResponse originalResponse, - final HttpContext context) throws IOException, InterruptedException { - - if (originalResponse == null) { - throw new IllegalStateException("httpClient.execute returned null! Check your mock configuration."); - } - + private HttpResponse handleResponse(final HttpHost httpHost, final HttpRequest request, final HttpResponse originalResponse, final HttpContext context) throws IOException { final GoodDataChallengeType challenge = identifyGoodDataChallenge(originalResponse); - if (challenge == GoodDataChallengeType.UNKNOWN) { return originalResponse; } - - EntityUtils.consume(originalResponse.getEntity()); - synchronized (tokenRefreshMonitor) { - if (tokenRefreshing) { - while (tokenRefreshing) { - try { - tokenRefreshMonitor.wait(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted while waiting for token refresh", e); - } - } - // After waiting, verify that tt was successfully obtained - if (tt == null) { - throw new GoodDataAuthException("Token refresh completed but TT is still null"); - } - final ClassicHttpRequest retryRequest = cloneRequestWithNewTT(originalRequest, tt); - return this.httpClient.execute(httpHost, retryRequest, context, response -> copyResponseEntity(response)); - } else { - tokenRefreshing = true; - } - } + EntityUtils.consume(((ClassicHttpResponse) originalResponse).getEntity()); try { - final Lock writeLock = rwLock.writeLock(); - writeLock.lock(); - try { + if (authLock.tryLock()) { + //only one thread requiring authentication will get here. + final Lock writeLock = rwLock.writeLock(); + writeLock.lock(); boolean doSST = true; - if (challenge == GoodDataChallengeType.TT && sst != null) { - boolean refreshed = refreshTt(); - if (refreshed) { - doSST = false; + try { + if (challenge == GoodDataChallengeType.TT && sst != null) { + if (refreshTt()) { + doSST = false; + } } - } - if (doSST) { - sst = sstStrategy.obtainSst(httpClient, authHost); - if (!refreshTt()) { - throw new GoodDataAuthException("Unable to obtain TT after successfully obtained SST"); + if (doSST) { + sst = sstStrategy.obtainSst(httpClient, authHost); + if (!refreshTt()) { + throw new GoodDataAuthException("Unable to obtain TT after successfully obtained SST"); + } } + } catch (GoodDataAuthException e) { + int code = HttpStatus.SC_UNAUTHORIZED; + String message = e.getMessage(); + return new BasicClassicHttpResponse(code, message); + } finally { + writeLock.unlock(); } - } finally { - writeLock.unlock(); + } else { + // the other thread is performing auth and thus is holding the write lock + // lets wait until it is finished (the write lock is granted) and then continue + authLock.lock(); } } finally { - synchronized (tokenRefreshMonitor) { - tokenRefreshing = false; - tokenRefreshMonitor.notifyAll(); - } + authLock.unlock(); } - - final ClassicHttpRequest retryRequest = cloneRequestWithNewTT(originalRequest, tt); - ClassicHttpResponse retryResponse = this.httpClient.execute(httpHost, retryRequest, context, response -> copyResponseEntity(response)); - - if (retryResponse.getCode() == HttpStatus.SC_UNAUTHORIZED && - identifyGoodDataChallenge(retryResponse) != GoodDataChallengeType.UNKNOWN) { - return retryResponse; - } - return retryResponse; - } - - private ClassicHttpRequest cloneRequestWithNewTT(ClassicHttpRequest original, String newTT) throws IOException { - ClassicHttpRequest copy; - - // Clone request based on method type - switch (original.getMethod()) { - case "GET": - copy = new HttpGet(original.getRequestUri()); - break; - case "POST": - copy = cloneRequestWithEntity( - new org.apache.hc.client5.http.classic.methods.HttpPost(original.getRequestUri()), - original - ); - break; - case "PUT": - copy = cloneRequestWithEntity( - new org.apache.hc.client5.http.classic.methods.HttpPut(original.getRequestUri()), - original - ); - break; - case "PATCH": - copy = cloneRequestWithEntity( - new org.apache.hc.client5.http.classic.methods.HttpPatch(original.getRequestUri()), - original - ); - break; - case "DELETE": - copy = new org.apache.hc.client5.http.classic.methods.HttpDelete(original.getRequestUri()); - break; - case "HEAD": - copy = new org.apache.hc.client5.http.classic.methods.HttpHead(original.getRequestUri()); - break; - case "OPTIONS": - copy = new org.apache.hc.client5.http.classic.methods.HttpOptions(original.getRequestUri()); - break; - default: - throw new UnsupportedOperationException("Unsupported HTTP method: " + original.getMethod()); - } - - // Copy original headers - for (Header header : original.getHeaders()) { - copy.addHeader(header.getName(), header.getValue()); - } - - // Set the new TT - copy.addHeader(TT_HEADER, newTT); - return copy; + return this.execute(httpHost, (ClassicHttpRequest) request, context); } /** - * Helper method to clone request entity safely, handling both repeatable and non-repeatable entities. - * For non-repeatable entities, buffers the content to allow reuse. + * Refresh temporary token. + * + * @return
    + *
  • true TT refresh successful
  • + *
  • false TT refresh unsuccessful (SST expired)
  • + *
+ * @throws GoodDataAuthException error */ - private T cloneRequestWithEntity(T target, ClassicHttpRequest source) throws IOException { - if (!(source instanceof org.apache.hc.core5.http.HttpEntityContainer)) { - return target; - } - - org.apache.hc.core5.http.HttpEntity entity = - ((org.apache.hc.core5.http.HttpEntityContainer) source).getEntity(); - - if (entity == null) { - return target; - } - - // Check if entity is repeatable - if so, we can reuse it directly - if (entity.isRepeatable()) { - if (target instanceof org.apache.hc.core5.http.HttpEntityContainer) { - ((org.apache.hc.core5.http.HttpEntityContainer) target).setEntity(entity); - } - } else { - // Entity is not repeatable - buffer it for reuse - log.debug("Buffering non-repeatable entity for retry"); - byte[] content = EntityUtils.toByteArray(entity); - String contentTypeStr = entity.getContentType(); - ContentType contentType = contentTypeStr != null ? - ContentType.parseLenient(contentTypeStr) : ContentType.DEFAULT_BINARY; - - ByteArrayEntity bufferedEntity = new ByteArrayEntity(content, contentType); - if (target instanceof org.apache.hc.core5.http.HttpEntityContainer) { - ((org.apache.hc.core5.http.HttpEntityContainer) target).setEntity(bufferedEntity); - } - } - - return target; - } - private boolean refreshTt() throws IOException { log.debug("Obtaining TT"); + final HttpGet request = new HttpGet(TOKEN_URL); + HttpResponse response = null; try { - - request.addHeader(SST_HEADER, sst); - - return httpClient.execute(authHost, request, (HttpContext) null, response -> { - int status = response.getCode(); - - switch (status) { - case HttpStatus.SC_OK: - tt = TokenUtils.extractTT(response); - return true; - case HttpStatus.SC_UNAUTHORIZED: - return false; - default: - throw new GoodDataAuthException("Unable to obtain TT, HTTP status: " + status); + request.setHeader(SST_HEADER, sst); + response = httpClient.execute(authHost, request, (HttpContext) null); + final int status = response.getCode(); + return switch (status) { + case HttpStatus.SC_OK -> { + tt = TokenUtils.extractTT(response); + yield true; } - }); + case HttpStatus.SC_UNAUTHORIZED -> + // we probably may check if SST challenge is present to be sure the problem is the expired SST + false; + default -> throw new GoodDataAuthException("Unable to obtain TT, HTTP status: " + status); + }; } finally { + if (response instanceof ClassicHttpResponse) { + EntityUtils.consumeQuietly(((ClassicHttpResponse) response).getEntity()); + } request.reset(); } } - /** - * Main public execute method: new style, always uses response handler. - */ - public ClassicHttpResponse execute(HttpHost target, ClassicHttpRequest request, HttpContext context) throws IOException { - notNull(request, "Request can't be null"); - // Using write lock for all requests to prevent deadlock scenarios where: - // 1. Thread A holds read lock and calls handleResponse (needs write lock) - // 2. Thread B waits for write lock to refresh tokens - // 3. Deadlock occurs as Thread A can't upgrade from read to write lock - // Trade-off: Serializes all requests but ensures thread safety during token refresh - final Lock lock = rwLock.writeLock(); - lock.lock(); + private boolean isLogoutRequest(HttpHost target, HttpRequest request) { + return authHost.equals(target) + && "DELETE".equals(request.getMethod()) + && request.getRequestUri().startsWith(LOGIN_URL); + } + + @Override + public ClassicHttpResponse execute(ClassicHttpRequest request) throws IOException { + return execute(request, (HttpContext) null); + } + + @Override + public ClassicHttpResponse execute(HttpHost target, ClassicHttpRequest request) throws IOException { + return execute(target, request, (HttpContext) null); + } + + @Override + public T execute(ClassicHttpRequest request, HttpContext context, + HttpClientResponseHandler responseHandler) throws IOException { + final ClassicHttpResponse resp = execute(request, context); try { + return responseHandler.handleResponse(resp); + } catch (HttpException e) { + throw new IOException("Failed to handle HTTP response: " + e.getMessage(), e); + } + } - // --- PATCH: Always check logout even if TT is null, if it's a logout request --- - if (isLogoutRequest(target, request)) { - try { + @Override + public ClassicHttpResponse execute(ClassicHttpRequest request, HttpContext context) throws IOException { + final URI uri; + try { + uri = request.getUri(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid URI in request: " + e.getMessage(), e); + } + final HttpHost httpHost = new HttpHost(uri.getScheme(), uri.getHost(), uri.getPort()); + return execute(httpHost, request, context); + } - sstStrategy.logout(httpClient, target, request.getRequestUri(), sst, tt); - tt = null; - sst = null; - // Return a dummy response for logout success - return new BasicClassicHttpResponse(HttpStatus.SC_NO_CONTENT, "Logout successful"); - } catch (GoodDataLogoutException e) { - throw new GoodDataHttpStatusException(e.getStatusCode(), e.getStatusText()); - } - } - // --- END PATCH --- + @Override + public ClassicHttpResponse execute(HttpHost target, ClassicHttpRequest request, HttpContext context) throws IOException { + requireNonNull(request, "Request can't be null"); + final boolean logoutRequest = isLogoutRequest(target, request); + final Lock lock = logoutRequest ? rwLock.writeLock() : rwLock.readLock(); - if (tt != null) { - request.addHeader(TT_HEADER, tt); - } + lock.lock(); - ClassicHttpResponse resp = this.httpClient.execute(target, request, context, response -> copyResponseEntity(response)); + final ClassicHttpResponse resp; + try { + if (tt != null) { + // this adds TT header to EVERY request to ALL hosts made by this HTTP client + // however the server performs additional checks to ensure client is not using forged TT + request.setHeader(TT_HEADER, tt); - if (resp.getCode() == HttpStatus.SC_UNAUTHORIZED) { - // 👇 Proper handling of InterruptedException - try { - return handleResponse(target, request, resp, context); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); // Preserve interrupt status - throw new IOException("Interrupted while handling authentication challenge", e); + if (logoutRequest) { + try { + sstStrategy.logout(httpClient, target, request.getRequestUri(), sst, tt); + tt = null; + sst = null; + return new BasicClassicHttpResponse(HttpStatus.SC_NO_CONTENT, "Logout successful"); + } catch (GoodDataLogoutException e) { + return new BasicClassicHttpResponse(e.getStatusCode(), e.getStatusText()); + } } } - - return resp; - + resp = (ClassicHttpResponse) this.httpClient.execute(target, request, context); } finally { lock.unlock(); } + return (ClassicHttpResponse) handleResponse(target, request, resp, context); } - public ClassicHttpResponse execute(HttpHost target, ClassicHttpRequest request) throws IOException { - return execute(target, request, null); + @Override + public T execute(ClassicHttpRequest request, HttpClientResponseHandler responseHandler) + throws IOException { + return execute(request, null, responseHandler); + } + + @Override + public T execute(HttpHost target, ClassicHttpRequest request, + HttpClientResponseHandler responseHandler) throws IOException { + return execute(target, request, null, responseHandler); } + @Override public T execute(HttpHost target, ClassicHttpRequest request, HttpContext context, - HttpClientResponseHandler responseHandler) throws IOException, org.apache.hc.core5.http.HttpException { - - if (responseHandler == null) { - throw new IllegalArgumentException("Response handler cannot be null"); - } - - // First, execute with authentication (reuse existing logic from the other execute method) - ClassicHttpResponse response = this.execute(target, request, context); - + HttpClientResponseHandler responseHandler) throws IOException { + ClassicHttpResponse resp = execute(target, request, context); try { - // Then apply the response handler - return responseHandler.handleResponse(response); - } finally { - // Ensure response entity is properly consumed to release resources - EntityUtils.consumeQuietly(response.getEntity()); + return responseHandler.handleResponse(resp); + } catch (HttpException e) { + throw new IOException("Failed to handle HTTP response: " + e.getMessage(), e); } } - /** - * Util for logout request check. - */ - private boolean isLogoutRequest(HttpHost target, ClassicHttpRequest request) { - return authHost.equals(target) - && "DELETE".equals(request.getMethod()) - && URI.create(request.getRequestUri()).getPath().startsWith(LOGIN_URL); - } - /** - * Helper method to copy response entity to avoid stream closure issues. - * Returns a new response with the same properties but a copied entity. - */ - private ClassicHttpResponse copyResponseEntity(ClassicHttpResponse response) throws IOException { - if (response.getEntity() == null) { - return response; - } - - // Copy the entity content - byte[] content = EntityUtils.toByteArray(response.getEntity()); - String contentTypeStr = response.getEntity().getContentType(); - ContentType contentType = contentTypeStr != null ? - ContentType.parseLenient(contentTypeStr) : ContentType.DEFAULT_BINARY; - - // Create a new response with copied entity - BasicClassicHttpResponse newResponse = new BasicClassicHttpResponse(response.getCode(), response.getReasonPhrase()); - for (Header header : response.getHeaders()) { - newResponse.addHeader(header); - } - newResponse.setEntity(new ByteArrayEntity(content, contentType)); - - return newResponse; + private enum GoodDataChallengeType { + SST, TT, UNKNOWN } -} \ No newline at end of file +} diff --git a/src/main/java/com/gooddata/http/client/GoodDataHttpStatusException.java b/src/main/java/com/gooddata/http/client/GoodDataHttpStatusException.java index a437248..795281d 100644 --- a/src/main/java/com/gooddata/http/client/GoodDataHttpStatusException.java +++ b/src/main/java/com/gooddata/http/client/GoodDataHttpStatusException.java @@ -1,9 +1,10 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; + /** * Exception thrown when HTTP operations fail with specific status codes. * This exception wraps HTTP status information to provide detailed error context. diff --git a/src/main/java/com/gooddata/http/client/GoodDataLogoutException.java b/src/main/java/com/gooddata/http/client/GoodDataLogoutException.java index 9fbd8f9..1c5c027 100644 --- a/src/main/java/com/gooddata/http/client/GoodDataLogoutException.java +++ b/src/main/java/com/gooddata/http/client/GoodDataLogoutException.java @@ -1,9 +1,10 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; + /** * Should be thrown when logout is not successful or not possible. * Must be provided with statusCode and optionally statusText, which are used to construct http response, for logout request. diff --git a/src/main/java/com/gooddata/http/client/JsonUtils.java b/src/main/java/com/gooddata/http/client/JsonUtils.java index 9cc6bbe..2786144 100644 --- a/src/main/java/com/gooddata/http/client/JsonUtils.java +++ b/src/main/java/com/gooddata/http/client/JsonUtils.java @@ -1,15 +1,20 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; + import org.apache.commons.text.StringEscapeUtils; + /** * Internal JSON helper */ class JsonUtils { - private JsonUtils() {} + + private JsonUtils() { + } + static String createLoginJson(final String login, final String password, final int verificationLevel) { return "{\"postUserLogin\":{\"login\":\"" + StringEscapeUtils.escapeJava(login) + "\",\"password\":\"" + StringEscapeUtils.escapeJava(password) + "\",\"remember\":0" + diff --git a/src/main/java/com/gooddata/http/client/LoginSSTRetrievalStrategy.java b/src/main/java/com/gooddata/http/client/LoginSSTRetrievalStrategy.java index 868c9f8..2efeb0f 100644 --- a/src/main/java/com/gooddata/http/client/LoginSSTRetrievalStrategy.java +++ b/src/main/java/com/gooddata/http/client/LoginSSTRetrievalStrategy.java @@ -1,51 +1,59 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; -import static com.gooddata.http.client.GoodDataHttpClient.SST_HEADER; -import static com.gooddata.http.client.GoodDataHttpClient.TT_HEADER; -import static java.lang.String.format; -import static org.apache.commons.lang3.Validate.notEmpty; -import static org.apache.commons.lang3.Validate.notNull; -import org.apache.hc.core5.http.Header; -import org.apache.hc.core5.http.HttpEntity; -import org.apache.hc.core5.http.HttpHost; -import org.apache.hc.core5.http.ClassicHttpResponse; -import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.classic.methods.HttpDelete; import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; -import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.ParseException; import org.apache.hc.core5.http.io.entity.EntityUtils; -import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.StatusLine; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + import java.io.IOException; +import static com.gooddata.http.client.GoodDataHttpClient.SST_HEADER; +import static com.gooddata.http.client.GoodDataHttpClient.TT_HEADER; +import static java.lang.String.format; +import static org.apache.commons.lang3.Validate.notEmpty; +import static org.apache.commons.lang3.Validate.notNull; + /** * This strategy obtains super-secure token via login and password. */ public class LoginSSTRetrievalStrategy implements SSTRetrievalStrategy { - private static final String X_GDC_REQUEST_HEADER_NAME = "X-GDC-REQUEST"; + public static final String LOGIN_URL = "/gdc/account/login"; - /** SST and TT must be present in the HTTP header. */ + private static final String X_GDC_REQUEST_HEADER_NAME = "X-GDC-REQUEST"; + /** + * SST and TT must be present in the HTTP header. + */ private static final int VERIFICATION_LEVEL = 2; - private Logger log = LoggerFactory.getLogger(getClass()); private final String login; private final String password; private final HttpHost httpHost; + private Logger log = LoggerFactory.getLogger(getClass()); /** * Construct object. - * @deprecated Use {@link #LoginSSTRetrievalStrategy(String, String)}} + * * @param httpClient HTTP client - * @param httpHost http host - * @param login user login - * @param password user password + * @param httpHost http host + * @param login user login + * @param password user password + * @deprecated Use {@link #LoginSSTRetrievalStrategy(String, String)}} */ @Deprecated public LoginSSTRetrievalStrategy(final HttpClient httpClient, final HttpHost httpHost, final String login, final String password) { @@ -59,7 +67,8 @@ public LoginSSTRetrievalStrategy(final HttpClient httpClient, final HttpHost htt /** * Construct object. - * @param login user login + * + * @param login user login * @param password user password */ public LoginSSTRetrievalStrategy(final String login, final String password) { @@ -81,51 +90,41 @@ public String obtainSst(final HttpClient httpClient, final HttpHost httpHost) th log.debug("Obtaining SST"); final HttpPost postLogin = new HttpPost(LOGIN_URL); + HttpResponse response = null; try { final String loginJson = JsonUtils.createLoginJson(login, password, VERIFICATION_LEVEL); postLogin.setEntity(new StringEntity(loginJson, ContentType.APPLICATION_JSON)); - HttpClientResponseHandler responseHandler = response -> { - int status = response.getCode(); - if (status != HttpStatus.SC_OK) { - final String message = getMessage(response); - log.info(message); - throw new GoodDataAuthException(message); - } - // todo TT is present at response as well - extract it to save one HTTP call - String sst = TokenUtils.extractSST(response); - return sst; - }; - return httpClient.execute(httpHost, postLogin, responseHandler); - - } catch (GoodDataAuthException e) { - throw e; - } catch (Exception e) { - if (e instanceof IOException) throw (IOException) e; - throw new IOException("Failed to obtain SST", e); + response = httpClient.execute(httpHost, postLogin); + int status = response.getCode(); + if (status != HttpStatus.SC_OK) { + final String message = getMessage(response); + log.info(message); + throw new GoodDataAuthException(message); + } + + // todo TT is present at response as well - extract it to save one HTTP call + return TokenUtils.extractSST(response); + } catch (ParseException e) { + IOException ioException = new IOException("Failed to parse HTTP response during SST retrieval: " + e.getMessage(), e); + throw ioException; } finally { + if (response instanceof ClassicHttpResponse) { + EntityUtils.consumeQuietly(((ClassicHttpResponse) response).getEntity()); + } postLogin.reset(); } } - private String getMessage(final ClassicHttpResponse response) throws IOException { - // Try to extract the request ID from the response headers + private String getMessage(final HttpResponse response) throws IOException, ParseException { final Header requestIdHeader = response.getFirstHeader(X_GDC_REQUEST_HEADER_NAME); final String requestId = requestIdHeader != null ? requestIdHeader.getValue() : null; - // Try to read the response entity (body) - final HttpEntity responseEntity = response.getEntity(); - String reason = null; - try { - reason = responseEntity != null ? EntityUtils.toString(responseEntity) : null; - } catch (Exception e) { - reason = "Failed to parse response body: " + e.getMessage(); - } - // Return a formatted error message with the reason, HTTP status code, and request ID - return format( - "Unable to login reason='%s'. Request tracking details httpStatus=%s requestId=%s", - reason, response.getCode(), requestId - ); + final HttpEntity responseEntity = ((ClassicHttpResponse) response).getEntity(); + final String reason = responseEntity != null ? EntityUtils.toString(responseEntity) : null; + + return format("Unable to login reason='%s'. Request tracking details httpStatus=%s requestId=%s", + reason, response.getCode(), requestId); } @Override @@ -136,32 +135,32 @@ public void logout(final HttpClient httpClient, final HttpHost httpHost, final S notEmpty(url, "url can't be empty"); notEmpty(sst, "SST can't be empty"); notEmpty(tt, "TT can't be empty"); + + log.debug("performing logout"); final HttpDelete request = new HttpDelete(url); + HttpResponse response = null; try { - request.addHeader(SST_HEADER, sst); - request.addHeader(TT_HEADER, tt); - org.apache.hc.core5.http.io.HttpClientResponseHandler handler = response -> { - if (response.getCode() != HttpStatus.SC_NO_CONTENT) { - throw new IOException(new GoodDataLogoutException("Logout unsuccessful using http", - response.getCode(), response.getReasonPhrase())); - } - return null; - }; - - try { - httpClient.execute(httpHost, request, handler); - } catch (IOException e) { - if (e.getCause() instanceof GoodDataLogoutException) { - throw (GoodDataLogoutException) e.getCause(); - } - throw e; + request.setHeader(SST_HEADER, sst); + request.setHeader(TT_HEADER, tt); + response = httpClient.execute(httpHost, request); + final StatusLine statusLine = new StatusLine(response); + if (statusLine.getStatusCode() != HttpStatus.SC_NO_CONTENT) { + throw new GoodDataLogoutException("Logout unsuccessful using http", + statusLine.getStatusCode(), statusLine.getReasonPhrase()); } } finally { + if (response instanceof ClassicHttpResponse) { + EntityUtils.consumeQuietly(((ClassicHttpResponse) response).getEntity()); + } request.reset(); } } + /** + * Fot tests only + */ void setLogger(Logger log) { this.log = log; } + } diff --git a/src/main/java/com/gooddata/http/client/SSTRetrievalStrategy.java b/src/main/java/com/gooddata/http/client/SSTRetrievalStrategy.java index 350cb9c..1d4fa7d 100644 --- a/src/main/java/com/gooddata/http/client/SSTRetrievalStrategy.java +++ b/src/main/java/com/gooddata/http/client/SSTRetrievalStrategy.java @@ -1,12 +1,15 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; -import org.apache.hc.core5.http.HttpHost; + import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.HttpHost; + import java.io.IOException; + /** * Interface for class which encapsulates SST retrieval. */ @@ -14,21 +17,23 @@ public interface SSTRetrievalStrategy { /** * Obtains SST using given HTTP client and host. - * @return SST + * * @param httpClient HTTP client - * @param httpHost HTTP host + * @param httpHost HTTP host + * @return SST */ String obtainSst(final HttpClient httpClient, final HttpHost httpHost) throws IOException; + /** * Performs the logout using given HTTP client, host and logout parameters. * Should throw {@link GoodDataLogoutException} in case of logout problem. * * @param httpClient HTTP client - * @param httpHost HTTP host - * @param url url for logout - * @param sst SST - * @param tt TT - * @throws IOException in case of connection error + * @param httpHost HTTP host + * @param url url for logout + * @param sst SST + * @param tt TT + * @throws IOException in case of connection error * @throws GoodDataLogoutException when the logout is not possible or failed */ void logout(final HttpClient httpClient, final HttpHost httpHost, final String url, final String sst, final String tt) diff --git a/src/main/java/com/gooddata/http/client/SimpleSSTRetrievalStrategy.java b/src/main/java/com/gooddata/http/client/SimpleSSTRetrievalStrategy.java index 308a3ae..2dcac30 100644 --- a/src/main/java/com/gooddata/http/client/SimpleSSTRetrievalStrategy.java +++ b/src/main/java/com/gooddata/http/client/SimpleSSTRetrievalStrategy.java @@ -1,22 +1,29 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; -import static org.apache.commons.lang3.Validate.notNull; -import org.apache.hc.core5.http.HttpHost; + import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.HttpHost; + import java.io.IOException; + +import static org.apache.commons.lang3.Validate.notNull; + /** * Provides super-secure token (SST). * This class is intended only for limited use mostly in tests and similar situations, since it doesn't know where the SST came from, * it is not capable to perform proper logout (implemented as noop). */ public class SimpleSSTRetrievalStrategy implements SSTRetrievalStrategy { + private final String sst; + /** * Creates new instance. + * * @param sst super-secure token (SST) */ public SimpleSSTRetrievalStrategy(final String sst) { @@ -31,16 +38,19 @@ public String obtainSst(final HttpClient httpClient, final HttpHost httpHost) { /** * NO-OP as it cannot delete SST, because it is not known where it came from. + * * @param httpClient ignored - * @param httpHost ignored - * @param url ignored - * @param sst ignored - * @param tt ignored - * @throws IOException never + * @param httpHost ignored + * @param url ignored + * @param sst ignored + * @param tt ignored + * @throws IOException never * @throws GoodDataLogoutException never */ @Override public void logout(HttpClient httpClient, HttpHost httpHost, String url, String sst, String tt) throws IOException, GoodDataLogoutException { // does nothing } + + } diff --git a/src/main/java/com/gooddata/http/client/TokenUtils.java b/src/main/java/com/gooddata/http/client/TokenUtils.java index 8b9719f..d8d17c0 100644 --- a/src/main/java/com/gooddata/http/client/TokenUtils.java +++ b/src/main/java/com/gooddata/http/client/TokenUtils.java @@ -1,28 +1,34 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; + import org.apache.hc.core5.http.Header; -import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpResponse; + import static com.gooddata.http.client.GoodDataHttpClient.SST_HEADER; import static com.gooddata.http.client.GoodDataHttpClient.TT_HEADER; import static org.apache.commons.lang3.Validate.notNull; + /** * Contains handy methods for working with SST and TT tokens. */ class TokenUtils { - private TokenUtils() {} - static String extractSST(final ClassicHttpResponse response) { + + private TokenUtils() { + } + + static String extractSST(final HttpResponse response) { return extractToken(response, SST_HEADER); } - static String extractTT(final ClassicHttpResponse response) { + static String extractTT(final HttpResponse response) { return extractToken(response, TT_HEADER); } - private static String extractToken(final ClassicHttpResponse response, final String headerName) { + private static String extractToken(final HttpResponse response, final String headerName) { notNull(response, "response can't be null"); notNull(headerName, "headerName can't be null"); final Header header = response.getFirstHeader(headerName); diff --git a/src/test/java/com/gooddata/http/client/GoodDataHttpClientAT.java b/src/test/java/com/gooddata/http/client/GoodDataHttpClientAT.java index a0fee87..a6b0f0f 100644 --- a/src/test/java/com/gooddata/http/client/GoodDataHttpClientAT.java +++ b/src/test/java/com/gooddata/http/client/GoodDataHttpClientAT.java @@ -1,57 +1,69 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; -import static com.gooddata.http.client.TestUtils.createGoodDataClient; -import static com.gooddata.http.client.TestUtils.getForEntity; -import static com.gooddata.http.client.TestUtils.logout; -import static com.gooddata.http.client.TestUtils.performGet; import org.apache.hc.client5.http.classic.HttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpStatus; -import org.junit.jupiter.api.Test; +import org.apache.hc.core5.http.ParseException; +import org.junit.Test; + import java.io.IOException; import java.util.regex.Matcher; import java.util.regex.Pattern; +import static com.gooddata.http.client.TestUtils.createGoodDataClient; +import static com.gooddata.http.client.TestUtils.getForEntity; +import static com.gooddata.http.client.TestUtils.logout; +import static com.gooddata.http.client.TestUtils.performGet; + +/** + * Acceptance tests. Requires GoodData credentials.
+ * mvn -Pat clean verify -DGDC_LOGIN=user@email.com -DGDC_PASSWORD=password [-DGDC_BACKEND=] + */ +@SuppressWarnings("squid:S2699") public class GoodDataHttpClientAT { + private static final String GDC_PROJECTS_PATH = "/gdc/projects"; + private static final Pattern profilePattern = Pattern.compile("\"/gdc/account/profile/([^\"]+)\""); private final String login = System.getProperty("GDC_LOGIN"); private final String password = System.getProperty("GDC_PASSWORD"); private final HttpHost httpHost = new HttpHost("https", System.getProperty("GDC_BACKEND", "secure.gooddata.com"), 443); @Test - public void gdcLogin() throws IOException, org.apache.hc.core5.http.HttpException { - // Modern style: use TestUtils.createGoodDataClient, returns a GoodDataHttpClient wrapper - final GoodDataHttpClient client = createGoodDataClient(login, password, httpHost); - // performGet expects GoodDataHttpClient, uses execute() with a lambda (see TestUtils) + public void gdcLogin() throws IOException, ParseException { + final HttpClient client = createGoodDataClient(login, password, httpHost); + performGet(client, httpHost, GDC_PROJECTS_PATH, HttpStatus.SC_OK); } @Test - public void gdcSstSimple() throws IOException, org.apache.hc.core5.http.HttpException { - final HttpClient httpClient = HttpClients.createDefault(); + public void gdcSstSimple() throws IOException, ParseException { + final HttpClient httpClient = HttpClientBuilder.create().build(); + final LoginSSTRetrievalStrategy loginSSTRetrievalStrategy = new LoginSSTRetrievalStrategy(login, password); final String sst = loginSSTRetrievalStrategy.obtainSst(httpClient, httpHost); + final SSTRetrievalStrategy sstStrategy = new SimpleSSTRetrievalStrategy(sst); - final GoodDataHttpClient client = new GoodDataHttpClient(httpClient, httpHost, sstStrategy); + final HttpClient client = new GoodDataHttpClient(httpClient, httpHost, sstStrategy); + performGet(client, httpHost, GDC_PROJECTS_PATH, HttpStatus.SC_OK); } - private static final Pattern profilePattern = Pattern.compile("\"/gdc/account/profile/([^\"]+)\""); - @Test - public void gdcLogout() throws IOException, org.apache.hc.core5.http.HttpException { - final GoodDataHttpClient client = createGoodDataClient(login, password, httpHost); + public void gdcLogout() throws IOException, ParseException { + final HttpClient client = createGoodDataClient(login, password, httpHost); final String response = getForEntity(client, httpHost, "/gdc/account/profile/current", HttpStatus.SC_OK); final Matcher matcher = profilePattern.matcher(response); matcher.find(); final String profile = matcher.group(1); + logout(client, httpHost, profile, HttpStatus.SC_NO_CONTENT); } -} + +} diff --git a/src/test/java/com/gooddata/http/client/GoodDataHttpClientIntegrationTest.java b/src/test/java/com/gooddata/http/client/GoodDataHttpClientIntegrationTest.java index b43b2a4..18c4134 100644 --- a/src/test/java/com/gooddata/http/client/GoodDataHttpClientIntegrationTest.java +++ b/src/test/java/com/gooddata/http/client/GoodDataHttpClientIntegrationTest.java @@ -1,228 +1,80 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; -import static com.gooddata.http.client.TestUtils.createGoodDataClient; -import static com.gooddata.http.client.TestUtils.logout; -import static com.gooddata.http.client.TestUtils.performGet; -import static net.jadler.Jadler.closeJadler; -import static net.jadler.Jadler.initJadler; -import static net.jadler.Jadler.onRequest; -import static net.jadler.Jadler.port; -import static net.jadler.Jadler.verifyThatRequest; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; + + import net.jadler.Request; import net.jadler.stubbing.RequestStubbing; import net.jadler.stubbing.Responder; import net.jadler.stubbing.ResponseStubbing; import net.jadler.stubbing.StubResponse; +import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpStatus; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.apache.hc.core5.http.ParseException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + import java.io.IOException; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import static com.gooddata.http.client.TestUtils.createGoodDataClient; +import static com.gooddata.http.client.TestUtils.logout; +import static com.gooddata.http.client.TestUtils.performGet; +import static net.jadler.Jadler.closeJadler; +import static net.jadler.Jadler.initJadler; +import static net.jadler.Jadler.onRequest; +import static net.jadler.Jadler.port; +import static net.jadler.Jadler.verifyThatRequest; + @SuppressWarnings("squid:S2699") public class GoodDataHttpClientIntegrationTest { + private static final String GDC_TOKEN_PATH = "/gdc/account/token"; private static final String GDC_LOGIN_PATH = "/gdc/account/login"; private static final String GDC_PROJECTS_PATH = "/gdc/projects"; private static final String GDC_PROJECTS2_PATH = "/gdc/projects2"; private static final String REDIRECT_PATH = "/redirect/to/projects"; + private static final String SST_HEADER = "X-GDC-AuthSST"; private static final String WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"; private static final String ACCEPT_HEADER = "Accept"; private static final String CONTENT_HEADER = "Content-Type"; private static final String TT_HEADER = "X-GDC-AuthTT"; + private static final String GOODDATA_REALM = "GoodData realm=\"GoodData API\""; private static final String TT_COOKIE = "cookie=GDCAuthTT"; + private static final String BODY_401 = "401 Authorization Required

This server could not verify that you are authorized to access the document requested. Either you supplied the wrong credentials (e.g., bad password), or your browser doesn't understand how to supply the credentials required.Please see Authenticating to the GoodData API for details.

"; private static final String BODY_PROJECTS = "{\"about\":{\"summary\":\"Project Resources\",\"category\":\"Projects\",\"links\":[]}}"; private static final String BODY_TOKEN_401 = "{\"parameters\":[],\"component\":\"Account::Token\",\"message\":\"/gdc/account/login\"}"; + private static final String CONTENT_TYPE_JSON = "application/json"; private static final String CONTENT_TYPE_JSON_UTF = "application/json; charset=UTF-8"; - private static final Charset CHARSET = Charset.forName("UTF-8"); + + private static final Charset CHARSET = StandardCharsets.UTF_8; + private String jadlerLogin; private String jadlerPassword; private HttpHost jadlerHost; - @BeforeEach - public void setUp() { - initJadler(); - - jadlerLogin = "user@email.com"; - jadlerPassword = "top secret"; - jadlerHost = new HttpHost("http", "localhost", port()); - } - - @AfterEach - public void tearDown() { - closeJadler(); - } - - @Test - public void vi () throws IOException, org.apache.hc.core5.http.HttpException { - mock401OnProjects(); - mock401OnToken(); - - requestOnLogin().respond() - .withStatus(401) - .withHeader(WWW_AUTHENTICATE_HEADER, GOODDATA_REALM) - .withBody("{\"parameters\":[],\"component\":\"Account::Login::AuthShare\",\"message\":\"Bad Login or Password!\"}") - .withContentType(CONTENT_TYPE_JSON); - - final GoodDataHttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); - - // When login fails with 401, obtainSst() throws GoodDataAuthException - org.junit.jupiter.api.Assertions.assertThrows( - GoodDataAuthException.class, - () -> performGet(client, jadlerHost, GDC_PROJECTS_PATH, HttpStatus.SC_UNAUTHORIZED) - ); - } - - - @Test - public void getProjectOkloginAndTtRefresh() throws Exception { - onRequest() - .havingMethodEqualTo("GET") - .havingPathEqualTo(REDIRECT_PATH) - .respond() - .withStatus(200) - .withBody(BODY_PROJECTS) - .withEncoding(CHARSET) - .withContentType(CONTENT_TYPE_JSON_UTF); - - mock401OnProjects(); - mock200OnProjects(); - mock401OnToken(); - mock200OnToken(); - mockLogin(); - final GoodDataHttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); - - try { - performGet(client, jadlerHost, REDIRECT_PATH, HttpStatus.SC_OK); - } catch (IOException e) { - throw new RuntimeException("GET request failed", e); - } - } - - private final class PerformGetWithCountDown implements Runnable { - private final GoodDataHttpClient client; - private final String path; - private final CountDownLatch countDown; - - private PerformGetWithCountDown(GoodDataHttpClient client, String path, CountDownLatch countDown) { - this.client = client; - this.path = path; - this.countDown = countDown; - } - - @Override - public void run() { - try { - performGet(client, jadlerHost, path, HttpStatus.SC_OK); - } catch (IOException | org.apache.hc.core5.http.HttpException e) { - throw new IllegalStateException("Can't execute get", e); - } finally { - countDown.countDown(); - } - } - } - - @Test - public void redirect() throws IOException, org.apache.hc.core5.http.HttpException { - onRequest() - .havingMethodEqualTo("GET") - .havingPathEqualTo(REDIRECT_PATH) - .respond() - .withStatus(302) - .withHeader("Location", GDC_PROJECTS_PATH); - - onRequest() - .havingMethodEqualTo("GET") - .havingPathEqualTo(GDC_PROJECTS_PATH) - .respond() - .withStatus(200) - .withBody(BODY_PROJECTS) - .withEncoding(CHARSET) - .withContentType(CONTENT_TYPE_JSON_UTF); - - mock401OnToken(); - mock200OnToken(); - mockLogin(); - - final GoodDataHttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); - - try { - performGet(client, jadlerHost, REDIRECT_PATH, HttpStatus.SC_OK); - } catch (IOException e) { - throw new RuntimeException("GET request failed", e); - } - } - - @Test - public void getProjectOkNoTtRefresh() throws IOException, org.apache.hc.core5.http.HttpException { - onRequest() - .havingMethodEqualTo("GET") - .havingPathEqualTo(REDIRECT_PATH) - .respond() - .withStatus(200) - .withBody(BODY_PROJECTS) - .withEncoding(CHARSET) - .withContentType(CONTENT_TYPE_JSON_UTF); - - final GoodDataHttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); - - try { - performGet(client, jadlerHost, REDIRECT_PATH, HttpStatus.SC_OK); - } catch (IOException e) { - throw new RuntimeException("GET request failed", e); - } - } - - @Test - public void shouldLogoutOk() throws IOException, org.apache.hc.core5.http.HttpException { - // Setup mocks to trigger authentication, then test logout - mock401OnProjects(); - mock200OnProjects(); - mock401OnToken(); - mock200OnToken(); - mockLogin(); - mockLogout("profileId"); - onRequest() - .havingMethodEqualTo("DELETE") - .havingPathEqualTo("/gdc/account/login/profileId") - .respond() - .withStatus(204); - - final GoodDataHttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); - try { - // Request to GDC_PROJECTS_PATH will trigger 401, authenticate, then return 200 - performGet(client, jadlerHost, GDC_PROJECTS_PATH, HttpStatus.SC_OK); - } catch (IOException e) { - throw new RuntimeException("GET request failed", e); - } - // Now logout - SST and TT should be set from the authentication above - logout(client, jadlerHost, "profileId", HttpStatus.SC_NO_CONTENT); - } - private static void mock401OnProjects() { mock401OnPath(GDC_PROJECTS_PATH, null); } private static void mock401OnPath(String url, String tt) { requestOnPath(url, tt) - .respond() + .respond() .withStatus(401) .withHeader(WWW_AUTHENTICATE_HEADER, GOODDATA_REALM + " " + TT_COOKIE) .withBody(BODY_401) @@ -235,7 +87,7 @@ private static RequestStubbing requestOnPath(String url, String tt) { .havingMethodEqualTo("GET") .havingPathEqualTo(url) .havingHeaderEqualTo(ACCEPT_HEADER, CONTENT_TYPE_JSON); - return tt != null + return tt != null ? requestStubbing.havingHeaderEqualTo(TT_HEADER, tt) : requestStubbing; } @@ -249,24 +101,19 @@ private static void mock200OnProjects(String tt) { } private static void mock200OnPath(String url, String tt) { - onRequest() - .havingMethodEqualTo("GET") - .havingPathEqualTo(url) - .havingHeaderEqualTo(TT_HEADER, tt) - .respondUsing(request -> { - return StubResponse.builder() - .status(200) - .body(BODY_PROJECTS, CHARSET) - .header(CONTENT_HEADER, CONTENT_TYPE_JSON_UTF) - .build(); - }); + requestOnPath(url, tt) + .respond() + .withStatus(200) + .withBody(BODY_PROJECTS) + .withEncoding(CHARSET) + .withContentType(CONTENT_TYPE_JSON_UTF); } private static void mock401OnToken() { onRequest() .havingMethodEqualTo("GET") .havingPathEqualTo(GDC_TOKEN_PATH) - .respond() + .respond() .withStatus(401) .withHeader(WWW_AUTHENTICATE_HEADER, GOODDATA_REALM + " cookie=GDCAuthSST") .withBody(BODY_TOKEN_401) @@ -303,11 +150,11 @@ private static void mockLogin() { .withEncoding(CHARSET); } - private static RequestStubbing requestOnLogin() { return onRequest() .havingMethodEqualTo("POST") - .havingPathEqualTo(GDC_LOGIN_PATH); + .havingPathEqualTo(GDC_LOGIN_PATH) + .havingBodyEqualTo("{\"postUserLogin\":{\"login\":\"user@email.com\",\"password\":\"top secret\",\"remember\":0,\"verify_level\":2}}"); } private static void mockLogout(String profileId) { @@ -316,7 +163,210 @@ private static void mockLogout(String profileId) { .havingPathEqualTo(GDC_LOGIN_PATH + "/" + profileId) .havingHeaderEqualTo(SST_HEADER, "SST") .havingHeaderEqualTo(TT_HEADER, "TT") - .respond() + .respond() .withStatus(204); } -} \ No newline at end of file + + @Before + public void setUp() { + initJadler(); + + jadlerLogin = "user@email.com"; + jadlerPassword = "top secret"; + jadlerHost = new HttpHost("http", "localhost", port()); + } + + @After + public void tearDown() { + closeJadler(); + } + + @Test + public void getProjectsBadLogin() throws IOException, ParseException { + mock401OnProjects(); + mock401OnToken(); + + requestOnLogin().respond() + .withStatus(401) + .withHeader(WWW_AUTHENTICATE_HEADER, GOODDATA_REALM) + .withBody("{\"parameters\":[],\"component\":\"Account::Login::AuthShare\",\"message\":\"Bad Login or Password!\"}") + .withContentType(CONTENT_TYPE_JSON); + + final HttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); + + performGet(client, jadlerHost, GDC_PROJECTS_PATH, HttpStatus.SC_UNAUTHORIZED); + } + + @Test + public void getProjectOkloginAndTtRefresh() throws Exception { + mock401OnProjects(); + mock200OnProjects(); + + mock401OnToken(); + mock200OnToken(); + + mockLogin(); + + final HttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); + + performGet(client, jadlerHost, GDC_PROJECTS_PATH, HttpStatus.SC_OK); + } + + @Test + public void shouldRefreshTTConcurrent() throws Exception { + mock401OnProjects(); + + // this serves to block second thread, until the first one gets 401 on projects, which causes TT refresh + // the test aims to test the second thread is not cycling on 401 and cumulating wrong TT headers + final Semaphore semaphore = new Semaphore(1); + + requestOnPath(GDC_PROJECTS_PATH, "TT1") + .respondUsing(new Responder() { + boolean first = true; + + @Override + public StubResponse nextResponse(Request request) { + if (first) { + first = false; + return StubResponse.builder() + .status(200) + .body(BODY_PROJECTS, CHARSET) + .header(CONTENT_HEADER, CONTENT_TYPE_JSON_UTF) + .build(); + } else { + semaphore.release(); + return StubResponse.builder() + .status(401) + .body(BODY_401, CHARSET) + .header(CONTENT_HEADER, CONTENT_TYPE_JSON_UTF) + .header(WWW_AUTHENTICATE_HEADER, GOODDATA_REALM + " " + TT_COOKIE) + .delay(5, TimeUnit.SECONDS) + .build(); + } + } + }); + mock200OnProjects("TT2"); + + mock401OnPath(GDC_PROJECTS2_PATH, "TT1"); + mock200OnPath(GDC_PROJECTS2_PATH, "TT2"); + + mock401OnToken(); + respond200OnToken( + mock200OnToken("TT1").thenRespond(), + "TT2"); + + mockLogin(); + + final HttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); + + // one get at the beginning causing successful login + performGet(client, jadlerHost, GDC_PROJECTS_PATH, 200); + + // to be able to finish when both threads finished + final CountDownLatch countDown = new CountDownLatch(2); + + final ExecutorService executor = Executors.newFixedThreadPool(2); + semaphore.acquire(); // will be released in jadler + executor.submit(new PerformGetWithCountDown(client, GDC_PROJECTS_PATH, countDown)); + semaphore.acquire(); // causes waiting + executor.submit(new PerformGetWithCountDown(client, GDC_PROJECTS2_PATH, countDown)); + + countDown.await(10, TimeUnit.SECONDS); + + verifyThatRequest() + .havingMethodEqualTo("GET") + .havingPathEqualTo(GDC_TOKEN_PATH) + .havingHeaderEqualTo(SST_HEADER, "SST") + // if received more than twice, it means the second thread didn't wait, while the first was refreshing TT + .receivedTimes(2); + + verifyThatRequest() + .havingMethodEqualTo("GET") + .havingPathEqualTo(GDC_PROJECTS2_PATH) + .havingHeaderEqualTo(TT_HEADER, "TT1") + // the second thread should try only once with expired TT1 + .receivedOnce(); + + verifyThatRequest() + .havingMethodEqualTo("GET") + .havingPathEqualTo(GDC_PROJECTS2_PATH) + .havingHeaderEqualTo(TT_HEADER, "TT1") + .havingHeaderEqualTo(TT_HEADER, "TT2") + // the second thread should not set more than one X-GDC-AuthTT header + .receivedNever(); + } + + @Test + public void redirect() throws IOException, ParseException { + onRequest() + .havingMethodEqualTo("GET") + .havingPathEqualTo(REDIRECT_PATH) + .respond() + .withStatus(302) + .withHeader("Location", GDC_PROJECTS_PATH); + + mock401OnProjects(); + + mock200OnProjects(); + + mock401OnToken(); + mock200OnToken(); + + mockLogin(); + + final HttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); + + performGet(client, jadlerHost, REDIRECT_PATH, HttpStatus.SC_OK); + } + + @Test + public void getProjectOkNoTtRefresh() throws IOException, ParseException { + mock200OnProjects(null); // null to simplify the boilerplate + final HttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); + + performGet(client, jadlerHost, GDC_PROJECTS_PATH, HttpStatus.SC_OK); + } + + @Test + public void shouldLogoutOk() throws IOException, ParseException { + mock401OnProjects(); + mock200OnProjects(); + + mock401OnToken(); + mock200OnToken(); + + mockLogin(); + + mockLogout("profileId"); + + final HttpClient client = createGoodDataClient(jadlerLogin, jadlerPassword, jadlerHost); + + performGet(client, jadlerHost, GDC_PROJECTS_PATH, HttpStatus.SC_OK); + + logout(client, jadlerHost, "profileId", HttpStatus.SC_NO_CONTENT); + } + + private final class PerformGetWithCountDown implements Runnable { + + private final HttpClient client; + private final String path; + private final CountDownLatch countDown; + + private PerformGetWithCountDown(HttpClient client, String path, CountDownLatch countDown) { + this.client = client; + this.path = path; + this.countDown = countDown; + } + + @Override + public void run() { + try { + performGet(client, jadlerHost, path, HttpStatus.SC_OK); + } catch (IOException | ParseException e) { + throw new IllegalStateException("Can't execute get", e); + } finally { + countDown.countDown(); + } + } + } +} diff --git a/src/test/java/com/gooddata/http/client/GoodDataHttpClientTest.java b/src/test/java/com/gooddata/http/client/GoodDataHttpClientTest.java index 3a6f544..1b738ba 100644 --- a/src/test/java/com/gooddata/http/client/GoodDataHttpClientTest.java +++ b/src/test/java/com/gooddata/http/client/GoodDataHttpClientTest.java @@ -1,521 +1,209 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; -import org.apache.hc.core5.http.HttpHost; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpDelete; +import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; -import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.ProtocolVersion; +import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; -import org.apache.hc.client5.http.classic.methods.HttpDelete; -import org.apache.hc.client5.http.classic.methods.HttpGet; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http.message.BasicHttpResponse; +import org.apache.hc.core5.http.message.StatusLine; import org.apache.hc.core5.http.protocol.HttpContext; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.Before; +import org.junit.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.mockito.stubbing.Answer; + import java.io.IOException; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.*; +import java.net.URI; + +import static com.gooddata.http.client.GoodDataHttpClient.TT_HEADER; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.only; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; -import java.lang.reflect.Field; -import org.apache.hc.core5.http.Header; -import org.apache.hc.core5.http.message.BasicHeader; public class GoodDataHttpClientTest { private static final String TT = "cookieTt"; private static final String SST = "SST"; - private GoodDataHttpClient goodDataHttpClient; - @Mock - public CloseableHttpClient httpClient; - + public HttpClient httpClient; @Mock public SSTRetrievalStrategy sstStrategy; + private GoodDataHttpClient goodDataHttpClient; + private HttpResponse ttChallengeResponse; - @Mock - private CloseableHttpResponse ttChallengeResponse; - - private CloseableHttpResponse sstChallengeResponse; - - @Mock - private CloseableHttpResponse okResponse; - - @Mock - private CloseableHttpResponse ttRefreshedResponse; + private HttpResponse sstChallengeResponse; + + private HttpResponse okResponse; + + private HttpResponse ttRefreshedResponse; + + private HttpResponse response401; - @Mock - private CloseableHttpResponse response401; private HttpHost host; + private HttpGet get; - private AutoCloseable mocks; - @BeforeEach + @Before public void setUp() { - // Initialize Mockito mocks and main GoodDataHttpClient under test - mocks = MockitoAnnotations.openMocks(this); + MockitoAnnotations.openMocks(this); host = new HttpHost("https", "server.com", 443); get = new HttpGet("/url"); goodDataHttpClient = new GoodDataHttpClient(httpClient, host, sstStrategy); - // Always return a valid mocked response for response401 (error), never null! - when(response401.getCode()).thenReturn(401); - when(response401.getHeaders(anyString())).thenReturn(new Header[0]); - when(response401.getFirstHeader(anyString())).thenReturn(null); - when(response401.getEntity()).thenReturn(null); - - // PATCH: use Mockito mock for sstChallengeResponse instead of BasicClassicHttpResponse! - sstChallengeResponse = org.mockito.Mockito.mock(CloseableHttpResponse.class); - when(sstChallengeResponse.getCode()).thenReturn(401); - when(sstChallengeResponse.getHeaders(anyString())) - .thenReturn(new Header[] { new BasicHeader("WWW-Authenticate", "cookie=GDCAuthSST") }); - when(sstChallengeResponse.getFirstHeader(anyString())) - .thenReturn(new BasicHeader("WWW-Authenticate", "cookie=GDCAuthSST")); - when(sstChallengeResponse.getEntity()).thenReturn(null); - // sstChallengeResponse is a Mockito mock to allow flexible usage as CloseableHttpResponse - - // Configure ttChallengeResponse to simulate 401 Unauthorized with TT challenge header - when(ttChallengeResponse.getCode()).thenReturn(401); - Header ttAuthHeader = new BasicHeader("WWW-Authenticate", "cookie=GDCAuthTT"); - when(ttChallengeResponse.getHeaders("WWW-Authenticate")).thenReturn(new Header[] { ttAuthHeader }); - when(ttChallengeResponse.getEntity()).thenReturn(null); - - // Always return TT header for okResponse when requested (simulate successful re-auth) - when(okResponse.getCode()).thenReturn(200); - when(okResponse.getHeaders(eq("X-GDC-AuthTT"))).thenReturn(new Header[] { - new BasicHeader("X-GDC-AuthTT", TT) - }); - when(okResponse.getFirstHeader(eq("X-GDC-AuthTT"))).thenReturn(new BasicHeader("X-GDC-AuthTT", TT)); - // For all other headers, return empty array/null to prevent NullPointerException - when(okResponse.getHeaders(argThat(s -> !"X-GDC-AuthTT".equals(s)))).thenReturn(new Header[0]); - when(okResponse.getFirstHeader(argThat(s -> !"X-GDC-AuthTT".equals(s)))).thenReturn(null); - when(okResponse.getEntity()).thenReturn(null); - - // Configure ttRefreshedResponse to always return HTTP 200 and TT header - when(ttRefreshedResponse.getCode()).thenReturn(200); - when(ttRefreshedResponse.getHeaders(eq("X-GDC-AuthTT"))).thenReturn(new Header[] { - new BasicHeader("X-GDC-AuthTT", TT) - }); - when(ttRefreshedResponse.getFirstHeader(eq("X-GDC-AuthTT"))).thenReturn(new BasicHeader("X-GDC-AuthTT", TT)); - when(ttRefreshedResponse.getHeaders(argThat(s -> !"X-GDC-AuthTT".equals(s)))).thenReturn(new Header[0]); - when(ttRefreshedResponse.getFirstHeader(argThat(s -> !"X-GDC-AuthTT".equals(s)))).thenReturn(null); - when(ttRefreshedResponse.getEntity()).thenReturn(null); - - // Other configuration as needed... + ttChallengeResponse = createResponse(HttpStatus.SC_UNAUTHORIZED, "401 Authorization Required", "Unauthorized"); + ttChallengeResponse.setHeader(new BasicHeader("WWW-Authenticate", "GoodData realm=\"GoodData API\" cookie=GDCAuthTT")); + + sstChallengeResponse = createResponse(HttpStatus.SC_UNAUTHORIZED, "401 Authorization Required", "Unauthorized"); + sstChallengeResponse.setHeader(new BasicHeader("WWW-Authenticate", "GoodData realm=\"GoodData API\" cookie=GDCAuthSST")); + + response401 = createResponse(HttpStatus.SC_UNAUTHORIZED, "401 Authorization Required", "Unauthorized"); + + okResponse = createResponse(HttpStatus.SC_OK, "OK", "OK"); + + ttRefreshedResponse = createResponse(HttpStatus.SC_OK, "OK"); + ttRefreshedResponse.setHeader(TT_HEADER, TT); } - @AfterEach - void tearDown() throws Exception { - mocks.close(); + private HttpResponse createResponse(final int status, final String reasonPhrase) { + return new BasicHttpResponse(new StatusLine(new ProtocolVersion("https", 1, 1), status, reasonPhrase).getStatusCode(), reasonPhrase); + } + + private ClassicHttpResponse createResponse(int status, String body, String reasonPhrase) { + BasicClassicHttpResponse response = new BasicClassicHttpResponse(status, reasonPhrase); + if (body != null && !body.isEmpty()) { + StringEntity entity = new StringEntity(body, ContentType.TEXT_HTML); + response.setEntity(entity); + } + return response; } @Test - public void execute_sstExpired() throws Exception { - Field ttField = GoodDataHttpClient.class.getDeclaredField("tt"); - ttField.setAccessible(true); - - org.apache.hc.core5.http.Header ttHeader = - new org.apache.hc.core5.http.message.BasicHeader("X-GDC-AuthTT", TT); - when(ttRefreshedResponse.getHeaders("X-GDC-AuthTT")) - .thenReturn(new org.apache.hc.core5.http.Header[] { ttHeader }); - when(ttRefreshedResponse.getFirstHeader("X-GDC-AuthTT")) - .thenReturn(ttHeader); - - // The test expects three calls: - // 1. /url (returns 401 TT challenge) - // 2. /gdc/account/token (returns 200, TT refreshed) - // 3. /url (retry after auth, returns 200 OK) - final int[] count = {0}; - when(httpClient.execute( - any(HttpHost.class), - any(ClassicHttpRequest.class), - (HttpContext) any(), - any(HttpClientResponseHandler.class) - )).thenAnswer(invocation -> { - HttpClientResponseHandler handler = (HttpClientResponseHandler) invocation.getArgument(3); - count[0]++; - ClassicHttpRequest req = (ClassicHttpRequest) invocation.getArgument(1); - String uri = req.getRequestUri(); - - // 1st call: /url - TT challenge (401) - if (count[0] == 1 && uri.equals("/url")) { - return handler.handleResponse(ttChallengeResponse); - } - // 2nd call: /gdc/account/token - refresh TT - else if (count[0] == 2 && uri.equals("/gdc/account/token")) { - // Set TT after refresh - ttField.set(goodDataHttpClient, TT); - return handler.handleResponse(ttRefreshedResponse); - } - // 3rd call: /url - retry, should return OK - else if (count[0] == 3 && uri.equals("/url")) { - return handler.handleResponse(okResponse); - } else { - throw new AssertionError("Too many calls to httpClient.execute! count=" + count[0] + ", uri=" + uri); - } - }); - - when(sstStrategy.obtainSst(httpClient, host)).thenReturn("MOCKED_SST"); - - final ClassicHttpRequest get = new HttpGet("/url"); - - ClassicHttpResponse result = goodDataHttpClient.execute(host, get); - - assertEquals(okResponse, result); - assertEquals(3, count[0]); // Verify exactly 3 httpClient.execute calls occurred + public void execute_sstExpired() throws IOException { + when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull())) // original requests + .thenReturn(ttChallengeResponse) + .thenReturn(ttRefreshedResponse) + .thenReturn(okResponse); + + assertEquals(okResponse, goodDataHttpClient.execute(host, get)); + + verify(sstStrategy).obtainSst(any(HttpClient.class), any(HttpHost.class)); + verifyNoMoreInteractions(sstStrategy); + verify(httpClient, times(2)).execute(eq(host), eq(get), (HttpContext) isNull()); + verify(httpClient, times(3)).execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull()); } - @SuppressWarnings("unchecked") @Test public void execute_unableObtainSst() throws IOException { - when(httpClient.execute( - eq(host), - any(ClassicHttpRequest.class), - (HttpContext) isNull(), - any(HttpClientResponseHandler.class))) - .thenAnswer(new Answer() { - private int count = 0; - @Override - public Object answer(org.mockito.invocation.InvocationOnMock invocation) throws Throwable { - HttpClientResponseHandler handler = invocation.getArgument(3); - count++; - if (count == 1) { - return handler.handleResponse(ttChallengeResponse); // 401, TT challenge - } else { - return handler.handleResponse(response401); // 401 (no challenge - failed to refresh TT) - } - } - }); - // Mock sstStrategy to successfully obtain SST - when(sstStrategy.obtainSst(httpClient, host)).thenReturn(SST); - - GoodDataAuthException ex = assertThrows( - GoodDataAuthException.class, - () -> goodDataHttpClient.execute(host, get) - ); - assertTrue(ex.getMessage().contains("Unable to obtain TT after successfully obtained SST")); + when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull())) + .thenReturn(ttChallengeResponse) + .thenReturn(response401); + + assertEquals(response401.getCode(), goodDataHttpClient.execute(host, get).getCode()); } - @SuppressWarnings("unchecked") @Test public void execute_unableObtainTTafterSuccessfullSstObtained() throws IOException { - when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull(), any(HttpClientResponseHandler.class))) - .thenAnswer(new Answer() { - private int count = 0; - @Override - public Object answer(org.mockito.invocation.InvocationOnMock invocation) throws Throwable { - HttpClientResponseHandler handler = invocation.getArgument(3); - count++; - if (count == 1) { - return handler.handleResponse(ttChallengeResponse); // 401 TT challenge - } else { - return handler.handleResponse(response401); // 401 (no challenge - failed to refresh TT) - } - } - }); - - // Mock sstStrategy to successfully obtain SST - when(sstStrategy.obtainSst(httpClient, host)).thenReturn(SST); + when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull())) + .thenReturn(ttChallengeResponse) + .thenReturn(sstChallengeResponse) + .thenReturn(response401); - GoodDataAuthException ex = assertThrows( - GoodDataAuthException.class, - () -> goodDataHttpClient.execute(host, get) - ); - // Verify the exception message contains the expected text - assertTrue(ex.getMessage().contains("Unable to obtain TT")); + assertEquals(response401.getCode(), goodDataHttpClient.execute(host, get).getCode()); } - @SuppressWarnings("unchecked") @Test public void execute_nonChallenge401() throws IOException { - when(httpClient.execute( - eq(host), - any(ClassicHttpRequest.class), - (HttpContext) isNull(), - any(HttpClientResponseHandler.class))) - .thenAnswer(invocation -> { - HttpClientResponseHandler handler = invocation.getArgument(3); - return handler.handleResponse(response401); - }); + when(httpClient.execute(eq(host), eq(get), (HttpContext) isNull())) + .thenReturn(response401); assertEquals(response401, goodDataHttpClient.execute(host, get)); + verifyNoInteractions(sstStrategy); - verify(httpClient, only()) - .execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull(), any(HttpClientResponseHandler.class)); + verify(httpClient, only()).execute(eq(host), eq(get), (HttpContext) isNull()); } /* * No TT or SST refresh needed. */ - @SuppressWarnings("unchecked") @Test public void execute_okResponse() throws IOException { - when(httpClient.execute( - eq(host), eq(get), (HttpContext) isNull(), any(HttpClientResponseHandler.class))) - .thenAnswer(invocation -> { - HttpClientResponseHandler handler = invocation.getArgument(3); - return handler.handleResponse(okResponse); - }); + when(httpClient.execute(eq(host), eq(get), (HttpContext) isNull())) + .thenReturn(okResponse); + assertEquals(okResponse, goodDataHttpClient.execute(host, get)); + verifyNoInteractions(sstStrategy); - verify(httpClient, only()) - .execute(eq(host), eq(get), (HttpContext) isNull(), any(HttpClientResponseHandler.class)); + verify(httpClient, only()).execute(eq(host), eq(get), (HttpContext) isNull()); } - @SuppressWarnings("unchecked") @Test public void execute_logoutPath() throws Exception { - // 1. Mock httpClient.execute(...) for general HTTP behavior, as used internally by the client: - when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull(), any(HttpClientResponseHandler.class))) - .thenAnswer(new Answer() { - private int count = 0; - @Override - public Object answer(org.mockito.invocation.InvocationOnMock invocation) throws Throwable { - HttpClientResponseHandler handler = invocation.getArgument(3); - count++; - if (count == 1) { - // First call: simulate TT challenge (401) - return handler.handleResponse(ttChallengeResponse); - } else if (count == 2) { - // Second call: simulate refreshed TT (200) - return handler.handleResponse(ttRefreshedResponse); - } else { - // Any subsequent calls: simulate unauthorized (401) - ClassicHttpResponse errorResponse = new BasicClassicHttpResponse(401, "Unauthorized"); - return handler.handleResponse(errorResponse); - } - } - }); - - // 2. Mock SST (login) retrieval to always return SST: + // first let's login + when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull())) + .thenReturn(ttChallengeResponse) + .thenReturn(ttRefreshedResponse) + .thenReturn(okResponse); when(sstStrategy.obtainSst(httpClient, host)).thenReturn(SST); - final String logoutUrl = "/gdc/account/login/1"; - // 3. Mock logout to throw GoodDataLogoutException (this is what the test is verifying!): - doThrow(new GoodDataLogoutException("Logout unsuccessful", 401, "Unauthorized")) - .when(sstStrategy).logout(eq(httpClient), eq(host), eq(logoutUrl), eq(SST), eq(TT)); - Field ttField = GoodDataHttpClient.class.getDeclaredField("tt"); - ttField.setAccessible(true); - ttField.set(goodDataHttpClient, TT); - - Field sstField = GoodDataHttpClient.class.getDeclaredField("sst"); - sstField.setAccessible(true); - sstField.set(goodDataHttpClient, SST); // Manually set SST for the test - - - // 4. Assert that executing the client will throw GoodDataHttpStatusException with expected fields: - GoodDataHttpStatusException ex = assertThrows( - GoodDataHttpStatusException.class, - () -> goodDataHttpClient.execute(host, new HttpDelete(logoutUrl)) - ); - assertEquals(401, ex.getCode()); - assertEquals("Unauthorized", ex.getReason()); + final String logoutUrl = "/gdc/account/login/1"; + final HttpResponse logoutResponse = goodDataHttpClient.execute(host, new HttpDelete(logoutUrl)); + assertEquals(204, logoutResponse.getCode()); - // 5. Verify that logout was actually called with the correct parameters: verify(sstStrategy).logout(eq(httpClient), eq(host), eq(logoutUrl), eq(SST), eq(TT)); } - @SuppressWarnings("unchecked") @Test public void execute_logoutUri() throws Exception { // first let's login - when(httpClient.execute( - eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull(), any(HttpClientResponseHandler.class))) - .thenAnswer(new Answer() { - private int count = 0; - @Override - public Object answer(org.mockito.invocation.InvocationOnMock invocation) throws Throwable { - HttpClientResponseHandler handler = (HttpClientResponseHandler) invocation.getArgument(3); - count++; - if (count == 1) { - return handler.handleResponse(ttChallengeResponse); - } else if (count == 2) { - return handler.handleResponse(ttRefreshedResponse); - } else { - return handler.handleResponse(okResponse); - } - } - }); - + when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull())) + .thenReturn(ttChallengeResponse) + .thenReturn(ttRefreshedResponse) + .thenReturn(okResponse); when(sstStrategy.obtainSst(httpClient, host)).thenReturn(SST); - Field ttField = GoodDataHttpClient.class.getDeclaredField("tt"); - ttField.setAccessible(true); - ttField.set(goodDataHttpClient, TT); - - Field sstField = GoodDataHttpClient.class.getDeclaredField("sst"); - sstField.setAccessible(true); - sstField.set(goodDataHttpClient, SST); - final String logoutUri = "/gdc/account/login/1"; - ClassicHttpResponse response = goodDataHttpClient.execute(host, new HttpDelete(logoutUri)); - assertEquals(204, response.getCode()); - assertEquals("Logout successful", response.getReasonPhrase()); + final String logoutUri = "https://server.com:443/gdc/account/login/1"; + final HttpResponse logoutResponse = goodDataHttpClient.execute(new HttpDelete(URI.create(logoutUri))); + assertEquals(204, logoutResponse.getCode()); - verify(sstStrategy).logout(eq(httpClient), eq(host), eq(logoutUri), eq(SST), eq(TT)); + verify(sstStrategy).logout(eq(httpClient), eq(host), eq("/gdc/account/login/1"), eq(SST), eq(TT)); } - @SuppressWarnings("unchecked") @Test public void execute_logoutFailed() throws Exception { - // Prepare the mock sequence for httpClient.execute - when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull(), any(HttpClientResponseHandler.class))) - .thenAnswer(new Answer() { - private int count = 0; - @Override - public Object answer(org.mockito.invocation.InvocationOnMock invocation) throws Throwable { - HttpClientResponseHandler handler = invocation.getArgument(3); - count++; - if (count == 1) { - return handler.handleResponse(ttChallengeResponse); - } else if (count == 2) { - return handler.handleResponse(ttRefreshedResponse); - } else { - return handler.handleResponse(okResponse); - } - } - }); - + // first let's login + when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull())) + .thenReturn(ttChallengeResponse) + .thenReturn(ttRefreshedResponse) + .thenReturn(okResponse); when(sstStrategy.obtainSst(httpClient, host)).thenReturn(SST); - // --- PATCH: Manually set TT and SST fields before logout call --- - Field ttField = GoodDataHttpClient.class.getDeclaredField("tt"); - ttField.setAccessible(true); - ttField.set(goodDataHttpClient, TT); - - Field sstField = GoodDataHttpClient.class.getDeclaredField("sst"); - sstField.setAccessible(true); - sstField.set(goodDataHttpClient, SST); - - // --- Prepare logout to throw exception (this is what the test is verifying) --- final String logoutUrl = "/gdc/account/login/1"; doThrow(new GoodDataLogoutException("msg", 400, "bad request")) - .when(sstStrategy).logout(eq(httpClient), eq(host), eq(logoutUrl), eq(SST), eq(TT)); - - // When execute is called, it will attempt logout with SST/TT, which is mocked to throw - GoodDataHttpStatusException ex = assertThrows( - GoodDataHttpStatusException.class, - () -> goodDataHttpClient.execute(host, new org.apache.hc.client5.http.classic.methods.HttpDelete(logoutUrl)) - ); - assertEquals(400, ex.getCode()); - assertEquals("bad request", ex.getReason()); - } - - /** - * Test that execute() with ResponseHandler parameter applies authentication tokens. - * This verifies the fix for the critical bug where this method was bypassing authentication. - */ - @SuppressWarnings("unchecked") - @Test - public void execute_withResponseHandler_appliesAuthentication() throws Exception { - // Prepare mock response - when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull(), any(HttpClientResponseHandler.class))) - .thenAnswer(new Answer() { - @Override - public Object answer(org.mockito.invocation.InvocationOnMock invocation) throws Throwable { - HttpClientResponseHandler handler = invocation.getArgument(3); - return handler.handleResponse(okResponse); - } - }); - - when(sstStrategy.obtainSst(httpClient, host)).thenReturn(SST); - - // Set up authentication state by pre-populating TT token - Field ttField = GoodDataHttpClient.class.getDeclaredField("tt"); - ttField.setAccessible(true); - ttField.set(goodDataHttpClient, TT); - - Field sstField = GoodDataHttpClient.class.getDeclaredField("sst"); - sstField.setAccessible(true); - sstField.set(goodDataHttpClient, SST); - - // Create test request - HttpGet request = new HttpGet("/gdc/account/profile/current"); - - // Execute with ResponseHandler - String result = goodDataHttpClient.execute(host, request, null, response -> { - // Verify the response handler receives the response - assertEquals(200, response.getCode()); - return "success"; - }); - - // Verify result - assertEquals("success", result); - - // Verify that X-GDC-AuthTT header was added to the request - // This verifies authentication was applied before sending the request - Header[] headers = request.getHeaders("X-GDC-AuthTT"); - assertEquals(1, headers.length, "X-GDC-AuthTT header should be present"); - assertEquals(TT, headers[0].getValue(), "X-GDC-AuthTT header should contain the token"); - } - - /** - * Test that execute() with ResponseHandler handles 401 responses by refreshing tokens. - */ - @SuppressWarnings("unchecked") - @Test - public void execute_withResponseHandler_handles401WithTokenRefresh() throws Exception { - // Prepare mock sequence: first 401, then success after token refresh - when(httpClient.execute(eq(host), any(ClassicHttpRequest.class), (HttpContext) isNull(), any(HttpClientResponseHandler.class))) - .thenAnswer(new Answer() { - private int count = 0; - @Override - public Object answer(org.mockito.invocation.InvocationOnMock invocation) throws Throwable { - HttpClientResponseHandler handler = invocation.getArgument(3); - count++; - if (count == 1) { - // First call returns 401 - return handler.handleResponse(ttChallengeResponse); - } else if (count == 2) { - // Token refresh call - return handler.handleResponse(ttRefreshedResponse); - } else { - // Retry returns 200 - return handler.handleResponse(okResponse); - } - } - }); + .when(sstStrategy).logout(eq(httpClient), eq(host), eq(logoutUrl), eq(SST), eq(TT)); - when(sstStrategy.obtainSst(httpClient, host)).thenReturn(SST); - - // Create test request - HttpGet request = new HttpGet("/gdc/account/profile/current"); - - // Execute with ResponseHandler (should handle 401 and retry) - String result = goodDataHttpClient.execute(host, request, null, response -> { - assertEquals(200, response.getCode(), "Should eventually receive 200 after token refresh"); - return "success_after_refresh"; - }); - - // Verify result - assertEquals("success_after_refresh", result); - } - - /** - * Test that execute() with null ResponseHandler throws IllegalArgumentException. - */ - @Test - public void execute_withNullResponseHandler_throwsException() throws Exception { - HttpGet request = new HttpGet("/gdc/account/profile/current"); - - // Execute with null ResponseHandler should throw - IllegalArgumentException ex = assertThrows( - IllegalArgumentException.class, - () -> goodDataHttpClient.execute(host, request, null, null) - ); - - assertTrue(ex.getMessage().contains("Response handler cannot be null")); + final HttpResponse logoutResponse = goodDataHttpClient.execute(host, new HttpDelete(logoutUrl)); + assertEquals(400, logoutResponse.getCode()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/gooddata/http/client/GoodDataLogoutExceptionMatcher.java b/src/test/java/com/gooddata/http/client/GoodDataLogoutExceptionMatcher.java index c300745..b8ec3cb 100644 --- a/src/test/java/com/gooddata/http/client/GoodDataLogoutExceptionMatcher.java +++ b/src/test/java/com/gooddata/http/client/GoodDataLogoutExceptionMatcher.java @@ -1,9 +1,10 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; + import org.hamcrest.BaseMatcher; import org.hamcrest.Description; @@ -22,8 +23,7 @@ public GoodDataLogoutExceptionMatcher(int statusCode, String statusText) { @Override public boolean matches(Object o) { - if (o instanceof GoodDataLogoutException) { - final GoodDataLogoutException e = (GoodDataLogoutException) o; + if (o instanceof GoodDataLogoutException e) { return statusCode == e.getStatusCode() && (statusText == null || statusText.equals(e.getStatusText())); } return false; diff --git a/src/test/java/com/gooddata/http/client/JsonUtilsTest.java b/src/test/java/com/gooddata/http/client/JsonUtilsTest.java index b31d742..203fe64 100644 --- a/src/test/java/com/gooddata/http/client/JsonUtilsTest.java +++ b/src/test/java/com/gooddata/http/client/JsonUtilsTest.java @@ -1,20 +1,23 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + import java.io.IOException; + import static com.gooddata.http.client.JsonUtils.createLoginJson; -import static org.hamcrest.Matchers.*; -import static org.hamcrest.MatcherAssert.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertTrue; public class JsonUtilsTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); @Test @@ -28,10 +31,12 @@ public void shouldCreateLoginJson() throws Exception { private void assertLoginJson(final String login, final String password) throws IOException { final String json = createLoginJson(login, password, 1); final JsonNode node = MAPPER.readTree(json); + final JsonNode postUserLogin = node.path("postUserLogin"); assertTrue(postUserLogin.isObject()); - assertThat(postUserLogin.path("login").asText(), is(login)); - assertThat(postUserLogin.path("password").asText(), is(password)); + + assertThat(postUserLogin.path("login").asString(), is(login)); + assertThat(postUserLogin.path("password").asString(), is(password)); assertThat(postUserLogin.path("verify_level").asInt(), is(1)); assertThat(postUserLogin.path("remember").asInt(), is(0)); } diff --git a/src/test/java/com/gooddata/http/client/LoginSSTRetrievalStrategyTest.java b/src/test/java/com/gooddata/http/client/LoginSSTRetrievalStrategyTest.java index 29e23e3..12dc1d4 100644 --- a/src/test/java/com/gooddata/http/client/LoginSSTRetrievalStrategyTest.java +++ b/src/test/java/com/gooddata/http/client/LoginSSTRetrievalStrategyTest.java @@ -1,93 +1,92 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; + import org.apache.commons.io.IOUtils; -import org.apache.hc.core5.http.HttpHost; -import org.apache.hc.core5.http.ClassicHttpResponse; -import org.apache.hc.core5.http.ContentType; -import org.apache.hc.core5.http.HttpStatus; -import org.apache.hc.core5.http.ProtocolVersion; -import org.apache.hc.core5.http.message.StatusLine; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.classic.methods.HttpDelete; import org.apache.hc.client5.http.classic.methods.HttpPost; -import org.apache.hc.core5.http.io.HttpClientResponseHandler; -import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.ProtocolVersion; +import org.apache.hc.core5.http.io.entity.BasicHttpEntity; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.apache.hc.core5.http.message.StatusLine; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.slf4j.Logger; + +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.io.StringWriter; -import java.net.URISyntaxException; + import static com.gooddata.http.client.GoodDataHttpClient.SST_HEADER; -import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class LoginSSTRetrievalStrategyTest { + private static final String FAILURE_REASON = "Bad username or password"; private static final String REQUEST_ID = "requestIdTest"; private static final String PASSWORD = "mysecret"; private static final String LOGIN = "user@server.com"; private static final String SST = "xxxtopsecretSST"; private static final String TT = "xxxtopsecretTT"; - private LoginSSTRetrievalStrategy sstStrategy; - private AutoCloseable mockClass; - @Mock public HttpClient httpClient; - @Mock public Logger logger; - public StatusLine statusLine; - + @Rule + public ExpectedException expectedException = ExpectedException.none(); + private LoginSSTRetrievalStrategy sstStrategy; + private AutoCloseable mockClass; private HttpHost host; - @BeforeEach + @Before public void setUp() { mockClass = MockitoAnnotations.openMocks(this); host = new HttpHost("server.com", 123); sstStrategy = new LoginSSTRetrievalStrategy(LOGIN, PASSWORD); } - @AfterEach + @After public void tearDown() throws Exception { mockClass.close(); } - @Test public void obtainSstHeader() throws IOException { statusLine = new StatusLine(new ProtocolVersion("https", 1, 1), HttpStatus.SC_OK, "OK"); - final ClassicHttpResponse response = new BasicClassicHttpResponse(HttpStatus.SC_OK, "OK"); - response.addHeader(SST_HEADER, SST); - when(httpClient.execute( - isA(HttpHost.class), isA(HttpPost.class), isA(org.apache.hc.core5.http.io.HttpClientResponseHandler.class))) - .thenAnswer(invocation -> { - org.apache.hc.core5.http.io.HttpClientResponseHandler handler = invocation.getArgument(2); - return handler.handleResponse(response); - }); + final BasicClassicHttpResponse response = new BasicClassicHttpResponse(statusLine.getStatusCode(), statusLine.getReasonPhrase()); + response.setHeader(SST_HEADER, SST); + when(httpClient.execute(isA(HttpHost.class), isA(HttpPost.class))).thenReturn(response); assertEquals(SST, sstStrategy.obtainSst(httpClient, host)); + final ArgumentCaptor hostCaptor = ArgumentCaptor.forClass(HttpHost.class); final ArgumentCaptor postCaptor = ArgumentCaptor.forClass(HttpPost.class); - verify(httpClient).execute(hostCaptor.capture(), postCaptor.capture(), any(org.apache.hc.core5.http.io.HttpClientResponseHandler.class)); + + verify(httpClient).execute(hostCaptor.capture(), postCaptor.capture()); + assertEquals("server.com", hostCaptor.getValue().getHostName()); assertEquals(123, hostCaptor.getValue().getPort()); @@ -96,54 +95,38 @@ public void obtainSstHeader() throws IOException { IOUtils.copy(postCaptor.getValue().getEntity().getContent(), writer, "UTF-8"); assertEquals(postBody, writer.toString()); - try { - assertEquals("/gdc/account/login", postCaptor.getValue().getUri().getPath()); - } catch (URISyntaxException e) { - fail("Invalid URI: " + e.getMessage()); - } + assertEquals("/gdc/account/login", postCaptor.getValue().getPath()); } - @Test + @Test(expected = GoodDataAuthException.class) public void obtainSst_badLogin() throws IOException { statusLine = new StatusLine(new ProtocolVersion("https", 1, 1), HttpStatus.SC_BAD_REQUEST, "Bad Request"); - final ClassicHttpResponse response = new BasicClassicHttpResponse(HttpStatus.SC_BAD_REQUEST, "Bad Request"); + final BasicClassicHttpResponse response = new BasicClassicHttpResponse(statusLine.getStatusCode(), statusLine.getReasonPhrase()); + when(httpClient.execute(any(HttpHost.class), any(HttpPost.class))).thenReturn(response); - when(httpClient.execute(any(HttpHost.class), any(HttpPost.class), any(HttpClientResponseHandler.class))) - .then(invocation -> { - HttpClientResponseHandler handler = invocation.getArgument(2); - return handler.handleResponse(response); - }); + sstStrategy.obtainSst(httpClient, host); - assertThrows(GoodDataAuthException.class, () -> sstStrategy.obtainSst(httpClient, host)); } @Test public void shouldLogout() throws Exception { statusLine = new StatusLine(new ProtocolVersion("https", 1, 1), HttpStatus.SC_NO_CONTENT, "NO CONTENT"); - final ClassicHttpResponse response = new BasicClassicHttpResponse(HttpStatus.SC_NO_CONTENT, "NO CONTENT"); - when(httpClient.execute( - isA(HttpHost.class), - isA(HttpDelete.class), - any(HttpClientResponseHandler.class) - )).thenAnswer(invocation -> { - HttpClientResponseHandler handler = invocation.getArgument(2); - return handler.handleResponse(response); - }); + final BasicClassicHttpResponse response = new BasicClassicHttpResponse(statusLine.getStatusCode(), statusLine.getReasonPhrase()); + when(httpClient.execute(isA(HttpHost.class), isA(HttpDelete.class))).thenReturn(response); sstStrategy.logout(httpClient, host, "/gdc/account/login/profileid", SST, TT); + final ArgumentCaptor hostCaptor = ArgumentCaptor.forClass(HttpHost.class); final ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(HttpDelete.class); - verify(httpClient).execute( - hostCaptor.capture(), - deleteCaptor.capture(), - any(HttpClientResponseHandler.class) - ); + + verify(httpClient).execute(hostCaptor.capture(), deleteCaptor.capture()); assertEquals("server.com", hostCaptor.getValue().getHostName()); assertEquals(123, hostCaptor.getValue().getPort()); + final HttpDelete delete = deleteCaptor.getValue(); assertNotNull(delete); - assertEquals("/gdc/account/login/profileid", delete.getUri().getPath()); + assertEquals("/gdc/account/login/profileid", delete.getPath()); assertEquals(SST, delete.getFirstHeader("X-GDC-AuthSST").getValue()); assertEquals(TT, delete.getFirstHeader("X-GDC-AuthTT").getValue()); } @@ -151,65 +134,59 @@ public void shouldLogout() throws Exception { @Test public void shouldThrowOnLogoutError() throws Exception { statusLine = new StatusLine(new ProtocolVersion("https", 1, 1), HttpStatus.SC_SERVICE_UNAVAILABLE, "downtime"); - final ClassicHttpResponse response = new BasicClassicHttpResponse(HttpStatus.SC_SERVICE_UNAVAILABLE, "downtime"); - - when(httpClient.execute( - isA(HttpHost.class), - isA(HttpDelete.class), - any(org.apache.hc.core5.http.io.HttpClientResponseHandler.class) - )).thenAnswer(invocation -> { - org.apache.hc.core5.http.io.HttpClientResponseHandler handler = invocation.getArgument(2); - return handler.handleResponse(response); - }); - - assertThrows(GoodDataLogoutException.class, () -> - sstStrategy.logout(httpClient, host, "/gdc/account/login/profileid", SST, TT) - ); + final BasicClassicHttpResponse response = new BasicClassicHttpResponse(statusLine.getStatusCode(), statusLine.getReasonPhrase()); + when(httpClient.execute(isA(HttpHost.class), isA(HttpDelete.class))).thenReturn(response); + + expectedException.expect(new GoodDataLogoutExceptionMatcher(503, "downtime")); + + sstStrategy.logout(httpClient, host, "/gdc/account/login/profileid", SST, TT); } - @Test - void logLoginFailureRequestId() throws Exception { + @Test(expected = GoodDataAuthException.class) + public void logLoginFailureRequestId() throws Exception { prepareLoginFailureResponse(); - GoodDataAuthException ex = assertThrows(GoodDataAuthException.class, () -> { + try { sstStrategy.obtainSst(httpClient, host); - }); - ArgumentCaptor logMessageCaptor = ArgumentCaptor.forClass(String.class); - verify(logger).info(logMessageCaptor.capture()); - assertThat("Missing requestId at the log message", logMessageCaptor.getValue(), containsString(REQUEST_ID)); + } finally { + ArgumentCaptor logMessageCaptor = ArgumentCaptor.forClass(String.class); + verify(logger).info(logMessageCaptor.capture()); + assertThat("Missing requestId at the log message", logMessageCaptor.getValue(), containsString(REQUEST_ID)); + } } - @Test - public void logLoginFailureReason() throws Exception{ + @Test(expected = GoodDataAuthException.class) + public void logLoginFailureReason() throws Exception { prepareLoginFailureResponse(); - Exception ex = assertThrows(GoodDataAuthException.class, () -> sstStrategy.obtainSst(httpClient, host)); - ArgumentCaptor logMessageCaptor = ArgumentCaptor.forClass(String.class); - verify(logger).info(logMessageCaptor.capture()); - assertThat("Missing login failure at the log message", logMessageCaptor.getValue(), containsString(FAILURE_REASON)); + try { + sstStrategy.obtainSst(httpClient, host); + } finally { + ArgumentCaptor logMessageCaptor = ArgumentCaptor.forClass(String.class); + verify(logger).info(logMessageCaptor.capture()); + assertThat("Missing login failure at the log message", logMessageCaptor.getValue(), containsString(FAILURE_REASON)); + } } - @Test - public void logLoginFailureHttpStatus() throws Exception{ + @Test(expected = GoodDataAuthException.class) + public void logLoginFailureHttpStatus() throws Exception { prepareLoginFailureResponse(); - Exception ex = assertThrows(GoodDataAuthException.class, () -> sstStrategy.obtainSst(httpClient, host)); - ArgumentCaptor logMessageCaptor = ArgumentCaptor.forClass(String.class); - verify(logger).info(logMessageCaptor.capture()); - assertThat("Missing HTTP response status at the log message", logMessageCaptor.getValue(), containsString("401")); + try { + sstStrategy.obtainSst(httpClient, host); + } finally { + ArgumentCaptor logMessageCaptor = ArgumentCaptor.forClass(String.class); + verify(logger).info(logMessageCaptor.capture()); + assertThat("Missing HTTP response status at the log message", logMessageCaptor.getValue(), containsString("401")); + } } private void prepareLoginFailureResponse() throws IOException { - ClassicHttpResponse response = new BasicClassicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "Unauthorized"); - response.addHeader("X-GDC-Request", REQUEST_ID); - response.setEntity(new StringEntity(FAILURE_REASON, ContentType.TEXT_PLAIN)); - - when(httpClient.execute( - any(HttpHost.class), - any(HttpPost.class), - any(org.apache.hc.core5.http.io.HttpClientResponseHandler.class) - )).thenAnswer(invocation -> { - org.apache.hc.core5.http.io.HttpClientResponseHandler handler = invocation.getArgument(2); - return handler.handleResponse(response); - }); - + statusLine = new StatusLine(new ProtocolVersion("https", 1, 1), HttpStatus.SC_UNAUTHORIZED, "Unauthorized"); + final BasicClassicHttpResponse response = new BasicClassicHttpResponse(statusLine.getStatusCode(), statusLine.getReasonPhrase()); + response.setHeader("X-GDC-Request", REQUEST_ID); + InputStream content = new ByteArrayInputStream(FAILURE_REASON.getBytes()); + int contentLength = FAILURE_REASON.getBytes().length; + HttpEntity entity = new BasicHttpEntity(content, contentLength, ContentType.TEXT_PLAIN); + response.setEntity(entity); + when(httpClient.execute(any(HttpHost.class), any(HttpPost.class))).thenReturn(response); sstStrategy.setLogger(logger); } -} \ No newline at end of file +} diff --git a/src/test/java/com/gooddata/http/client/SimpleSSTRetrievalStrategyTest.java b/src/test/java/com/gooddata/http/client/SimpleSSTRetrievalStrategyTest.java index 85c8e49..9356c44 100644 --- a/src/test/java/com/gooddata/http/client/SimpleSSTRetrievalStrategyTest.java +++ b/src/test/java/com/gooddata/http/client/SimpleSSTRetrievalStrategyTest.java @@ -1,14 +1,17 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import org.junit.jupiter.api.Test; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + @SuppressWarnings("squid:S2699") public class SimpleSSTRetrievalStrategyTest { + public static final String TOKEN = "sst token"; @Test @@ -17,14 +20,14 @@ public void obtainSst() { assertEquals(TOKEN, sstStrategy.obtainSst(null, null)); } -@Test -void constructor_nullSst() { - assertThrows(NullPointerException.class, () -> new SimpleSSTRetrievalStrategy(null)); -} + @Test(expected = NullPointerException.class) + public void constructor_nullSst() { + new SimpleSSTRetrievalStrategy(null); + } @Test public void shouldLogout() throws Exception { SimpleSSTRetrievalStrategy sstStrategy = new SimpleSSTRetrievalStrategy(TOKEN); sstStrategy.logout(null, null, null, null, null); } -} \ No newline at end of file +} diff --git a/src/test/java/com/gooddata/http/client/TestUtils.java b/src/test/java/com/gooddata/http/client/TestUtils.java index e3caa09..c67a452 100644 --- a/src/test/java/com/gooddata/http/client/TestUtils.java +++ b/src/test/java/com/gooddata/http/client/TestUtils.java @@ -1,54 +1,81 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; -import static org.junit.jupiter.api.Assertions.assertEquals; -import org.apache.hc.core5.http.HttpHost; -import org.apache.hc.core5.http.ContentType; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpDelete; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.ParseException; import org.apache.hc.core5.http.io.entity.EntityUtils; + import java.io.IOException; -import org.apache.hc.client5.http.classic.methods.HttpGet; +import static org.junit.Assert.assertEquals; + +/** + * Common utilities for testing + */ abstract class TestUtils { - // Calls a GET request using the GoodDataHttpClient and checks the response status. - // This method delegates to getForEntity for actual execution and status assertion. - static void performGet(GoodDataHttpClient client, HttpHost httpHost, String path, int expectedStatus) throws IOException, org.apache.hc.core5.http.HttpException { + + /** + * Executes GET on given host and path and asserts the response to given status + * + * @param client client for execution + * @param httpHost host + * @param path path at host + * @param expectedStatus status to assert + * @throws IOException + * @throws ParseException + */ + static void performGet(HttpClient client, HttpHost httpHost, String path, int expectedStatus) throws IOException, ParseException { getForEntity(client, httpHost, path, expectedStatus); } - // Executes a GET request and returns the response body as a String. - // Uses basic execute method instead of ResponseHandler to avoid stream closure issues. - static String getForEntity(GoodDataHttpClient client, HttpHost httpHost, String path, int expectedStatus) throws IOException, org.apache.hc.core5.http.HttpException { + /** + * Executes GET on given host and path and asserts the response to given status + * + * @param client client for execution + * @param httpHost host + * @param path path at host + * @param expectedStatus status to assert + * @return fetched entity string representation + * @throws IOException + * @throws ParseException + */ + static String getForEntity(HttpClient client, HttpHost httpHost, String path, int expectedStatus) throws IOException, ParseException { HttpGet get = new HttpGet(path); - get.addHeader("Accept", ContentType.APPLICATION_JSON.getMimeType()); - - try (ClassicHttpResponse response = client.execute(httpHost, get, null)) { - if (response.getCode() != expectedStatus) { - throw new IOException("Unexpected status: " + response.getCode()); - } - return response.getEntity() == null ? null : EntityUtils.toString(response.getEntity()); + try { + get.addHeader("Accept", ContentType.APPLICATION_JSON.getMimeType()); + ClassicHttpResponse getProjectResponse = client.execute(httpHost, get); + assertEquals(expectedStatus, getProjectResponse.getCode()); + return getProjectResponse.getEntity() == null ? null : EntityUtils.toString(getProjectResponse.getEntity()); + } finally { + get.reset(); } } - // Executes a DELETE request (used for logout) and checks the response status. - // Uses basic execute method instead of ResponseHandler to avoid stream closure issues. - static void logout(GoodDataHttpClient client, HttpHost httpHost, String profile, int expectedStatus) throws IOException, org.apache.hc.core5.http.HttpException { - org.apache.hc.client5.http.classic.methods.HttpDelete logout = new org.apache.hc.client5.http.classic.methods.HttpDelete("/gdc/account/login/" + profile); - logout.addHeader("Accept", ContentType.APPLICATION_JSON.getMimeType()); - try (ClassicHttpResponse response = client.execute(httpHost, logout, null)) { - assertEquals(expectedStatus, response.getCode()); // Assert HTTP status code. - EntityUtils.consume(response.getEntity()); // Ensure the entity is fully consumed and resources are released. + static void logout(HttpClient client, HttpHost httpHost, String profile, int expectedStatus) throws IOException { + final HttpDelete logout = new HttpDelete("/gdc/account/login/" + profile); + try { + logout.addHeader("Accept", ContentType.APPLICATION_JSON.getMimeType()); + ClassicHttpResponse logoutResponse = client.execute(httpHost, logout); + assertEquals(expectedStatus, logoutResponse.getCode()); + EntityUtils.consume(logoutResponse.getEntity()); + } finally { + logout.reset(); } } - // Factory method to create a GoodDataHttpClient instance with login/password auth. - // Internally creates the underlying Apache HttpClient and wraps it. - static GoodDataHttpClient createGoodDataClient(String login, String password, HttpHost host) { - org.apache.hc.client5.http.classic.HttpClient httpClient = org.apache.hc.client5.http.impl.classic.HttpClients.createDefault(); - SSTRetrievalStrategy sstStrategy = new LoginSSTRetrievalStrategy(login, password); + static HttpClient createGoodDataClient(String login, String password, HttpHost host) { + final HttpClient httpClient = HttpClientBuilder.create().build(); + final SSTRetrievalStrategy sstStrategy = new LoginSSTRetrievalStrategy(login, password); return new GoodDataHttpClient(httpClient, host, sstStrategy); } -} \ No newline at end of file +} diff --git a/src/test/java/com/gooddata/http/client/TokenUtilsTest.java b/src/test/java/com/gooddata/http/client/TokenUtilsTest.java index fb52130..eb43264 100644 --- a/src/test/java/com/gooddata/http/client/TokenUtilsTest.java +++ b/src/test/java/com/gooddata/http/client/TokenUtilsTest.java @@ -1,48 +1,53 @@ /* - * (C) 2022 GoodData Corporation. + * (C) 2025 GoodData Corporation. * This source code is licensed under the BSD-style license found in the * LICENSE.txt file in the root directory of this source tree. */ package com.gooddata.http.client; + import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.ProtocolVersion; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; -import org.apache.hc.core5.http.ClassicHttpResponse; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.apache.hc.core5.http.message.StatusLine; +import org.junit.Before; +import org.junit.Test; + import static com.gooddata.http.client.GoodDataHttpClient.SST_HEADER; import static com.gooddata.http.client.GoodDataHttpClient.TT_HEADER; import static com.gooddata.http.client.TokenUtils.extractSST; import static com.gooddata.http.client.TokenUtils.extractTT; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; public class TokenUtilsTest { - private ClassicHttpResponse response; - @BeforeEach + private static final StatusLine STATUS = new StatusLine(new ProtocolVersion("http", 1, 1), HttpStatus.SC_OK, "OK"); + + private BasicClassicHttpResponse response; + + @Before public void setUp() throws Exception { - response = new BasicClassicHttpResponse(HttpStatus.SC_OK, "OK"); + response = new BasicClassicHttpResponse(STATUS.getStatusCode(), STATUS.getReasonPhrase()); } - @Test - public void shouldFailOnNullResponseSST() { - assertThrows(NullPointerException.class, () -> extractSST(null)); + @Test(expected = NullPointerException.class) + public void shouldFailOnNullResponseSST() throws Exception { + extractSST(null); } - @Test - public void shouldFailOnNullResponseTT() { - assertThrows(NullPointerException.class, () -> extractTT(null)); + @Test(expected = NullPointerException.class) + public void shouldFailOnNullResponseTT() throws Exception { + extractTT(null); } - @Test - public void shouldFailOnMissingHeaderSST() { - assertThrows(GoodDataAuthException.class, () -> extractSST(response)); + @Test(expected = GoodDataAuthException.class) + public void shouldFailOnMissingHeaderSST() throws Exception { + extractSST(response); } - @Test - public void shouldFailOnMissingHeaderTT() { - assertThrows(GoodDataAuthException.class, () -> extractTT(response)); + @Test(expected = GoodDataAuthException.class) + public void shouldFailOnMissingHeaderTT() throws Exception { + extractTT(response); } @Test @@ -60,4 +65,4 @@ public void shouldExtractTT() throws Exception { final String token = extractTT(response); assertThat(token, is("tt")); } -} \ No newline at end of file +} diff --git a/src/test/resources/log4j.properties b/src/test/resources/log4j.properties index 28be612..693a900 100644 --- a/src/test/resources/log4j.properties +++ b/src/test/resources/log4j.properties @@ -1,28 +1,23 @@ # -# (C) 2022 GoodData Corporation. +# (C) 2025 GoodData Corporation. # This source code is licensed under the BSD-style license found in the # LICENSE.txt file in the root directory of this source tree. # - #----------------------------------------------------------------------------------------------------------------------- # log4j Configuration #----------------------------------------------------------------------------------------------------------------------- - #======================================================================================================================= # Root Logger #======================================================================================================================= log4j.rootCategory=DEBUG, Console - #======================================================================================================================= # Logger with Higher Verbosity #======================================================================================================================= log4j.logger.com.gooddata=DEBUG log4j.logger.org.apache.commons.httpclient.HttpMethodDirector=ERROR - #======================================================================================================================= # Appenders #======================================================================================================================= - #----------------------------------------------------------------------------------------------------------------------- # Console Appender #-----------------------------------------------------------------------------------------------------------------------