From 8e4f3b5491beeb20f560794bcaf2aeb1bfc1bfae Mon Sep 17 00:00:00 2001 From: Athitya Kumar Date: Thu, 10 Aug 2023 19:07:34 +0530 Subject: [PATCH] chore: adds init commit to migrate to public GH --- .github/CODEOWNERS | 10 + .github/pull_request_template.md | 15 ++ .gitignore | 24 +++ CHANGELOG.md | 2 + CODE_OF_CONDUCT.md | 34 +++ CONTRIBUTING.md | 68 ++++++ GETTING_STARTED.md | 16 ++ LICENCE | 19 ++ README.md | 78 +++++++ RetryHandling.md | 61 ++++++ codecov.yml | 11 + pom.xml | 193 ++++++++++++++++++ .../client/CommonSpringWebClient.java | 121 +++++++++++ .../config/HttpClientConfig.java | 9 + .../config/HttpConnectionPoolConfig.java | 11 + .../config/SpringWebClientConfig.java | 13 ++ .../config/WebClientConfiguration.java | 51 +++++ .../config/WebClientRetryConfig.java | 14 ++ .../entity/ClientHttpRequest.java | 34 +++ .../entity/ClientHttpResponse.java | 23 +++ .../entity/enums/RetryHandlerName.java | 5 + .../filter/WebClientRequestFilter.java | 16 ++ .../retryHandler/RetryHandler.java | 14 ++ .../retryHandler/RetryHandlerFactory.java | 37 ++++ .../util/WebClientConstants.java | 5 + .../client/CommonSpringWebClientTest.java | 119 +++++++++++ .../config/WebClientConfigurationTest.java | 57 ++++++ 27 files changed, 1060 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/pull_request_template.md create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 GETTING_STARTED.md create mode 100644 LICENCE create mode 100644 README.md create mode 100644 RetryHandling.md create mode 100644 codecov.yml create mode 100644 pom.xml create mode 100644 src/main/java/com/intuit/springwebclient/client/CommonSpringWebClient.java create mode 100644 src/main/java/com/intuit/springwebclient/config/HttpClientConfig.java create mode 100644 src/main/java/com/intuit/springwebclient/config/HttpConnectionPoolConfig.java create mode 100644 src/main/java/com/intuit/springwebclient/config/SpringWebClientConfig.java create mode 100644 src/main/java/com/intuit/springwebclient/config/WebClientConfiguration.java create mode 100644 src/main/java/com/intuit/springwebclient/config/WebClientRetryConfig.java create mode 100644 src/main/java/com/intuit/springwebclient/entity/ClientHttpRequest.java create mode 100644 src/main/java/com/intuit/springwebclient/entity/ClientHttpResponse.java create mode 100644 src/main/java/com/intuit/springwebclient/entity/enums/RetryHandlerName.java create mode 100644 src/main/java/com/intuit/springwebclient/filter/WebClientRequestFilter.java create mode 100644 src/main/java/com/intuit/springwebclient/retryHandler/RetryHandler.java create mode 100644 src/main/java/com/intuit/springwebclient/retryHandler/RetryHandlerFactory.java create mode 100644 src/main/java/com/intuit/springwebclient/util/WebClientConstants.java create mode 100644 src/test/java/com/intuit/springwebclient/client/CommonSpringWebClientTest.java create mode 100644 src/test/java/com/intuit/springwebclient/config/WebClientConfigurationTest.java diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6ee3ec6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,10 @@ +# List of source code paths and code owners +# For more information on the CODEOWNERS file go to: +# https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax + +# Uncomment line 10 and add the correct owners's usernames. +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, +# @global-owner1 and @global-owner2 will be requested for +# review when someone opens a pull request. +* @global-owner1 @global-owner2 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..818e8b5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +### Checklist +🚨 Please review this repository's [contribution guidelines](./CONTRIBUTING.md). + +- [ ] I've read and agree to the project's contribution guidelines. +- [ ] I'm requesting to **pull a topic/feature/bugfix branch**. +- [ ] I checked that my code additions will pass code linting checks and unit tests. +- [ ] I updated unit and integration tests (if applicable). +- [ ] I'm ready to notify the team of this contribution. + +### Description +What does this change do and why? + +[Link to JIRA] + +Thank you! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2af7cef --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..819fd75 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +# Change Log +All notable changes to this project will be documented in this file. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5a6d94e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,34 @@ +Open source projects are “living.” Contributions in the form of issues and pull requests are welcomed and encouraged. +When you contribute, you explicitly say you are part of the community and abide by its Code of Conduct. + +# The Code + +Intuit's Open Source community fosters a kind, respectful, harassment-free cooperative community. We strive to: + +- Be kind and respectful; +- Act as a global community; +- Conduct ourselves professionally. + +As members of this community, we will not tolerate behaviors including, but not limited to: + +- Violent threats or language; +- Discriminatory or derogatory jokes or language; +- Public or private harassment of any kind; +- Other conduct considered inappropriate in a professional setting. + +## Reporting Concerns + +If you see someone violating the Code of Conduct please email TechOpenSource@intuit.com + +## Scope + +This code of conduct applies to: + +All repos and communities for Intuit-managed projects, whether the text is included in an Intuit-managed project’s repository; + +Individuals or teams representing projects in official capacity, such as via official social media channels or at in-person meetups. + +## Attribution + +This Code of Conduct is partly inspired by and based on those of Amazon, CocoaPods, GitHub, Microsoft, thoughtbot, +and on the Contributor Covenant version 1.4.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9842b0b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +Contribution Guidelines +======================= +Great to have you here. Whether it's improving documentation, adding a new component, or suggesting an issue that will help us improve, all contributions are welcome! + +- [Contribution Expectations](#Contribution-Expectations) +- [Contribution Process](#Contribution-Process) +- [After Contribution is Merged](#After-Contribution-is-Merged) +- [Contact Information](#Contact-Information) + +## Contribution Expectations + +#### Adding Functionality or Reporting Bugs + +* You can look through the existing features/ bugs in the issues, if not please create a new issue. +* Please note we have a code of conduct, please follow it in all your interactions with the project. + +#### Code Quality Expectations +- Tests: All new Java methods should have correlated JUnit tests +- Coverage: Ensure that code coverage does not fall below 80% +- Documentation: Code should be well-documented. What code is doing should be self-explanatory based on coding conventions. Why code is doing something should be explained: + * Java code should have JavaDoc + * `pom.xml` should have comments + * Unit tests should have comments and failure messages + * Integration tests should have comments and failure messages +- Code Style: We try to follow [Google's Coding Standards](https://google.github.io/styleguide/javaguide.html). It's easiest to format based on existing code you see. We don't enforce this; it's just a guideline + +#### SLAs +The team that owns this repo is expected to practice the following: + +>The pull request review SLA is 7 days +- Address any incoming PRs for contributions +- Prioritize feature requests if handled by the team itself +- Support the contributor through code guidance and contribution recognition + + + +## Contribution Process +**All contributions should be done through a fork** + +1. Once the alignment is reached. Fork and Clone. From the GitHub UI, fork the project into your user space or another organization. +2. Create a branch in your forked repo. +3. Make your changes, including documentation. Writing good commit logs is important. Follow the [Local Development](./LOCAL_DEVELOPMENT.md) steps to get started. + ```text + A commit log should describe what changed and why. + Make sure that the commit message contains the Issue number. + ``` +4. **Test**. Bug fixes and features **should come with tests** and coverage should meet or exceed 80%. Make sure all tests pass. Please do not submit patches that fail this check. + +5. Push your changes to your fork's branch. Use `git rebase` (not `git merge`) to sync your work from time to time. +6. In GitHub, create a Pull Request to the upstream repository. On your forked repo, click the 'Pull Request' button and fill out the form. +7. Making a PR will automatically trigger a series of checks against your changes. +8. The team will reach out if they need more information or to make suggestions. + + +[//]: # (after pr) + +## After Contribution is Merged + +Once the PR is good to go, the team will merge it, and you'll be credited as a contributor! Reach out to the team to follow their release cycle. These key questions can help you know what to expect: + +>- Are there ownership expectations in preprod/prod for a period of time? +>- When can a contributor expect to see merged code built and deployed to preprod and prod? +>- How can a contributor validate their code changes after changes have been deployed? + + +## Contact Information + +* Need to get in contact with the team? The best people to start with are the project [code owners](./.github/CODEOWNERS). diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md new file mode 100644 index 0000000..af7dc6f --- /dev/null +++ b/GETTING_STARTED.md @@ -0,0 +1,16 @@ +# Local Development + +### Pre-requisites + +* Java 11 +* Maven +* Spring 5 Reactive Web Client + +## Steps + +1. Fork & Clone the repo. +2. Make sure you are using java version 11. +3. Download the dependencies: ```mvn clean install``` +4. Make changes. +5. Update the version in POM.xml +6. Run ```mvn clean install``` to generate the jar locally for testing. diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..874d803 --- /dev/null +++ b/LICENCE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Intuit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f1ddf5 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# RWebPulse + +RWebPulse is a ready to consume JAR library to easily integrate your springboot project with the latest reactive web-client offered by the spring. It is a one stop solution with config based initialisations, exception and retry handling. + + +## Who should use it? +Any springboot application which requires downstream communication with different servers through rest/ graphql apis can leverage RWebPulse. + + +## Why? +The existing webclient cannot be integrated in an easy configurable manner with an existing application. The app user needs to make significant changes in their code to be able to leverage the benefits of webclient. + +With RWebPulse we are providing seamless config based integration to WebClient with support of exception and retry handling. All the http parameters can be configured at the runtime by the config. The existing application needs to make minimal changes by just providing the right config and directly consume the webclient. + + +## How to integrate? + + +### Adding the config + +Spring web client config needs to be defined like this in your application config. + +``` +spring-web-client-config: + connection-pool: # Connection pool configuration + pending-acquire-timeout: 31000 # 31 seconds + max-idle-time: 31000 # 31 seconds + max-life-time: 300000 # 5 minutes + max-connections: 400 # max pool connections + http-client-config: # http client config + connect-timeout-millis: 30000 # 30 seconds + socket-timeout-millis: 30000 # 30 seconds +``` + + + +| Property | Description | Default values | +| ------ | ----------- | ------- | +| connection-pool | | | +| pending-acquire-timeout | Webclient fails if pool connection is pending for more than this duration | 31 seconds | +| max-idle-time | max time connection can remain idle before the server closes | 31 seconds | +| max-life-time | max life time of connection after which the server closes | 5 mins | +| max-connections | max connections that can be maintained in the pool | 400 | +| http-client-config | | | +| connect-timeout-millis | a time period in which a client should establish a connection with a server | 30 seconds | +| socket-timeout-millis | a maximum time of inactivity between two data packets when exchanging data with a server | 30 seconds | + + + +### Adding the client + +Add the below snippet in your application where you need to make a downstream service call + +``` +private final CommonSpringWebClient webClient; + +protected ClientHttpResponse executeRequest(final Map body) { + + return webClient.syncHttpResponse( + ClientHttpRequest.builder() + .url("https:abc.com/v1/create") + .httpMethod(HttpMethod.POST) + .requestHeaders(new HttpHeaders()) + .request(body) + .build()); + } +``` + + +### Configure retries +[Retry Handling](./RetryHandling.md) + + +## [Contribution](./CONTRIBUTING.md) + + +## Local Development +[Local Development](./LOCAL_DEVELOPMENT.md) diff --git a/RetryHandling.md b/RetryHandling.md new file mode 100644 index 0000000..d3de805 --- /dev/null +++ b/RetryHandling.md @@ -0,0 +1,61 @@ +# Retry Handling + +This library provides retry capabilites based on the runtime attributes as well as provides custom retry handling based on the response. + +Retry Config can be passed along with [ClientHttpRequest](./src/main/java/com/intuit/springwebclient/entity/ClientHttpRequest.java) + +``` + webClient.syncHttpResponse( + ClientHttpRequest.builder() + .url("https:abc.com/v1/create") + .httpMethod(HttpMethod.POST) + .requestHeaders(new HttpHeaders()) + .request(body) + .clientRetryConfig() + .build()) +``` + +### Client Retry Config + +[WebClientRetryConfig](./src/main/java/com/intuit/springwebclient/config/WebClientRetryConfig.java) + +| Attribute | Description | Default | +| -------- | --------------------- | ---- | +| maxAttempts | Maximum number of reties | 0 | +| backOff | Backoff time between retries in seconds | 0 | + +### Custom Retry Handlers + +Custom retry handlers can be added, which would get invoked after exhaution of all reties specified in Client Retry Config. + +#### Steps + +1. Implement custom retry handler. [RetryHandler](./src/main/java/com/intuit/springwebclient/retryHandler/RetryHandler.java) +2. Populate the Retry handler factory at the application start event. +``` +@Component +@AllArgsConstructor +public class ApplicationEventListener { + + private final List RetryHandler; + @EventListener + public void handleContextRefresh(ContextRefreshedEvent event) { + + // Init Retry Handler Factory + RetryHandler.forEach( + handler -> RetryHandlerFactory.addHandler(handler.getName(), handler)); + } +} +``` +3. Pass the list fo handlers to be called in the ClientHttpRequest. + +``` + webClient.syncHttpResponse( + ClientHttpRequest.builder() + .url("https:abc.com/v1/create") + .httpMethod(HttpMethod.POST) + .requestHeaders(new HttpHeaders()) + .request(body) + .retryHandlers() + .build()) +``` diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..3441f65 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,11 @@ +# https://docs.codecov.io/docs/codecov-yaml +codecov: + branch: master +coverage: + status: + patch: + default: + target: 75% + +comment: + layout: "diff, flags, files:10, footer" \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d77b30b --- /dev/null +++ b/pom.xml @@ -0,0 +1,193 @@ + + 4.0.0 + + com.intuit.rwebpulse + rwebpulse + 1.0.0 + jar + Spring Web Client + https://github.com/intuit/rwebpulse + 2023 + + A ready to consume JAR that provides a configuration-based reactive SpringBoot web-client + + + + org.springframework.boot + spring-boot-starter-parent + 2.7.12 + + + + + UTF-8 + UTF-8 + 11 + 11 + + 5.7.2 + true + 0.8.8 + 2.22.2 + 1.7.36 + 1.31 + + + scm:git:git://github.com/intuit/rwebpulse.git + scm:git:git@github.com/intuit/rwebpulse.git + https://github.com/intuit/rwebpulse + HEAD + + + + + + + org.slf4j + slf4j-api + + + + + + org.junit.jupiter + junit-jupiter + test + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework + spring-web-reactive + + + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework + spring-web-reactive + 5.0.0.M4 + + + + + org.apache.httpcomponents + httpasyncclient + 4.1.5 + + + + + org.apache.httpcomponents + httpclient + 4.5.14 + + + + + + + + + org.slf4j + slf4j-api + ${slf4j-api.version} + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.1 + + + org.apache.httpcomponents.core5 + httpcore5-reactive + 5.1 + + + + org.yaml + snakeyaml + ${snakeyaml.version} + + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare + + prepare-agent + + + + generate-report + test + + report + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + spring-milestones-repository + https://repo.spring.io/milestone/ + + + diff --git a/src/main/java/com/intuit/springwebclient/client/CommonSpringWebClient.java b/src/main/java/com/intuit/springwebclient/client/CommonSpringWebClient.java new file mode 100644 index 0000000..8e8a3d4 --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/client/CommonSpringWebClient.java @@ -0,0 +1,121 @@ +package com.intuit.springwebclient.client; + +import com.intuit.springwebclient.entity.ClientHttpRequest; +import com.intuit.springwebclient.entity.ClientHttpResponse; +import com.intuit.springwebclient.retryHandler.RetryHandlerFactory; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.UnknownContentTypeException; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +import java.time.Duration; +import java.util.function.Consumer; + +/** + * Spring 5 Web Client Method executor. + */ +@Slf4j +@Component +@AllArgsConstructor +public class CommonSpringWebClient { + private final WebClient webClient; + + /** + * Execute Blocking http request. + * @param httpRequest + * @return + * @param + * @param + */ + public ClientHttpResponse syncHttpResponse(ClientHttpRequest httpRequest) { + try { + log.info("Executing http request for request={}, method={}", httpRequest.getUrl(), httpRequest.getHttpMethod()); + return generateResponse( + generateResponseSpec(httpRequest) + .bodyToMono(httpRequest.getResponseType()) + .retryWhen(generateRetrySpec(httpRequest)) + .block()); + } catch (final HttpStatusCodeException ex) { + final String errorMessage = String.format("Error in making rest call. Error=%s Headers=%s", + ex.getResponseBodyAsString(), ex.getResponseHeaders()); + return handleException(ex, errorMessage, HttpStatus.valueOf(ex.getStatusCode().value()), httpRequest); + } catch (final UnknownContentTypeException ex) { + // It was observed that this exception was thrown whenever there was a HTTP 5XX error + // returned in the REST call. The handle went into `RestClientException` which is the parent + // class of `UnknownContentTypeException` and hence some contextual information was lost + final String errorMessage = String.format("Error in making rest call. Error=%s Headers=%s", + ex.getResponseBodyAsString(), ex.getResponseHeaders()); + return handleException(ex, errorMessage, HttpStatus.valueOf(ex.getRawStatusCode()), httpRequest); + } catch (final Exception ex) { + final String errorMessage = String + .format("Error in making rest call. Error=%s", ex.getMessage()); + return handleException(ex, errorMessage, HttpStatus.INTERNAL_SERVER_ERROR, httpRequest); + } + } + + /** + * Generate Web Client Response spec from http request. + * @param httpRequest + * @return + */ + private WebClient.ResponseSpec generateResponseSpec(ClientHttpRequest httpRequest) { + + Consumer httpHeadersConsumer = (httpHeaders -> httpHeaders.putAll(httpRequest.getRequestHeaders())); + return webClient.method(httpRequest.getHttpMethod()) + .uri(httpRequest.getUrl()) + .headers(httpHeadersConsumer) + .body(Mono.just(httpRequest.getRequest()), httpRequest.getRequestType()) + .retrieve(); + } + + /** + * Generates retry spec for the request based on config provided. + * @param httpRequest + * @return + */ + private Retry generateRetrySpec(ClientHttpRequest httpRequest) { + return Retry.fixedDelay(httpRequest.getClientRetryConfig().getMaxAttempts(), Duration.ofSeconds(httpRequest.getClientRetryConfig().getBackOff())) + .doBeforeRetry(signal -> log.info("Retrying for request={}, retryCount={}", httpRequest.getUrl(), signal.totalRetries())) + .filter(httpRequest.getClientRetryConfig().getRetryFilter()); + } + + /** + * Handle Success response. + * @param response + * @return + * @param + */ + private ClientHttpResponse generateResponse(RESPONSE response) { + return ClientHttpResponse.builder() + .response(response) + .status(HttpStatus.OK) + .isSuccess2xx(HttpStatus.OK.is2xxSuccessful()) + .build(); + } + + /** + * Handle Exception and send back response. + * @param exception + * @param errorMessage + * @param httpStatus + * @param httpRequest + * @return + * @param + */ + private ClientHttpResponse handleException( + final Exception exception, + final String errorMessage, + final HttpStatus httpStatus, + final ClientHttpRequest httpRequest) { + log.error("Exception while executing http request for request={}, status={}, errorMessage={}", httpRequest.getUrl(), httpStatus, errorMessage); + httpRequest.getRetryHandlers() + .forEach(handlerId -> RetryHandlerFactory.getHandler(handlerId.toString()).checkAndThrowRetriableException(exception)); + return ClientHttpResponse.builder().error(errorMessage).status(httpStatus).build(); + } +} diff --git a/src/main/java/com/intuit/springwebclient/config/HttpClientConfig.java b/src/main/java/com/intuit/springwebclient/config/HttpClientConfig.java new file mode 100644 index 0000000..4c78b92 --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/config/HttpClientConfig.java @@ -0,0 +1,9 @@ +package com.intuit.springwebclient.config; + +import lombok.Data; + +@Data +public class HttpClientConfig { + private Integer connectTimeoutMillis = 30000; + private Integer socketTimeoutMillis = 30000; +} diff --git a/src/main/java/com/intuit/springwebclient/config/HttpConnectionPoolConfig.java b/src/main/java/com/intuit/springwebclient/config/HttpConnectionPoolConfig.java new file mode 100644 index 0000000..d37c421 --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/config/HttpConnectionPoolConfig.java @@ -0,0 +1,11 @@ +package com.intuit.springwebclient.config; + +import lombok.Data; + +@Data +public class HttpConnectionPoolConfig { + private int maxConnections = 400; + private Long pendingAcquireTimeout = 31000L; + private Long maxIdleTime = 31000L; + private Long maxLifeTime = 300000L; +} diff --git a/src/main/java/com/intuit/springwebclient/config/SpringWebClientConfig.java b/src/main/java/com/intuit/springwebclient/config/SpringWebClientConfig.java new file mode 100644 index 0000000..8ac203d --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/config/SpringWebClientConfig.java @@ -0,0 +1,13 @@ +package com.intuit.springwebclient.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "spring-web-client-config") +@Data +public class SpringWebClientConfig { + private HttpConnectionPoolConfig connectionPool; + private HttpClientConfig httpClientConfig; +} diff --git a/src/main/java/com/intuit/springwebclient/config/WebClientConfiguration.java b/src/main/java/com/intuit/springwebclient/config/WebClientConfiguration.java new file mode 100644 index 0000000..e79ce3f --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/config/WebClientConfiguration.java @@ -0,0 +1,51 @@ +package com.intuit.springwebclient.config; + + +import com.intuit.springwebclient.filter.WebClientRequestFilter; +import com.intuit.springwebclient.util.WebClientConstants; +import io.netty.channel.ChannelOption; +import lombok.AllArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; + +import java.time.Duration; + +/** + * Define the instance of Spring 5 Web Client interface + */ +@Configuration +@AllArgsConstructor +public class WebClientConfiguration { + private final SpringWebClientConfig webClientConfiguration; + private final WebClientRequestFilter webClientRequestFilter; + + @Bean + public ConnectionProvider webClientConnectionProvider(){ + return ConnectionProvider.builder(WebClientConstants.CONNECTION_PROVIDER_NAME) + .maxConnections(webClientConfiguration.getConnectionPool().getMaxConnections()) + .maxIdleTime(Duration.ofMillis(webClientConfiguration.getConnectionPool().getMaxIdleTime())) + .maxLifeTime(Duration.ofMillis(webClientConfiguration.getConnectionPool().getMaxLifeTime())) + .pendingAcquireTimeout(Duration.ofMillis(webClientConfiguration.getConnectionPool().getPendingAcquireTimeout())) + .build(); + } + + @Bean + public HttpClient webHttpClient(){ + return HttpClient.create(webClientConnectionProvider()) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, webClientConfiguration.getHttpClientConfig().getConnectTimeoutMillis()) + .option(ChannelOption.SO_TIMEOUT, webClientConfiguration.getHttpClientConfig().getSocketTimeoutMillis()); + } + + @Bean + public WebClient createWebClient(){ + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(webHttpClient())) + .filter(webClientRequestFilter.getFilter()) + .build(); + } +} diff --git a/src/main/java/com/intuit/springwebclient/config/WebClientRetryConfig.java b/src/main/java/com/intuit/springwebclient/config/WebClientRetryConfig.java new file mode 100644 index 0000000..a657384 --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/config/WebClientRetryConfig.java @@ -0,0 +1,14 @@ +package com.intuit.springwebclient.config; + +import lombok.Builder; +import lombok.Getter; + +import java.util.function.Predicate; + +@Getter +@Builder(toBuilder = true) +public class WebClientRetryConfig { + @Builder.Default private final int maxAttempts = 0; + @Builder.Default private final int backOff = 0; // Seconds + @Builder.Default private final Predicate retryFilter = (Throwable ex) -> false; +} diff --git a/src/main/java/com/intuit/springwebclient/entity/ClientHttpRequest.java b/src/main/java/com/intuit/springwebclient/entity/ClientHttpRequest.java new file mode 100644 index 0000000..c1c129d --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/entity/ClientHttpRequest.java @@ -0,0 +1,34 @@ +package com.intuit.springwebclient.entity; + +import com.intuit.springwebclient.config.WebClientRetryConfig; +import lombok.Builder; +import lombok.Getter; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; + +import java.util.ArrayList; +import java.util.List; + +/** + * The HttpRequest object. + * + * @param the type parameter + * @param the type parameter + */ +@Getter +@Builder(toBuilder = true) +public final class ClientHttpRequest { + private final String url; + private final REQUEST request; + @Builder.Default + private final ParameterizedTypeReference requestType = new ParameterizedTypeReference<>() {}; + @Builder.Default + private final ParameterizedTypeReference responseType = new ParameterizedTypeReference<>() {}; + @Builder.Default + private final HttpHeaders requestHeaders = new HttpHeaders(); + @Builder.Default + private final HttpMethod httpMethod = HttpMethod.GET; + @Builder.Default private List retryHandlers = new ArrayList<>(); + @Builder.Default private WebClientRetryConfig clientRetryConfig = WebClientRetryConfig.builder().build(); +} diff --git a/src/main/java/com/intuit/springwebclient/entity/ClientHttpResponse.java b/src/main/java/com/intuit/springwebclient/entity/ClientHttpResponse.java new file mode 100644 index 0000000..9a67e38 --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/entity/ClientHttpResponse.java @@ -0,0 +1,23 @@ +package com.intuit.springwebclient.entity; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public final class ClientHttpResponse{ + + private final T response; + private final String error; + private final HttpStatus status; + private final boolean isSuccess2xx; + + public int statusCode() { + return this.status.value(); + } + + public boolean isSuccess2xx() { + return this.isSuccess2xx; + } +} diff --git a/src/main/java/com/intuit/springwebclient/entity/enums/RetryHandlerName.java b/src/main/java/com/intuit/springwebclient/entity/enums/RetryHandlerName.java new file mode 100644 index 0000000..ff3dc28 --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/entity/enums/RetryHandlerName.java @@ -0,0 +1,5 @@ +package com.intuit.springwebclient.entity.enums; + +public enum RetryHandlerName { + STATUS_CODE; +} diff --git a/src/main/java/com/intuit/springwebclient/filter/WebClientRequestFilter.java b/src/main/java/com/intuit/springwebclient/filter/WebClientRequestFilter.java new file mode 100644 index 0000000..55bdf19 --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/filter/WebClientRequestFilter.java @@ -0,0 +1,16 @@ +package com.intuit.springwebclient.filter; + +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; + +/** + * Interface to add https request filters (interceptors). + */ +public interface WebClientRequestFilter { + default ExchangeFilterFunction getFilter() { + return (request, next) -> next.exchange(execute(request)); + } + default ClientRequest execute(ClientRequest request) { + return request; + } +} diff --git a/src/main/java/com/intuit/springwebclient/retryHandler/RetryHandler.java b/src/main/java/com/intuit/springwebclient/retryHandler/RetryHandler.java new file mode 100644 index 0000000..c6d775b --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/retryHandler/RetryHandler.java @@ -0,0 +1,14 @@ +package com.intuit.springwebclient.retryHandler; + +/** + * Interface for handling retries + */ +public interface RetryHandler { + + /** + * Logic to retry or not based on an exception goes here + * @param ex + */ + void checkAndThrowRetriableException(Exception ex); + String getName(); +} diff --git a/src/main/java/com/intuit/springwebclient/retryHandler/RetryHandlerFactory.java b/src/main/java/com/intuit/springwebclient/retryHandler/RetryHandlerFactory.java new file mode 100644 index 0000000..215408e --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/retryHandler/RetryHandlerFactory.java @@ -0,0 +1,37 @@ +package com.intuit.springwebclient.retryHandler; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +/** + * This class has the utility to get the retry handler implementation based on the name + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class RetryHandlerFactory { + + private static final Map RETRY_HANDLER_MAP = new HashMap<>(); + + /** + * Adds a retry handler. + * + * @param retryHandlerName the retry handler name + * @param retryHandler the retry handler + */ + public static void addHandler(String retryHandlerName, RetryHandler retryHandler) { + + RETRY_HANDLER_MAP.put(retryHandlerName, retryHandler); + } + + /** + * Gets a retry handler. + * + * @param handlerName input handler name + * @return action handler impl + */ + public static RetryHandler getHandler(String handlerName) { + return RETRY_HANDLER_MAP.get(handlerName); + } +} diff --git a/src/main/java/com/intuit/springwebclient/util/WebClientConstants.java b/src/main/java/com/intuit/springwebclient/util/WebClientConstants.java new file mode 100644 index 0000000..078014f --- /dev/null +++ b/src/main/java/com/intuit/springwebclient/util/WebClientConstants.java @@ -0,0 +1,5 @@ +package com.intuit.springwebclient.util; + +public final class WebClientConstants { + public static final String CONNECTION_PROVIDER_NAME = "CustomConnectionProvider"; +} diff --git a/src/test/java/com/intuit/springwebclient/client/CommonSpringWebClientTest.java b/src/test/java/com/intuit/springwebclient/client/CommonSpringWebClientTest.java new file mode 100644 index 0000000..301caf7 --- /dev/null +++ b/src/test/java/com/intuit/springwebclient/client/CommonSpringWebClientTest.java @@ -0,0 +1,119 @@ +package com.intuit.springwebclient.client; + +import com.intuit.springwebclient.entity.ClientHttpRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.UnknownContentTypeException; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import java.util.function.Consumer; + + +@ExtendWith(MockitoExtension.class) +public class CommonSpringWebClientTest { + + @Mock + private WebClient webClient; + @Mock + private WebClient.RequestBodyUriSpec requestBodyUriSpec; + @Mock + private WebClient.RequestBodySpec requestBodySpec; + @Mock + WebClient.RequestHeadersSpec headersSpec; + @Mock + WebClient.ResponseSpec responseSpec; + + @InjectMocks + private CommonSpringWebClient commonSpringWebClient; + + + + @Test + public void testSyncHttpResponseSuccess() { + ClientHttpRequest clientHttpRequest = createClientHttpRequest(); + mockRequestBody(); + + Mockito.when(headersSpec.retrieve()).thenReturn(responseSpec); + Mockito.when(responseSpec.bodyToMono(ParameterizedTypeReference.forType(String.class))).thenReturn(Mono.just("ztest")); + + + commonSpringWebClient.syncHttpResponse(clientHttpRequest); + } + + @Test + public void testHttpStatusCodeException() { + + ClientHttpRequest clientHttpRequest = createClientHttpRequest(); + mockRequestBody(); + + HttpClientErrorException httpClientErrorException = Mockito.mock(HttpClientErrorException.class); + Mockito.when(httpClientErrorException.getResponseBodyAsString()).thenReturn("Page not found"); + Mockito.when(httpClientErrorException.getResponseHeaders()).thenReturn(new HttpHeaders()); + Mockito.when(httpClientErrorException.getStatusCode()).thenReturn(HttpStatus.valueOf(404)); + + Mockito.when(headersSpec.retrieve()).thenThrow(httpClientErrorException); + + commonSpringWebClient.syncHttpResponse(clientHttpRequest); + } + + @Test + public void testUnknownContentTypeException() { + ClientHttpRequest clientHttpRequest = createClientHttpRequest(); + mockRequestBody(); + + UnknownContentTypeException unknownContentTypeException = Mockito.mock(UnknownContentTypeException.class); + Mockito.when(unknownContentTypeException.getResponseBodyAsString()).thenReturn("Page not found"); + Mockito.when(unknownContentTypeException.getResponseHeaders()).thenReturn(new HttpHeaders()); + Mockito.when(unknownContentTypeException.getRawStatusCode()).thenReturn(415); + + Mockito.when(headersSpec.retrieve()).thenThrow(unknownContentTypeException); + + commonSpringWebClient.syncHttpResponse(clientHttpRequest); + } + + @Test + public void testOtherException() { + ClientHttpRequest clientHttpRequest = createClientHttpRequest(); + mockRequestBody(); + + Mockito.when(headersSpec.retrieve()).thenThrow(IllegalArgumentException.class); + + commonSpringWebClient.syncHttpResponse(clientHttpRequest); + } + + private ClientHttpRequest createClientHttpRequest() { + HttpHeaders httpHeadersMock = new HttpHeaders(); + Consumer httpHeadersConsumer = new Consumer() { + @Override + public void accept(HttpHeaders httpHeaders) { + return; + } + }; + httpHeadersConsumer.accept(httpHeadersMock); + + return ClientHttpRequest.builder() + .httpMethod(HttpMethod.GET) + .url("test-url") + .requestHeaders(httpHeadersMock) + .requestType(ParameterizedTypeReference.forType(String.class)) + .request("hello") + .responseType(ParameterizedTypeReference.forType(String.class)) + .build(); + } + + private void mockRequestBody() { + Mockito.when(webClient.method(HttpMethod.GET)).thenReturn(requestBodyUriSpec); + Mockito.doReturn(requestBodyUriSpec).when(requestBodyUriSpec).uri("test-url"); + Mockito.when(requestBodyUriSpec.headers(Mockito.any())).thenReturn(requestBodySpec); + Mockito.when(requestBodySpec.body("hello", ParameterizedTypeReference.forType(String.class))).thenReturn(headersSpec); + } +} diff --git a/src/test/java/com/intuit/springwebclient/config/WebClientConfigurationTest.java b/src/test/java/com/intuit/springwebclient/config/WebClientConfigurationTest.java new file mode 100644 index 0000000..e9d1106 --- /dev/null +++ b/src/test/java/com/intuit/springwebclient/config/WebClientConfigurationTest.java @@ -0,0 +1,57 @@ +package com.intuit.springwebclient.config; + +import com.intuit.springwebclient.filter.WebClientRequestFilter; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.reactive.function.client.*; +import reactor.core.publisher.Mono; +import reactor.netty.resources.ConnectionProvider; + + +@ExtendWith(MockitoExtension.class) +public class WebClientConfigurationTest { + + @Mock + private SpringWebClientConfig springWebClientConfig; + @Mock + private WebClientRequestFilter webClientRequestFilter; + + @InjectMocks + private WebClientConfiguration webClientConfiguration; + + @Test + public void testWebClientConnectionProvider() { + HttpConnectionPoolConfig poolConfig = new HttpConnectionPoolConfig(); + poolConfig.setMaxConnections(200); + poolConfig.setMaxIdleTime(2000L); + poolConfig.setMaxLifeTime(3500L); + poolConfig.setPendingAcquireTimeout(1500L); + + Mockito.when(springWebClientConfig.getConnectionPool()).thenReturn(poolConfig); + + ConnectionProvider connectionProvider = webClientConfiguration.webClientConnectionProvider(); + Assertions.assertEquals(200, connectionProvider.maxConnections()); + + HttpClientConfig httpClientConfig = new HttpClientConfig(); + httpClientConfig.setConnectTimeoutMillis(5000); + httpClientConfig.setConnectTimeoutMillis(3000); + + + Mockito.when(springWebClientConfig.getHttpClientConfig()).thenReturn(httpClientConfig); + + Mockito.when(webClientRequestFilter.getFilter()).thenReturn(new ExchangeFilterFunction() { + @Override + public Mono filter(ClientRequest request, ExchangeFunction next) { + return null; + } + }); + + WebClient webClient = webClientConfiguration.createWebClient(); + + } +}