diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b67a4c1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "weekly" + day: "monday" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e0d39cc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,36 @@ +name: PR Validation + +on: + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up JDK 11 + uses: actions/setup-java@v2 + with: + java-version: '11' + distribution: 'adopt' + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b + - name: Cache Gradle packages + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Build with Gradle + run: ./gradlew build --info -Dorg.gradle.daemon=false + - name: Cleanup Gradle Cache + # Remove some files from the Gradle cache, so they aren't cached by GitHub Actions. + # Restoring these files from a GitHub Actions cache might cause problems for future builds. + run: | + rm -f ~/.gradle/caches/modules-2/modules-2.lock + rm -f ~/.gradle/caches/modules-2/gc.properties diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..905c187 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,45 @@ +name: Publish to Github Packages on Release +on: + release: + branches: + - main + types: + - created +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-java@v2 + with: + java-version: '11' + distribution: 'adopt' + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b + - name: Cache Gradle packages + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Build with Gradle + run: ./gradlew build --info -Dorg.gradle.daemon=false + - name: Cleanup Gradle Cache + # Remove some files from the Gradle cache, so they aren't cached by GitHub Actions. + # Restoring these files from a GitHub Actions cache might cause problems for future builds. + run: | + rm -f ~/.gradle/caches/modules-2/modules-2.lock + rm -f ~/.gradle/caches/modules-2/gc.properties + - name: Publish package + run: | + NEW_VERSION=$(echo "${GITHUB_REF}" | cut -d "/" -f3) + echo "Publishing new version: ${NEW_VERSION}" + ./gradlew -Pversion=${NEW_VERSION} publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54f1b35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +*.class +*.log +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.war +*.ear +#*.jar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# output +target/ +build/ +/bin/ +out/ + +# eclipse +.classpath +.project +.settings/ + +# idea +*.iml +*.ipr +*.iws +.idea +.DS_Store +.gradle/ \ No newline at end of file diff --git a/.whitesource b/.whitesource new file mode 100644 index 0000000..7a52500 --- /dev/null +++ b/.whitesource @@ -0,0 +1,3 @@ +{ + "settingsInheritedFrom": "whitesource-config/whitesource-config@main" +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..ccaaee8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,33 @@ +# Contributor Covenant Code of Conduct + +# Our Pledge +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +# Our Standards +Examples of behavior that contributes to creating a positive environment include: +Using welcoming and inclusive language +Being respectful of differing viewpoints and experiences +Gracefully accepting constructive criticism +Focusing on what is best for the community +Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: +The use of sexualized language or imagery and unwelcome sexual attention or advances +Trolling, insulting/derogatory comments, and personal or political attacks +Public or private harassment +Publishing others' private information, such as a physical or electronic address, without explicit permission +Other conduct which could reasonably be considered inappropriate in a professional setting + +# Our Responsibilities +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +# Scope +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +# Enforcement +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at TTS-OpenSource-Office@target.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +# Attribution +This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..49aeae1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Contributing to reuse + +### Issues + +Feel free to submit bugs or feature requests as issues. + +### Pull Requests + +These rules must be followed for any contributions to be merged into master. + +1. Fork this repo +1. Make any desired changes +1. Validate your changes meet your desired use case +1. Ensure documentation has been updated +1. Open a pull request diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..cb490df --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,20 @@ +Copyright (c) 2021 Target Brands, Inc. + + +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..8c86a24 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# Token Manager for Salesforce + +This project makes Salesforce API calls with Spring a breeze. It exposes either a RestTemplate or WebClient that handles generating, refreshing and attaching an authorization token header for every API call. Just pass in your instance and user credentials via `application.yml`, autowire your desired bean and it takes care of the rest. + +## Usage + +To use this library, the following repository declaration and one of the dependencies to your project's `build.gradle` file. This will include the core module as well automatically. + +```groovy +repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/target/token-manager-for-salesforce") + credentials { + username = project.findProperty("gpr.user") ?: System.getenv("USERNAME") + password = project.findProperty("gpr.token") ?: System.getenv("TOKEN") + } + } +} +dependencies { + // for reactive applications + implementation "com.tgt.crm:token-manager-for-salesforce-webflux:${libraryVersion}" + // for non-reactive applications + implementation "com.tgt.crm:token-manager-for-salesforce-webmvc:${libraryVersion}" +} +``` + +Find the latest version in the "packages" section of this repo. You will need access to the Github repository and will need to make your user and a personal access token available as a project or system property to authorize with Github and download the package. More details can be found [here](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry). + +Add the following to your `application.yml` file to pass in Salesforce properties from TAP secrets/environment variables. You can set explicit values for these properties instead of environment variables if you prefer. Ensure you don't check any secrets into Git. + +```yaml +salesforce: + host: ${SALESFORCE_HOST} + username: ${SALESFORCE_USERNAME} + password: ${SALESFORCE_PASSWORD} + client-id: ${SALESFORCE_CLIENT_ID} + client-secret: ${SALESFORCE_CLIENT_SECRET} + auth-uri: /services/oauth2/token # optional + retry-backoff-delay: 1000 # optional, configures retry for auth token requests only + max-auth-token-retries: 3 # optional, configures retry for auth token requests only + retry-backoff-multiplier: 2 # optional, configures retry for auth token requests only, only used by MVC, see SalesforceConfig for more info + httpclient: + max-conn-per-route: 20 # optional + read-timeout: 30000 # optional, in milliseconds + connection-timeout: 60000 # optional, in milliseconds + mvc: # configs in this block only available for webmvc + max-pools: 50 # optional + connection-request-timeout: 30000 # optional, in milliseconds + retries: 3 # optional, MVC only, configures default # of retries for all requests except auth token + retry-interval: 2000 # optional, in milliseconds, configures default retry interval for all requests except auth token +``` + +You should then be able to autowire the RestTemplate or WebClient bean in any component in your project and use it to make API calls to Salesforce. + +```java +@Service +public class SalesforceClient { + + private final RestTemplate salesforceRestTemplate; + + public SalesforceClient(@Qualifier("sfRestTemplate") final RestTemplate salesForceRestTemplate) { + this.salesForceRestTemplate = salesForceRestTemplate; + } + + public String querySalesforce() { + ResponseEntity sfResponse = + salesForceRestTemplate.exchange( + "/services/data/v50.0/query&q={query}", + HttpMethod.GET, + HttpEntity.EMPTY, + String.class, + "SELECT Id FROM Case"); + + return sfResponse.getBody(); + } +} +``` + +```java +@Service +public class SalesforceClient { + + private final WebClient salesforceWebClient; + + public SalesforceClient(@Qualifier("sfWebClient") final WebClient salesforceWebClient) { + this.salesforceWebClient = salesforceWebClient; + } + + public Mono querySalesforce() { + return webClient + .get() + .uri("/services/data/v50.0/query&q={query}", "SELECT Id FROM Case") + .retrieve() + .toEntity(String.class) + .map(HttpEntity::getBody); + } +} +``` + +### Minimum Requirements + +In your project, the following minimum versions of Spring Boot are required to use this library: + +* token-manager-for-salesforce-webflux: Spring Boot > 2.2.6.RELEASE + * Spring Boot > 2.4.0 is recommended to support Wiretap for WebClient debug logging +* token-manager-for-salesforce-webmvc: Spring Boot > 2.2.0.RELEASE + +### Metrics + +The application also emits one micrometer metric. If a token refresh fails, a counter is incremented. The counter is called `exception_counter` and has one tag `exception_type` with value `token_refresh_exception`. This can be used to set up an alert in Grafana if a token refresh ever fails. + +### How does it work? + +This library follows the [OAuth 2.0 Username-Password Flow](https://help.salesforce.com/articleView?id=remoteaccess_oauth_username_password_flow.htm&type=5) and is intended to be used with first-party applications. + +When your application starts up it will not have a token. The first time it makes an API call, the library intercepts the request, makes the appropriate API call to `/services/oauth2/token` for your configured instance as documented [here](https://help.salesforce.com/articleView?id=remoteaccess_oauth_endpoints.htm&type=5). The generated token is attached to the request as an `Authorization` header and the request proceeds. It is also cached in memory. + +Subsequent requests use the cached token and do not try to request a new token unless a 401 response is received. When a 401 is received, it attempts to generate a new token and retries the request. We use this behavior because Salesforce auth tokens do not return an `expires_in` property and the length of time they are valid for can vary from instance to instance based on admin settings. + +### Debugging Requests + +It is possible and occasionally useful to log complete HTTP requests and responses including URLs, query params, headers and bodies. Be careful as this has the potential to expose sensitive data such as passwords, auth tokens or API keys. It is recommended to only use this when running the application locally. + +To enable request/response logging for either mvc or webflux library, add the following to your `application.yml`: + +```yaml +logging: + level: + com.tgt.crm.token: TRACE + org.springframework.web.client.RestTemplate: DEBUG # additional RestTemplate debug logs, only applies to MVC. WARNING: logs sensitive info + org.apache.http: DEBUG # additional detailed logging for RestTemplate, only applies to MVC. WARNING: logs sensitive info + reactor.netty: TRACE # additional detailed logging for WebClient, only applies to WebFlux +``` + +Note that setting log level to `DEBUG` will enable all debug logging except request/response logging. This is by design to help prevent unintentional sensitive data exposure. + +## Local Development + +If you make changes to this library locally and want to test those changes with another application, you can use [Gradle Composite Builds](https://docs.gradle.org/current/userguide/composite_builds.html). Essentially you just tell your other application to point to this project on your local file system instead of downloading the dependency from a repository. + +In the `settings.gradle` file in the root of the other project (you may have to create the file if it doesn't exist in that project), add the following line: + +``` +includeBuild '../token-manager-for-salesforce' +``` + +Where `../token-manager-for-salesforce` is the relative path to this project on your local file system. Now when you build the other project, it should use your local copy of `token-manager-for-salesforce`. Be sure to remove this change before committing. + +## Publish a new version + +Make the desired changes locally. Open a PR against the master branch, get it reviewed and merge. Tag this commit with a tag following [semantic versioning](https://semver.org/). This will trigger a new deployment of the library to [Github Packages](https://github.com/orgs/target/packages?repo_name=token-manager-for-salesforce). + +## Troubleshooting + +Problem: When I try to start my application I am getting a `NoClassDefFoundError` or a `NoSuchMethodError`. + +Solution: Check the version of Spring Boot you are using in your app and that it meets the minimum requirement listed above in the README. + +## License + +token-manager-for-salesforce is licensed under the [MIT License](LICENSE.md) + +Salesforce is a trademark of Salesforce.com, inc., and is used here with permission. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..6341b13 --- /dev/null +++ b/build.gradle @@ -0,0 +1,123 @@ +buildscript { + ext { + // libraries + springBootVersion = '2.5.3' + lombokVersion = '1.18.20' + apacheCommonsVersion = '3.12.0' + hibernateValidatorVersion = '6.1.7.Final' + mockWebserverVersion = '4.9.1' + spotbugsAnnotationsVersion = '4.3.0' + httpComponentsVersion = '4.5.13' + reactorTestVersion = '3.4.8' + + // plugins + spotlessPluginVersion = '5.14.2' + dependencyManagementPluginVersion = '1.0.11.RELEASE' + dependencyUpdatesPluginVersion = '0.39.0' + jacocoPluginVersion = '0.8.7' + libraryGradlePluginVersion = '2.1.0' + qualityPluginVersion = '4.6.0' + } + repositories { + mavenCentral() + google() + } +} + +plugins { + id 'java-library' + id 'idea' + id 'jacoco' + id 'maven-publish' + id 'com.github.ben-manes.versions' version "${dependencyUpdatesPluginVersion}" + id 'com.diffplug.spotless' version "${spotlessPluginVersion}" + id 'io.spring.dependency-management' version "${dependencyManagementPluginVersion}" + id 'ru.vyarus.quality' version "${qualityPluginVersion}" +} + +apply from: 'gradle/spotless.gradle' + +subprojects { + group 'com.tgt.crm' + sourceCompatibility = 11 + + repositories { + mavenCentral() + google() + } + + jar { + enabled = true + } + + apply plugin: 'java-library' + apply plugin: 'io.spring.dependency-management' + apply plugin: 'jacoco' + apply plugin: 'idea' + apply plugin: 'maven-publish' + apply plugin: 'ru.vyarus.quality' + apply plugin: 'com.diffplug.spotless' + + apply from: '../gradle/spotless.gradle' + apply from: '../gradle/checks.gradle' + + publishing { + publications { + gpr(MavenPublication) { + from(components.java) + } + } + repositories { + maven { + name = "GitHubPackages" + url = "https://maven.pkg.github.com/target/token-manager-for-salesforce" + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } + } + + dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:${springBootVersion}") + } + } + + test { + // use junit5 + useJUnitPlatform() + } + + dependencies { + // used to copy & log headers on requests if debug enabled + implementation "org.apache.commons:commons-lang3:${apacheCommonsVersion}" + // used for bean validation + implementation "org.hibernate.validator:hibernate-validator:${hibernateValidatorVersion}" + implementation "org.apache.httpcomponents:httpclient:${httpComponentsVersion}" + implementation 'io.micrometer:micrometer-core' + + compileOnly "org.projectlombok:lombok:${lombokVersion}" + annotationProcessor "org.projectlombok:lombok:${lombokVersion}" + testCompileOnly "org.projectlombok:lombok:${lombokVersion}" + testAnnotationProcessor "org.projectlombok:lombok:${lombokVersion}" + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "com.squareup.okhttp3:mockwebserver:${mockWebserverVersion}" + testImplementation "com.squareup.okhttp3:okhttp:${mockWebserverVersion}" + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + rejectVersionIf { + isNonStable(it.candidate.version) + } +} diff --git a/example-projects/README.md b/example-projects/README.md new file mode 100644 index 0000000..3572c8d --- /dev/null +++ b/example-projects/README.md @@ -0,0 +1,61 @@ +# Example Projects + +Since these are standalone projects distinct from the `token-manager-for-salesforce project`, you must help IntelliJ recognize them. Right-click on the `settings.gradle` file within each project and select the "Import Gradle Project" option. + +These small sample projects each expose a single Rest endpoint `GET /salesforce/query?q={SOQL QUERY HERE}`. This endpoint takes in a SOQL query as the query parameter and executes this query against the configured Salesforce instance. It uses the `token-manager-for-salesforce` library to handle authenticating with the Salesforce instance to execute the request. It calls the Salesforce [SOQL Query API](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm) and simply passes through the response. + +Note that in `settings.gradle` we use [Gradle Composite Builds](https://docs.gradle.org/current/userguide/composite_builds.html) to point to the local version of this library. This way you can use this project to easily test changes to the library. If you'd prefer to point to a deployed version, simple remove the `includeBuild '../../../token-manager-for-salesforce'` line from `settings.gradle`. + +### How to Run + +To run the application you will need to pass in environment variables with credentials for your Salesforce environment. In [application.yml](webflux-example/src/main/resources/application.yml) you can find the required secrets. + +You can use IntelliJ's run configurations to set environment variables or run the application directly from the commandline using Gradle. Ensure you are in the example project's directory and execute, substituting in your real secrets. + +```shell +./gradlew bootRun --args='--salesforce.host=secret_host --salesforce.username=secret_username --salesforce.password=secret_password --salesforce.client-id=secret_id --salesforce.client-secret=secret_secret' +``` + +Examples of what these values may look like for your org: + +``` +SF_HOST: https://your_org--sandbox_name.my.salesforce.com +SF_USERNAME: some_username@your_org.com.service.sandbox_name +SF_PASSWORD: password for the above account +SF_CLIENT_ID: long string of numbers and letters +SF_CLIENT_SECRET: long string of numbers and letters +``` + +If you already have a connected app set up, you can find the client ID and client secret in Setup > App Manager > Right click, View on your connected app. + +If you do not have a connected app, you can follow instructions [here](https://help.salesforce.com/articleView?id=sf.connected_app_create_api_integration.htm&type=5) to create a new one. Fill out the basic Connected App information and use the following for the oAuth settings to get started. You may want to customize these later to limit access. After you create the connected app, a client ID and secret will be generated. + +![Connected App oAuth Settings](media/connected_app_oauth_settings.png) + +### How to Test + +Once you have the application running, you should be able to execute the following cURL to hit the Rest endpoint: + +```shell +curl --request GET \ + --url 'http://localhost:8080/salesforce/query?q=SELECT%20ID%2C%20Name%20FROM%20ACCOUNT%20LIMIT%201' +``` + +Note that the SOQL query is URL encoded. The query being executed is: `SELECT ID, Name FROM ACCOUNT LIMIT 1`. You should get a response with something like: + +```json +{ + "totalSize": 1, + "done": true, + "records": [ + { + "attributes": { + "type": "Account", + "url": "/services/data/v51.0/sobjects/Account/001P000001opKSMIA2" + }, + "Id": "001P000001opKSMIA2", + "Name": "Francis Johnson" + } + ] +} +``` diff --git a/example-projects/media/connected_app_oauth_settings.png b/example-projects/media/connected_app_oauth_settings.png new file mode 100644 index 0000000..b269c21 Binary files /dev/null and b/example-projects/media/connected_app_oauth_settings.png differ diff --git a/example-projects/webflux-example/build.gradle b/example-projects/webflux-example/build.gradle new file mode 100644 index 0000000..091c7f8 --- /dev/null +++ b/example-projects/webflux-example/build.gradle @@ -0,0 +1,46 @@ +buildscript { + ext { + springBootVersion = '2.2.6.RELEASE' + tokenManagerVersion = '1.0.0' + dependencyManagementVersion = '1.0.11.RELEASE' + lombokVersion = '1.18.20' + } + repositories { + mavenCentral() + google() + } + dependencies { + classpath "org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}" + } +} + +plugins { + id 'java' + id 'org.springframework.boot' version "${springBootVersion}" + id 'io.spring.dependency-management' version "${dependencyManagementVersion}" +} + +group = 'com.tgt.crm.webflux' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '11' + +repositories { + mavenCentral() + google() + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/target/token-manager-for-salesforce") + credentials { + username = project.findProperty("gpr.user") ?: System.getenv("USERNAME") + password = project.findProperty("gpr.token") ?: System.getenv("TOKEN") + } + } +} + +dependencies { + implementation "com.tgt.crm:token-manager-for-salesforce-webflux:${tokenManagerVersion}" + implementation 'org.springframework.boot:spring-boot-starter-webflux' + compileOnly "org.projectlombok:lombok:${lombokVersion}" + annotationProcessor "org.projectlombok:lombok:${lombokVersion}" +} + diff --git a/example-projects/webflux-example/gradle/wrapper/gradle-wrapper.jar b/example-projects/webflux-example/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/example-projects/webflux-example/gradle/wrapper/gradle-wrapper.jar differ diff --git a/example-projects/webflux-example/gradle/wrapper/gradle-wrapper.properties b/example-projects/webflux-example/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..549d844 --- /dev/null +++ b/example-projects/webflux-example/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.9-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/example-projects/webflux-example/gradlew b/example-projects/webflux-example/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/example-projects/webflux-example/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/example-projects/webflux-example/gradlew.bat b/example-projects/webflux-example/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/example-projects/webflux-example/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/example-projects/webflux-example/settings.gradle b/example-projects/webflux-example/settings.gradle new file mode 100644 index 0000000..519a840 --- /dev/null +++ b/example-projects/webflux-example/settings.gradle @@ -0,0 +1,3 @@ +rootProject.name = 'webflux-example' + +includeBuild '../../../token-manager-for-salesforce' diff --git a/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/WebfluxTokenManagerExample.java b/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/WebfluxTokenManagerExample.java new file mode 100644 index 0000000..c4c8c03 --- /dev/null +++ b/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/WebfluxTokenManagerExample.java @@ -0,0 +1,12 @@ +package com.tgt.crm.webflux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class WebfluxTokenManagerExample { + + public static void main(String[] args) { + SpringApplication.run(WebfluxTokenManagerExample.class, args); + } +} diff --git a/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/client/SalesforceClient.java b/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/client/SalesforceClient.java new file mode 100644 index 0000000..f8caea2 --- /dev/null +++ b/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/client/SalesforceClient.java @@ -0,0 +1,41 @@ +package com.tgt.crm.webflux.client; + +import com.tgt.crm.webflux.vo.QueryResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; + +@Service +@Slf4j +public class SalesforceClient { + + private static final String SF_BASE_URL = "/services/data/v51.0"; + private static final String SF_QUERY_URL = SF_BASE_URL + "/query?q={query}"; + + private final WebClient salesforceWebClient; + + public SalesforceClient(@Qualifier("sfWebClient") final WebClient salesforceWebClient) { + this.salesforceWebClient = salesforceWebClient; + } + + public Mono executeQuery(final String query) { + + log.info("executing query to Salesforce: {}", query); + + return salesforceWebClient + .get() + .uri(SF_QUERY_URL, query) + .retrieve() + .bodyToMono(QueryResponse.class) + .doOnError( + err -> err instanceof WebClientResponseException, + err -> + log.error( + "error message: {}", + ((WebClientResponseException) err).getResponseBodyAsString())) + .doOnSuccess(response -> log.info("query response: {}", response)); + } +} diff --git a/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/controller/WebfluxController.java b/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/controller/WebfluxController.java new file mode 100644 index 0000000..eee9f74 --- /dev/null +++ b/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/controller/WebfluxController.java @@ -0,0 +1,26 @@ +package com.tgt.crm.webflux.controller; + +import com.tgt.crm.webflux.client.SalesforceClient; +import com.tgt.crm.webflux.vo.QueryResponse; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@AllArgsConstructor +@RequestMapping("/salesforce") +@Slf4j +public class WebfluxController { + + private final SalesforceClient salesforceClient; + + @GetMapping("/query") + public Mono querySalesforce(@RequestParam final String q) { + + return salesforceClient.executeQuery(q); + } +} diff --git a/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/vo/QueryResponse.java b/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/vo/QueryResponse.java new file mode 100644 index 0000000..5e96a8f --- /dev/null +++ b/example-projects/webflux-example/src/main/java/com/tgt/crm/webflux/vo/QueryResponse.java @@ -0,0 +1,21 @@ +package com.tgt.crm.webflux.vo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class QueryResponse { + + @JsonProperty("totalSize") + private int totalSize; + + @JsonProperty("done") + private boolean done; + + @JsonProperty("records") + private List> records; +} diff --git a/example-projects/webflux-example/src/main/resources/application.yml b/example-projects/webflux-example/src/main/resources/application.yml new file mode 100644 index 0000000..97b1fc1 --- /dev/null +++ b/example-projects/webflux-example/src/main/resources/application.yml @@ -0,0 +1,15 @@ +salesforce: + host: ${SF_HOST} + username: ${SF_USERNAME} + password: ${SF_PASSWORD} + client-id: ${SF_CLIENT_ID} + client-secret: ${SF_CLIENT_SECRET} + httpclient: + max-conn-per-route: 20 + read-timeout: 30000 + connection-timeout: 60000 + +logging: + level: + com.tgt.crm.token: TRACE + reactor.netty: TRACE diff --git a/example-projects/webmvc-example/build.gradle b/example-projects/webmvc-example/build.gradle new file mode 100644 index 0000000..c2fdb23 --- /dev/null +++ b/example-projects/webmvc-example/build.gradle @@ -0,0 +1,46 @@ +buildscript { + ext { + springBootVersion = '2.2.0.RELEASE' + tokenManagerVersion = '1.0.0' + dependencyManagementVersion = '1.0.11.RELEASE' + lombokVersion = '1.18.20' + } + repositories { + mavenCentral() + google() + } + dependencies { + classpath "org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}" + } +} + +plugins { + id 'java' + id 'org.springframework.boot' version "${springBootVersion}" + id 'io.spring.dependency-management' version "${dependencyManagementVersion}" +} + +group = 'com.tgt.crm.mvc' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '11' + +repositories { + mavenCentral() + google() + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/target/token-manager-for-salesforce") + credentials { + username = project.findProperty("gpr.user") ?: System.getenv("USERNAME") + password = project.findProperty("gpr.token") ?: System.getenv("TOKEN") + } + } +} + +dependencies { + implementation "com.tgt.crm:token-manager-for-salesforce-webmvc:${tokenManagerVersion}" + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly "org.projectlombok:lombok:${lombokVersion}" + annotationProcessor "org.projectlombok:lombok:${lombokVersion}" +} + diff --git a/example-projects/webmvc-example/gradle/wrapper/gradle-wrapper.jar b/example-projects/webmvc-example/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/example-projects/webmvc-example/gradle/wrapper/gradle-wrapper.jar differ diff --git a/example-projects/webmvc-example/gradle/wrapper/gradle-wrapper.properties b/example-projects/webmvc-example/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..549d844 --- /dev/null +++ b/example-projects/webmvc-example/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.9-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/example-projects/webmvc-example/gradlew b/example-projects/webmvc-example/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/example-projects/webmvc-example/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/example-projects/webmvc-example/gradlew.bat b/example-projects/webmvc-example/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/example-projects/webmvc-example/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/example-projects/webmvc-example/settings.gradle b/example-projects/webmvc-example/settings.gradle new file mode 100644 index 0000000..8ec4d47 --- /dev/null +++ b/example-projects/webmvc-example/settings.gradle @@ -0,0 +1,3 @@ +rootProject.name = 'webmvc-example' + +includeBuild '../../../token-manager-for-salesforce' diff --git a/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/MvcTokenManagerExample.java b/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/MvcTokenManagerExample.java new file mode 100644 index 0000000..e51858a --- /dev/null +++ b/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/MvcTokenManagerExample.java @@ -0,0 +1,12 @@ +package com.tgt.crm.mvc; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MvcTokenManagerExample { + + public static void main(String[] args) { + SpringApplication.run(MvcTokenManagerExample.class, args); + } +} diff --git a/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/client/SalesforceClient.java b/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/client/SalesforceClient.java new file mode 100644 index 0000000..fb764cd --- /dev/null +++ b/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/client/SalesforceClient.java @@ -0,0 +1,42 @@ +package com.tgt.crm.mvc.client; + +import com.tgt.crm.mvc.vo.QueryResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +@Slf4j +public class SalesforceClient { + + private static final String SF_BASE_URL = "/services/data/v51.0"; + private static final String SF_QUERY_URL = SF_BASE_URL + "/query?q={query}"; + + private final RestTemplate salesforceRestTemplate; + + public SalesforceClient(@Qualifier("sfRestTemplate") final RestTemplate salesforceRestTemplate) { + this.salesforceRestTemplate = salesforceRestTemplate; + } + + public QueryResponse executeQuery(final String query) { + + log.info("executing query to Salesforce: {}", query); + + ResponseEntity sfResponse = + salesforceRestTemplate.exchange( + SF_QUERY_URL, + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference<>() {}, + query); + + log.info("query response: {}", sfResponse); + + return sfResponse.getBody(); + } +} diff --git a/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/controller/MvcController.java b/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/controller/MvcController.java new file mode 100644 index 0000000..ce40241 --- /dev/null +++ b/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/controller/MvcController.java @@ -0,0 +1,28 @@ +package com.tgt.crm.mvc.controller; + +import com.tgt.crm.mvc.client.SalesforceClient; +import com.tgt.crm.mvc.vo.QueryResponse; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@RequestMapping("/salesforce") +@Slf4j +public class MvcController { + + private final SalesforceClient salesforceClient; + + @GetMapping("/query") + public ResponseEntity querySalesforce(@RequestParam final String q) { + + QueryResponse response = salesforceClient.executeQuery(q); + + return ResponseEntity.ok(response); + } +} diff --git a/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/vo/QueryResponse.java b/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/vo/QueryResponse.java new file mode 100644 index 0000000..64cacd4 --- /dev/null +++ b/example-projects/webmvc-example/src/main/java/com/tgt/crm/mvc/vo/QueryResponse.java @@ -0,0 +1,21 @@ +package com.tgt.crm.mvc.vo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class QueryResponse { + + @JsonProperty("totalSize") + private int totalSize; + + @JsonProperty("done") + private boolean done; + + @JsonProperty("records") + private List> records; +} diff --git a/example-projects/webmvc-example/src/main/resources/application.yml b/example-projects/webmvc-example/src/main/resources/application.yml new file mode 100644 index 0000000..b0ad32e --- /dev/null +++ b/example-projects/webmvc-example/src/main/resources/application.yml @@ -0,0 +1,22 @@ +salesforce: + host: ${SF_HOST} + username: ${SF_USERNAME} + password: ${SF_PASSWORD} + client-id: ${SF_CLIENT_ID} + client-secret: ${SF_CLIENT_SECRET} + httpclient: + max-conn-per-route: 20 + read-timeout: 30000 + connection-timeout: 60000 + webmvc: + max-pools: 50 + connection-request-timeout: 30000 + retries: 3 + retry-interval: 2000 + +logging: + level: + com.tgt.crm.token: TRACE + org.springframework.web: TRACE + org.apache.http: DEBUG + diff --git a/gradle/checks.gradle b/gradle/checks.gradle new file mode 100644 index 0000000..34324cf --- /dev/null +++ b/gradle/checks.gradle @@ -0,0 +1,145 @@ +quality { + + /** + * When disabled, quality plugins will not be registered automatically (according to sources). + * Only manually registered quality plugins will be configured. + */ + autoRegistration = true + + // Enable/disable tools (when auto registration disabled control configuration appliance) + + checkstyle = false + pmd = true + cpd = false + spotbugs = true + codenarc = false + + /** + * Enable PMD incremental analysis (cache results between builds to speed up processing). + * This is a shortcut for pmd plugin's {@code pmd.incrementalAnalysis } configuration option. + * Option is disabled by default due to possible side effects with build gradle cache or incremental builds. + */ + pmdIncremental = false + + /** + * The analysis effort level. The value specified should be one of min, default, or max. + * Higher levels increase precision and find more bugs at the expense of running time and + * memory consumption. Default is 'max'. + */ + spotbugsEffort = 'max' + + /** + * The priority threshold for reporting bugs. If set to low, all bugs are reported. + * If set to medium, medium and high priority bugs are reported. + * If set to high, only high priority bugs are reported. Default is 'medium'. + */ + spotbugsLevel = 'medium' + + /** + * Spotbugs rank should be an integer value between 1 and 20, where 1 to 4 are scariest, 5 to 9 scary, + * 10 to 14 troubling, and 15 to 20 of concern bugs. + *

+ * This option allows you to filter low-priority ranks: for example, setting {@code spotbugsMaxRank=15} will + * filter all bugs with ranks 16-20. Note that this is not the same as {@link #spotbugsLevel}: + * it has a bit different meaning (note that both priority and rank are shown for each spotbugs + * violation in console). + *

+ * The only way to apply rank filtering is through exclude filter. Plugin will automatically generate + * additional rule in your exclude filter or in default one. But it may conflict with manual rank rule declaration + * (in case if you edit exclude filter manually), so be careful when enabling this option. + */ + spotbugsMaxRank = 20 + + /** + * Max memory available for spotbugs task. Note that in gradle 4 spotbugs task maximum memory was + * 1/4 of physical memory, but in gradle 5 it become only 512mb (default for workers api). + * To minify impact of this gradle 5 change, default value in extension is 1g now, but it may be not + * enough for large projects (and so you will have to increase it manually). + *

+ * IMPORTANT: setting will not work if heap size configured directly in spotbugs task (for example, with + * spotbugsMain.maxHeapSize = '2g'. This was done in order to not break current behaviour + * (when task memory is already configured) and affect only default cases (mostly caused by gradle 5 transition). + *

+ * See: https://github.com/gradle/gradle/issues/6216 (Reduce default memory settings for daemon and + * workers). + */ + spotbugsMaxHeapSize = '2g' + + /** + * Javac lint options to show compiler warnings, not visible by default. + * Applies to all CompileJava tasks. + * Options will be added as -Xlint:option + * Full list of options: http://docs.oracle.com/javase/8/docs/technotes/tools/windows/javac.html#BHCJCABJ + */ + lintOptions = ['deprecation', 'unchecked'] + + /** + * Strict quality leads to build fail on any violation found. If disabled, all violation + * are just printed to console. + */ + strict = true + + /** + * When false, disables quality tasks execution. Allows disabling tasks without removing plugins. + * Quality tasks are still registered, but skip execution, except when task called directly or through + * checkQualityMain (or other source set) grouping task. + */ + enabled = true + + /** + * When false, disables reporting quality issues to console. Only gradle general error messages will + * remain in logs. This may be useful in cases when project contains too many warnings. + * Also, console reporting require xml reports parsing, which could be time consuming in case of too + * many errors (large xml reports). + * True by default. + */ + consoleReporting = true + + /** + * When false, no html reports will be built. True by default. + */ + htmlReports = true + + /** + * Source sets to apply checks on. + * Default is [sourceSets.main] to apply only for project sources, excluding tests. + */ + sourceSets = project.sourceSets + + /** + * Source patterns (relative to source dir) to exclude from checks. Simply sets exclusions to quality tasks. + * + * Animalsniffer is not affected because + * it's a different kind of check (and, also, it operates on classes so source patterns may not comply). + * + * Spotbugs does not support exclusion directly, but plugin will resolve excluded classes and apply + * them to xml exclude file (default one or provided by user). + * + * By default nothing is excluded. + * + * IMPORTANT: Patterns are checked relatively to source set dirs (not including them). So you can only + * match source files and packages, but not absolute file path (this is gradle specific, not plugin). + * + * @see org.gradle.api.tasks.SourceTask#exclude(java.lang.Iterable) (base class for all quality tasks) + */ + exclude = [] + + /** + * Direct sources to exclude from checks (except animalsniffer). + * This is useful as last resort, when extension or package is not enough for filtering. + * Use {@link Project#files(java.lang.Object)} or {@link Project#fileTree(java.lang.Object)} + * to create initial collections and apply filter on it (using + * {@link org.gradle.api.file.FileTree#matching(groovy.lang.Closure)}). + * + * Plugin will include files into spotbugs exclusion filter xml (default one or provided by user). + * + * Note: this must be used when excluded classes can't be extracted to different source set and + * filter by package and filename is not sufficient. + */ + FileCollection excludeSources + + /** + * User configuration files directory. Files in this directory will be used instead of default (bundled) configs. + */ + configDir = 'gradle/config/' +} diff --git a/gradle/config/pmd/pmd.xml b/gradle/config/pmd/pmd.xml new file mode 100644 index 0000000..652b04c --- /dev/null +++ b/gradle/config/pmd/pmd.xml @@ -0,0 +1,120 @@ + + + + + + Basic Java code quality rules + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle/spotless.gradle b/gradle/spotless.gradle new file mode 100644 index 0000000..a706ea9 --- /dev/null +++ b/gradle/spotless.gradle @@ -0,0 +1,44 @@ +spotless { + java { + googleJavaFormat() + } + format 'misc', { + target '**/*.md', '**/.gitignore' + trimTrailingWhitespace() + indentWithSpaces(2) + endWithNewline() + } + groovyGradle { + target 'build.gradle', 'gradle/*.gradle' + targetExclude 'out/**', 'build/**', '.idea/**', '.gradle/**' + greclipse() + trimTrailingWhitespace() + indentWithSpaces(2) + endWithNewline() + } + format 'xml', { + target fileTree('.') { + include '**/*.xml', '**/*.xsd' + exclude '**/build/**', 'out/**', '.idea/**', '**/.gradle/**', '**/pom.xml' + } + eclipseWtp('xml') + indentWithSpaces(2) + trimTrailingWhitespace() + } + format 'json', { + target '**/*.json' + targetExclude 'out/**', 'build/**', '.idea/**', '.gradle/**' + eclipseWtp('json') + indentWithSpaces(2) + trimTrailingWhitespace() + } + // requires node, not present on current docker image + // format 'yaml', { + // target '**/*.yml', '**/*.yaml' + // targetExclude 'out/**', 'build/**', '.idea/**', '.gradle/**' + // prettier().config(['parser': 'yaml']) + // trimTrailingWhitespace() + // indentWithSpaces(2) + // endWithNewline() + // } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..05679dc --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e2344df --- /dev/null +++ b/settings.gradle @@ -0,0 +1,6 @@ +rootProject.name = 'token-manager-for-salesforce' + +include 'token-manager-for-salesforce-core' +include 'token-manager-for-salesforce-webflux' +include 'token-manager-for-salesforce-webmvc' +include 'token-manager-for-salesforce-core' diff --git a/token-manager-for-salesforce-core/build.gradle b/token-manager-for-salesforce-core/build.gradle new file mode 100644 index 0000000..c818a12 --- /dev/null +++ b/token-manager-for-salesforce-core/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'java-test-fixtures' +} + +// === module dependencies === + +dependencies { + implementation "com.fasterxml.jackson.core:jackson-annotations" + implementation "org.springframework.boot:spring-boot-starter" + testFixturesImplementation "com.squareup.okhttp3:mockwebserver:${mockWebserverVersion}" + testFixturesImplementation "com.squareup.okhttp3:okhttp:${mockWebserverVersion}" + testFixturesImplementation "org.springframework.boot:spring-boot-starter-test" + testFixturesImplementation "org.springframework:spring-web" + testFixturesCompileOnly "org.projectlombok:lombok:${lombokVersion}" + testFixturesAnnotationProcessor "org.projectlombok:lombok:${lombokVersion}" + testFixturesImplementation "com.fasterxml.jackson.core:jackson-databind" + testFixturesCompileOnly "com.github.spotbugs:spotbugs-annotations:${spotbugsAnnotationsVersion}" +} diff --git a/token-manager-for-salesforce-core/settings.gradle b/token-manager-for-salesforce-core/settings.gradle new file mode 100644 index 0000000..c14f561 --- /dev/null +++ b/token-manager-for-salesforce-core/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'token-manager-for-salesforce-core' \ No newline at end of file diff --git a/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/HttpClientConfig.java b/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/HttpClientConfig.java new file mode 100644 index 0000000..e56d3f6 --- /dev/null +++ b/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/HttpClientConfig.java @@ -0,0 +1,13 @@ +package com.tgt.crm.token.core; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties("salesforce.httpclient") +public class HttpClientConfig { + + private int maxConnPerRoute = 20; + private int readTimeout = 30_000; + private int connectionTimeout = 60_000; +} diff --git a/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/SalesforceAuthResponse.java b/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/SalesforceAuthResponse.java new file mode 100644 index 0000000..7365ac3 --- /dev/null +++ b/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/SalesforceAuthResponse.java @@ -0,0 +1,35 @@ +package com.tgt.crm.token.core; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SalesforceAuthResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("instance_url") + private String instanceUrl; + + private String id; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("issued_at") + private String issuedAt; + + private String signature; + + private String error; + + @JsonProperty("error_description") + private String errorDescription; + + public String getSalesforceAuthToken() { + return tokenType + " " + accessToken; + } +} diff --git a/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/SalesforceConfig.java b/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/SalesforceConfig.java new file mode 100644 index 0000000..9432af1 --- /dev/null +++ b/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/SalesforceConfig.java @@ -0,0 +1,49 @@ +package com.tgt.crm.token.core; + +import static com.tgt.crm.token.core.SalesforceConstants.AUTH_URI; +import static com.tgt.crm.token.core.SalesforceConstants.MAX_AUTH_TOKEN_RETRIES_DEFAULT; +import static com.tgt.crm.token.core.SalesforceConstants.RETRY_BACKOFF_DELAY_DEFAULT; +import static com.tgt.crm.token.core.SalesforceConstants.RETRY_BACKOFF_MULTIPLIER_DEFAULT; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Data +@ConfigurationProperties("salesforce") +@Validated +public class SalesforceConfig { + + private static final String NOT_ENV_VAR_PATTERN = "^(?!(\\$\\{.+})$).+$"; + private static final String ENV_VAR_MISSING_MSG = "Environment variable is missing"; + + @NotBlank + @Pattern(regexp = NOT_ENV_VAR_PATTERN, message = ENV_VAR_MISSING_MSG) + private String host; + + @NotBlank + @Pattern(regexp = NOT_ENV_VAR_PATTERN, message = ENV_VAR_MISSING_MSG) + private String username; + + @NotBlank + @Pattern(regexp = NOT_ENV_VAR_PATTERN, message = ENV_VAR_MISSING_MSG) + private String password; + + @NotBlank + @Pattern(regexp = NOT_ENV_VAR_PATTERN, message = ENV_VAR_MISSING_MSG) + private String clientId; + + @NotBlank + @Pattern(regexp = NOT_ENV_VAR_PATTERN, message = ENV_VAR_MISSING_MSG) + private String clientSecret; + + private String authUri = AUTH_URI; + private int maxAuthTokenRetries = MAX_AUTH_TOKEN_RETRIES_DEFAULT; + private int retryBackoffDelay = RETRY_BACKOFF_DELAY_DEFAULT; // milliseconds + + // property only used for MVC. WebClient retry uses the backoff delay value as a min delay and + // a jitter factor to randomize retry delays instead of a fixed multiplier + private int retryBackoffMultiplier = RETRY_BACKOFF_MULTIPLIER_DEFAULT; +} diff --git a/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/SalesforceConstants.java b/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/SalesforceConstants.java new file mode 100644 index 0000000..3c35099 --- /dev/null +++ b/token-manager-for-salesforce-core/src/main/java/com/tgt/crm/token/core/SalesforceConstants.java @@ -0,0 +1,15 @@ +package com.tgt.crm.token.core; + +@SuppressWarnings("PMD.LongVariable") +public final class SalesforceConstants { + + public static final String AUTH_URI = "/services/oauth2/token"; + public static final String EXCEPTION_COUNTER = "exception_counter"; + public static final String EXCEPTION_TYPE_TAG = "exception_type"; + public static final String TOKEN_REFRESH_EXCEPTION = "token_refresh_exception"; + public static final int MAX_AUTH_TOKEN_RETRIES_DEFAULT = 3; + public static final int RETRY_BACKOFF_DELAY_DEFAULT = 1000; + public static final int RETRY_BACKOFF_MULTIPLIER_DEFAULT = 2; + + private SalesforceConstants() {} +} diff --git a/token-manager-for-salesforce-core/src/testFixtures/java/com/tgt/crm/token/core/BaseIntegrationTest.java b/token-manager-for-salesforce-core/src/testFixtures/java/com/tgt/crm/token/core/BaseIntegrationTest.java new file mode 100644 index 0000000..f2fe682 --- /dev/null +++ b/token-manager-for-salesforce-core/src/testFixtures/java/com/tgt/crm/token/core/BaseIntegrationTest.java @@ -0,0 +1,73 @@ +package com.tgt.crm.token.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD) +public class BaseIntegrationTest { + + @SuppressFBWarnings("MS_PKGPROTECT") + protected static MockWebServer mockWebServer; + + protected static final int TIMEOUT = 5; // seconds + protected static final String QUERY_SUCCESSFUL = "query successful"; + protected static final String SF_URL = "/some/sf/url"; + + @BeforeAll + static void setup() throws IOException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + } + + @AfterAll + static void shutdown() throws IOException { + mockWebServer.shutdown(); + } + + @DynamicPropertySource + @SuppressWarnings("PMD.DefaultPackage") + static void registerProperties(final DynamicPropertyRegistry registry) { + registry.add("salesforce.host", () -> "http://localhost:" + mockWebServer.getPort()); + } + + protected void validateAuthRequest() throws InterruptedException { + RecordedRequest authReq = mockWebServer.takeRequest(TIMEOUT, TimeUnit.SECONDS); + assertNotNull(authReq); + assertEquals(HttpMethod.POST.name(), authReq.getMethod()); + assertEquals("/services/oauth2/token", authReq.getPath()); + assertEquals( + MediaType.APPLICATION_FORM_URLENCODED_VALUE, authReq.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals( + "grant_type=password&username=username&password=password%21%40%23%24%25%5E%26*%28%29&client_id=clientId&client_secret=clientSecret", + authReq.getBody().readUtf8()); + } + + protected void validateSfRequest() throws InterruptedException { + validateSfRequest("Bearer bearerToken"); + } + + protected void validateSfRequest(final String authHeader) throws InterruptedException { + RecordedRequest queryReq = mockWebServer.takeRequest(TIMEOUT, TimeUnit.SECONDS); + assertNotNull(queryReq); + assertEquals(HttpMethod.GET.name(), queryReq.getMethod()); + assertEquals("/some/sf/url", queryReq.getPath()); + assertEquals(1, queryReq.getHeaders().values(HttpHeaders.CONTENT_TYPE).size()); + assertEquals(MediaType.APPLICATION_JSON_VALUE, queryReq.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals(authHeader, queryReq.getHeader(HttpHeaders.AUTHORIZATION)); + } +} diff --git a/token-manager-for-salesforce-core/src/testFixtures/java/com/tgt/crm/token/core/MockResponseUtil.java b/token-manager-for-salesforce-core/src/testFixtures/java/com/tgt/crm/token/core/MockResponseUtil.java new file mode 100644 index 0000000..34e7623 --- /dev/null +++ b/token-manager-for-salesforce-core/src/testFixtures/java/com/tgt/crm/token/core/MockResponseUtil.java @@ -0,0 +1,74 @@ +package com.tgt.crm.token.core; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import okhttp3.mockwebserver.MockResponse; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +@Slf4j +public final class MockResponseUtil { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static String sfAuthSuccess; + private static String sfAuthError; + private static String sfQueryUnauthorizedResponse; + private static String sfAuthRefreshed; + + private MockResponseUtil() {} + + static { + try { + sfAuthSuccess = readFile("sfAuthSuccess.json"); + sfAuthError = readFile("sfAuthError.json"); + sfQueryUnauthorizedResponse = readFile("sfQueryUnauthorizedResponse.json"); + sfAuthRefreshed = readFile("sfAuthRefreshed.json"); + } catch (IOException e) { + log.error("Error reading resource file", e); + assert false; + } + } + + public static MockResponse getSfAuthSuccessResponse() { + return buildSuccessResponse(sfAuthSuccess); + } + + public static MockResponse getSfAuthRefreshedSuccessResponse() { + return buildSuccessResponse(sfAuthRefreshed); + } + + public static MockResponse getSfAuthErrorResponse() { + return new MockResponse() + .setResponseCode(400) + .setBody(sfAuthError) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + } + + public static MockResponse getSfQueryResponse() { + return buildSuccessResponse("query successful"); + } + + public static MockResponse getSfQueryUnauthorizedResponse() { + return new MockResponse() + .setResponseCode(401) + .setBody(sfQueryUnauthorizedResponse) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + } + + private static MockResponse buildSuccessResponse(final String body) { + return new MockResponse() + .setResponseCode(200) + .setBody(body) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + } + + private static String readFile(final String fileName) throws IOException { + return MAPPER + .readValue(new ClassPathResource(fileName).getInputStream(), JsonNode.class) + .toString(); + } +} diff --git a/token-manager-for-salesforce-core/src/testFixtures/resources/sfAuthError.json b/token-manager-for-salesforce-core/src/testFixtures/resources/sfAuthError.json new file mode 100644 index 0000000..68e013e --- /dev/null +++ b/token-manager-for-salesforce-core/src/testFixtures/resources/sfAuthError.json @@ -0,0 +1,4 @@ +{ + "error": "invalid_grant", + "error_description": "authentication failure" +} \ No newline at end of file diff --git a/token-manager-for-salesforce-core/src/testFixtures/resources/sfAuthRefreshed.json b/token-manager-for-salesforce-core/src/testFixtures/resources/sfAuthRefreshed.json new file mode 100644 index 0000000..7fe6b37 --- /dev/null +++ b/token-manager-for-salesforce-core/src/testFixtures/resources/sfAuthRefreshed.json @@ -0,0 +1,8 @@ +{ + "access_token": "new bearerToken", + "instance_url": "instanceUrl", + "id": "tokenId", + "token_type": "Bearer", + "issued_at": "1585760950027", + "signature": "tokenSignature" +} \ No newline at end of file diff --git a/token-manager-for-salesforce-core/src/testFixtures/resources/sfAuthSuccess.json b/token-manager-for-salesforce-core/src/testFixtures/resources/sfAuthSuccess.json new file mode 100644 index 0000000..a75274c --- /dev/null +++ b/token-manager-for-salesforce-core/src/testFixtures/resources/sfAuthSuccess.json @@ -0,0 +1,8 @@ +{ + "access_token": "bearerToken", + "instance_url": "instanceUrl", + "id": "tokenId", + "token_type": "Bearer", + "issued_at": "1585760950027", + "signature": "tokenSignature" +} \ No newline at end of file diff --git a/token-manager-for-salesforce-core/src/testFixtures/resources/sfQueryUnauthorizedResponse.json b/token-manager-for-salesforce-core/src/testFixtures/resources/sfQueryUnauthorizedResponse.json new file mode 100644 index 0000000..015db27 --- /dev/null +++ b/token-manager-for-salesforce-core/src/testFixtures/resources/sfQueryUnauthorizedResponse.json @@ -0,0 +1,6 @@ +[ + { + "message": "Session expired or invalid", + "errorCode": "INVALID_SESSION_ID" + } +] \ No newline at end of file diff --git a/token-manager-for-salesforce-webflux/build.gradle b/token-manager-for-salesforce-webflux/build.gradle new file mode 100644 index 0000000..77c9dc8 --- /dev/null +++ b/token-manager-for-salesforce-webflux/build.gradle @@ -0,0 +1,95 @@ +// === configure jacoco === + +jacoco { + toolVersion = "${jacocoPluginVersion}" +} + +def jacocoExcludes = [ + '**/AuthWebClient.class', + '**/SalesforceLibraryAutoConfiguration.class', + '**/SalesforceWebClient.class' +] + +jacocoTestReport { + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: jacocoExcludes) + })) + } +} + +jacocoTestCoverageVerification { + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: jacocoExcludes) + })) + } + violationRules { + rule { + limit { + value = 'COVEREDRATIO' + counter = 'LINE' + minimum = 1.00 + } + } + } +} + +// check will fail if minimum code coverage % is not met +check.dependsOn jacocoTestCoverageVerification +// generate a report of code coverage in build directory after test task is run +test.finalizedBy jacocoTestReport + +// === define integration test source set === + +sourceSets { + testintegration { + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } +} + +idea { + module { + // configures IntelliJ to recognize integrationTest sources as tests + testSourceDirs += sourceSets.testintegration.java.srcDirs + testResourceDirs += sourceSets.testintegration.resources.srcDirs + scopes.TEST.plus += [ + configurations.testintegrationCompileClasspath + ] + } +} + +// define new testintegration task +task testintegration(type: Test) { + systemProperty "spring.profiles.active", System.getProperty("spring.profiles.active") + description = 'Runs integration tests.' + group = 'verification' + useJUnitPlatform() // use Junit5 + testClassesDirs = sourceSets.testintegration.output.classesDirs + classpath = sourceSets.testintegration.runtimeClasspath + shouldRunAfter test + testLogging.exceptionFormat = 'full' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + // makes lombok available for integration tests + testintegrationImplementation.extendsFrom testImplementation + testintegrationCompileOnly.extendsFrom compileOnly + testintegrationAnnotationProcessor.extendsFrom annotationProcessor +} + +check.dependsOn testintegration + +// === module dependencies === + +dependencies { + api project(':token-manager-for-salesforce-core') + implementation "org.springframework.boot:spring-boot-starter-webflux" + implementation "org.springframework.boot:spring-boot-starter-actuator" + testImplementation "io.projectreactor:reactor-test:${reactorTestVersion}" + testintegrationImplementation(testFixtures(project(":token-manager-for-salesforce-core"))) +} diff --git a/token-manager-for-salesforce-webflux/settings.gradle b/token-manager-for-salesforce-webflux/settings.gradle new file mode 100644 index 0000000..affdf5c --- /dev/null +++ b/token-manager-for-salesforce-webflux/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'token-manager-for-salesforce-webflux' \ No newline at end of file diff --git a/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/AuthWebClient.java b/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/AuthWebClient.java new file mode 100644 index 0000000..ff064ca --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/AuthWebClient.java @@ -0,0 +1,51 @@ +package com.tgt.crm.token.webflux; + +import com.tgt.crm.token.core.SalesforceConfig; +import io.netty.handler.logging.LogLevel; +import lombok.AllArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; +import reactor.netty.transport.logging.AdvancedByteBufFormat; + +@Configuration +@AllArgsConstructor +public class AuthWebClient { + + private final SalesforceConfig salesforceConfig; + + @Bean + @Qualifier("sfAuthWebClient") + @ConditionalOnClass(AdvancedByteBufFormat.class) + public WebClient sfAuthWebClientWiretap(final WebClient.Builder webClientBuilder) { + return webClientBuilder + .clientConnector( + new ReactorClientHttpConnector( + HttpClient.create() + .wiretap( + "com.tgt.crm.token.webflux.sfAuthWebClient", + LogLevel.TRACE, + AdvancedByteBufFormat.TEXTUAL))) + .baseUrl(salesforceConfig.getHost()) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .build(); + } + + @Bean + @Qualifier("sfAuthWebClient") + @ConditionalOnMissingClass("reactor.netty.transport.logging.AdvancedByteBufFormat") + public WebClient sfAuthWebClient(final WebClient.Builder webClientBuilder) { + return webClientBuilder + .clientConnector(new ReactorClientHttpConnector(HttpClient.create())) + .baseUrl(salesforceConfig.getHost()) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .build(); + } +} diff --git a/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/SalesforceLibraryAutoConfiguration.java b/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/SalesforceLibraryAutoConfiguration.java new file mode 100644 index 0000000..fc30a67 --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/SalesforceLibraryAutoConfiguration.java @@ -0,0 +1,12 @@ +package com.tgt.crm.token.webflux; + +import com.tgt.crm.token.core.HttpClientConfig; +import com.tgt.crm.token.core.SalesforceConfig; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({SalesforceConfig.class, HttpClientConfig.class}) +@ComponentScan +public class SalesforceLibraryAutoConfiguration {} diff --git a/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/SalesforceWebClient.java b/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/SalesforceWebClient.java new file mode 100644 index 0000000..03845ef --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/SalesforceWebClient.java @@ -0,0 +1,132 @@ +package com.tgt.crm.token.webflux; + +import com.tgt.crm.token.core.HttpClientConfig; +import com.tgt.crm.token.core.SalesforceConfig; +import io.netty.channel.ChannelOption; +import io.netty.handler.logging.LogLevel; +import io.netty.handler.timeout.ReadTimeoutHandler; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; +import reactor.netty.transport.logging.AdvancedByteBufFormat; + +@Configuration +@AllArgsConstructor +@Slf4j +public class SalesforceWebClient { + + private final SalesforceWebfluxAuthClient salesforceWebfluxAuthClient; + private final SalesforceConfig salesforceConfig; + private final HttpClientConfig httpClientConfig; + + @Bean + @Qualifier("sfWebClient") + @ConditionalOnClass(AdvancedByteBufFormat.class) + public WebClient sfWebClientWiretap(final WebClient.Builder webClientBuilder) { + HttpClient httpClient = + HttpClient.create( + ConnectionProvider.create( + "sfTokenManagerProvider", httpClientConfig.getMaxConnPerRoute())) + .doOnConnected( + conn -> + conn.addHandlerLast( + new ReadTimeoutHandler( + httpClientConfig.getReadTimeout(), TimeUnit.MILLISECONDS))) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, httpClientConfig.getConnectionTimeout()) + .wiretap( + "com.tgt.crm.token.webflux.sfWebClient", + LogLevel.TRACE, + AdvancedByteBufFormat.TEXTUAL); + return webClientBuilder + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .baseUrl(salesforceConfig.getHost()) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .filter(addAuthHeader()) + .filter(retryOnUnauthorized()) + .build(); + } + + /** Uses deprecated configuration to support older versions of spring boot */ + @Bean + @Qualifier("sfWebClient") + @ConditionalOnMissingClass("reactor.netty.transport.logging.AdvancedByteBufFormat") + public WebClient sfWebClient(final WebClient.Builder webClientBuilder) { + HttpClient httpClient = + HttpClient.create( + ConnectionProvider.create( + "sfTokenManagerProvider", httpClientConfig.getMaxConnPerRoute())) + .tcpConfiguration( + tcpClient -> + tcpClient + .option( + ChannelOption.CONNECT_TIMEOUT_MILLIS, + httpClientConfig.getConnectionTimeout()) + .doOnConnected( + conn -> + conn.addHandlerLast( + new ReadTimeoutHandler( + httpClientConfig.getReadTimeout(), + TimeUnit.MILLISECONDS)))); + return webClientBuilder + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .baseUrl(salesforceConfig.getHost()) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .filter(addAuthHeader()) + .filter(retryOnUnauthorized()) + .build(); + } + + private ExchangeFilterFunction addAuthHeader() { + return (request, next) -> + salesforceWebfluxAuthClient + .getToken() + .map( + token -> + ClientRequest.from(request).header(HttpHeaders.AUTHORIZATION, token).build()) + .flatMap(next::exchange); + } + + private ExchangeFilterFunction retryOnUnauthorized() { + return (request, next) -> + next.exchange(request) + .flatMap( + (Function>) + clientResponse -> { + if (clientResponse.statusCode() == HttpStatus.UNAUTHORIZED) { + log.info("received 401 response, refreshing token and retrying request"); + return salesforceWebfluxAuthClient + .refreshToken() + .map( + token -> + ClientRequest.from(request) + .headers( + headers -> + headers.replace( + HttpHeaders.AUTHORIZATION, + Collections.singletonList(token))) + .build()) + .flatMap(next::exchange); + } else { + return Mono.just(clientResponse); + } + }); + } +} diff --git a/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/SalesforceWebfluxAuthClient.java b/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/SalesforceWebfluxAuthClient.java new file mode 100644 index 0000000..d34cc5d --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/main/java/com/tgt/crm/token/webflux/SalesforceWebfluxAuthClient.java @@ -0,0 +1,87 @@ +package com.tgt.crm.token.webflux; + +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_COUNTER; +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_TYPE_TAG; +import static com.tgt.crm.token.core.SalesforceConstants.TOKEN_REFRESH_EXCEPTION; + +import com.tgt.crm.token.core.SalesforceAuthResponse; +import com.tgt.crm.token.core.SalesforceConfig; +import io.micrometer.core.instrument.MeterRegistry; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +@Slf4j +@Configuration +public class SalesforceWebfluxAuthClient { + + private final WebClient authWebClient; + private final SalesforceConfig salesforceConfig; + private final MeterRegistry meterRegistry; + private String token; + + public SalesforceWebfluxAuthClient( + final @Qualifier("sfAuthWebClient") WebClient authWebClient, + final SalesforceConfig salesforceConfig, + final MeterRegistry meterRegistry) { + this.authWebClient = authWebClient; + this.salesforceConfig = salesforceConfig; + this.meterRegistry = meterRegistry; + } + + public Mono getToken() { + // will be null on first call to Salesforce + if (this.token == null) { + log.info("token is null, calling refresh token to generate first token"); + return refreshToken(); + } + return Mono.just(this.token); + } + + public Mono refreshToken() { + return authWebClient + .post() + .uri(salesforceConfig.getAuthUri()) + .body(Mono.just(initAuthString()), String.class) + .retrieve() + .bodyToMono(SalesforceAuthResponse.class) + .retryWhen( + Retry.backoff( + salesforceConfig.getMaxAuthTokenRetries(), + Duration.ofMillis(salesforceConfig.getRetryBackoffDelay())) + .onRetryExhaustedThrow( + (spec, signal) -> { + log.error("retries exhausted"); + // throw original exception instead of RetryExhaustedException wrapper + return signal.failure(); + }) + .doAfterRetry(retry -> log.error("Retry failed. ", retry.failure()))) + .doOnError( + error -> { + log.error("token refresh failed", error); + meterRegistry + .counter(EXCEPTION_COUNTER, EXCEPTION_TYPE_TAG, TOKEN_REFRESH_EXCEPTION) + .increment(); + }) + .doOnSuccess(success -> log.info("token refresh successful")) + .map(val -> this.token = val.getSalesforceAuthToken()); + } + + private String initAuthString() { + return "grant_type=password" + + "&username=" + + salesforceConfig.getUsername() + + "&password=" + + URLEncoder.encode(salesforceConfig.getPassword(), StandardCharsets.UTF_8) + + "&client_id=" + + salesforceConfig.getClientId() + + "&client_secret=" + + salesforceConfig.getClientSecret(); + } +} diff --git a/token-manager-for-salesforce-webflux/src/main/resources/META-INF/spring.factories b/token-manager-for-salesforce-webflux/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..e56d44b --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.tgt.crm.token.webflux.SalesforceLibraryAutoConfiguration \ No newline at end of file diff --git a/token-manager-for-salesforce-webflux/src/test/java/com/tgt/crm/token/webflux/SalesforceWebfluxAuthClientTest.java b/token-manager-for-salesforce-webflux/src/test/java/com/tgt/crm/token/webflux/SalesforceWebfluxAuthClientTest.java new file mode 100644 index 0000000..60067ac --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/test/java/com/tgt/crm/token/webflux/SalesforceWebfluxAuthClientTest.java @@ -0,0 +1,217 @@ +package com.tgt.crm.token.webflux; + +import static com.tgt.crm.token.core.SalesforceConstants.AUTH_URI; +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_COUNTER; +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_TYPE_TAG; +import static com.tgt.crm.token.core.SalesforceConstants.TOKEN_REFRESH_EXCEPTION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tgt.crm.token.core.SalesforceConfig; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import java.io.IOException; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class SalesforceWebfluxAuthClientTest { + + private static final String BEARER_TOKEN = "Bearer bearerToken"; + private static final String TEST_USER_NAME = "testUserName"; + private static final String TEST_PASSWORD = "testPassword!@#$%^&*()"; + private static final String TEST_CLIENT_ID = "testClientId"; + private static final String TEST_CLIENT_SECRET = "testClientSecret"; + + @Mock private SalesforceConfig salesforceConfig; + @Mock private ExchangeFunction exchangeFunction; + @Mock private MeterRegistry meterRegistry; + @Mock private Counter counter; + + @Captor private ArgumentCaptor requestCaptor; + + private SalesforceWebfluxAuthClient systemUnderTest; + + private static String salesforceAuthResponse; + private static String sfAuthResponseRefreshed; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + static { + try { + salesforceAuthResponse = + MAPPER + .readValue( + new ClassPathResource("salesforceAuthResponse.json").getInputStream(), + JsonNode.class) + .toString(); + sfAuthResponseRefreshed = + MAPPER + .readValue( + new ClassPathResource("salesforceAuthResponseRefreshed.json").getInputStream(), + JsonNode.class) + .toString(); + } catch (IOException e) { + log.error("Error reading resource file", e); + } + } + + @BeforeEach + public void setUp() { + WebClient webClient = WebClient.builder().exchangeFunction(exchangeFunction).build(); + systemUnderTest = new SalesforceWebfluxAuthClient(webClient, salesforceConfig, meterRegistry); + } + + @Test + public void testGetToken_getTokenFirst_useCachedSecond() { + when(salesforceConfig.getUsername()).thenReturn(TEST_USER_NAME); + when(salesforceConfig.getPassword()).thenReturn(TEST_PASSWORD); + when(salesforceConfig.getClientId()).thenReturn(TEST_CLIENT_ID); + when(salesforceConfig.getClientSecret()).thenReturn(TEST_CLIENT_SECRET); + when(salesforceConfig.getAuthUri()).thenReturn(AUTH_URI); + + when(exchangeFunction.exchange(any(ClientRequest.class))) + .thenReturn(buildMockResponseSuccess()); + + Mono actual = systemUnderTest.getToken(); + StepVerifier.create(actual).expectNextMatches(BEARER_TOKEN::equals).verifyComplete(); + + // should execute callout first time + verify(exchangeFunction, times(1)).exchange(any(ClientRequest.class)); + + Mono actualCached = systemUnderTest.getToken(); + StepVerifier.create(actualCached).expectNextMatches(BEARER_TOKEN::equals).verifyComplete(); + + // verify exchange function was not called again, this means cached value was used + verify(exchangeFunction, times(1)).exchange(requestCaptor.capture()); + ClientRequest actualRequest = requestCaptor.getValue(); + assertEquals(HttpMethod.POST, actualRequest.method()); + assertEquals(AUTH_URI, actualRequest.url().getPath()); + // not possible to read body from ClientRequest currently, verified in integration tests + } + + @Test + public void testGetToken_refreshToken() { + when(salesforceConfig.getUsername()).thenReturn(TEST_USER_NAME); + when(salesforceConfig.getPassword()).thenReturn(TEST_PASSWORD); + when(salesforceConfig.getClientId()).thenReturn(TEST_CLIENT_ID); + when(salesforceConfig.getClientSecret()).thenReturn(TEST_CLIENT_SECRET); + + when(exchangeFunction.exchange(any(ClientRequest.class))) + .thenReturn(buildMockResponseSuccess()) + .thenReturn(buildMockResponseRefreshed()); + + Mono actual = systemUnderTest.refreshToken(); + StepVerifier.create(actual).expectNextMatches(BEARER_TOKEN::equals).verifyComplete(); + + // should execute callout first time + verify(exchangeFunction, times(1)).exchange(any(ClientRequest.class)); + + Mono actualRefresh = systemUnderTest.refreshToken(); + StepVerifier.create(actualRefresh) + .expectNextMatches("Bearer newBearerToken"::equals) + .verifyComplete(); + + // should refresh even if there is a cached value + verify(exchangeFunction, times(2)).exchange(any(ClientRequest.class)); + } + + @Test + public void testGetToken_fail_retries() { + when(salesforceConfig.getUsername()).thenReturn(TEST_USER_NAME); + when(salesforceConfig.getPassword()).thenReturn(TEST_PASSWORD); + when(salesforceConfig.getClientId()).thenReturn(TEST_CLIENT_ID); + when(salesforceConfig.getClientSecret()).thenReturn(TEST_CLIENT_SECRET); + + when(exchangeFunction.exchange(any(ClientRequest.class))) + .thenReturn(buildMockResponseFailure()) + .thenReturn(buildMockResponseSuccess()); + when(salesforceConfig.getMaxAuthTokenRetries()).thenReturn(3); + when(salesforceConfig.getRetryBackoffDelay()).thenReturn(50); + + Mono actual = systemUnderTest.refreshToken(); + StepVerifier.create(actual).expectNextMatches(BEARER_TOKEN::equals).verifyComplete(); + + // should fail on first callout then succeed + verify(exchangeFunction, times(2)).exchange(any(ClientRequest.class)); + } + + @Test + public void testGetToken_fail_shouldNotUpdateToken() { + when(salesforceConfig.getUsername()).thenReturn(TEST_USER_NAME); + when(salesforceConfig.getPassword()).thenReturn(TEST_PASSWORD); + when(salesforceConfig.getClientId()).thenReturn(TEST_CLIENT_ID); + when(salesforceConfig.getClientSecret()).thenReturn(TEST_CLIENT_SECRET); + + when(exchangeFunction.exchange(any(ClientRequest.class))) + .thenReturn(buildMockResponseSuccess()) + .thenReturn(buildMockResponseFailure()); + when(meterRegistry.counter(EXCEPTION_COUNTER, EXCEPTION_TYPE_TAG, TOKEN_REFRESH_EXCEPTION)) + .thenReturn(counter); + when(salesforceConfig.getMaxAuthTokenRetries()).thenReturn(3); + when(salesforceConfig.getRetryBackoffDelay()).thenReturn(50); + + Mono actual = systemUnderTest.refreshToken(); + StepVerifier.create(actual).expectNextMatches(BEARER_TOKEN::equals).verifyComplete(); + // should succeed on first refresh + verify(exchangeFunction, times(1)).exchange(any(ClientRequest.class)); + + // on subsequent retry attempts it will fail + // one attempt + 3 retries, throws RetryExhaustedException, child of RuntimeException + Mono actualRefresh = systemUnderTest.refreshToken(); + StepVerifier.create(actualRefresh) + .expectError(RuntimeException.class) + .verify(Duration.ofSeconds(10)); + + // 1 for original refresh token, 1 for 2nd refresh token, 3 retires, 5 total calls + verify(exchangeFunction, times(5)).exchange(any(ClientRequest.class)); + + verify(counter).increment(); + } + + private Mono buildMockResponseSuccess() { + return Mono.just( + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(salesforceAuthResponse) + .build()); + } + + private Mono buildMockResponseRefreshed() { + return Mono.just( + ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(sfAuthResponseRefreshed) + .build()); + } + + private Mono buildMockResponseFailure() { + return Mono.just( + ClientResponse.create(HttpStatus.INTERNAL_SERVER_ERROR) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build()); + } +} diff --git a/token-manager-for-salesforce-webflux/src/test/resources/salesforceAuthResponse.json b/token-manager-for-salesforce-webflux/src/test/resources/salesforceAuthResponse.json new file mode 100644 index 0000000..a75274c --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/test/resources/salesforceAuthResponse.json @@ -0,0 +1,8 @@ +{ + "access_token": "bearerToken", + "instance_url": "instanceUrl", + "id": "tokenId", + "token_type": "Bearer", + "issued_at": "1585760950027", + "signature": "tokenSignature" +} \ No newline at end of file diff --git a/token-manager-for-salesforce-webflux/src/test/resources/salesforceAuthResponseRefreshed.json b/token-manager-for-salesforce-webflux/src/test/resources/salesforceAuthResponseRefreshed.json new file mode 100644 index 0000000..ae8cf1f --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/test/resources/salesforceAuthResponseRefreshed.json @@ -0,0 +1,8 @@ +{ + "access_token": "newBearerToken", + "instance_url": "instanceUrl", + "id": "tokenId", + "token_type": "Bearer", + "issued_at": "1585760950027", + "signature": "tokenSignature" +} \ No newline at end of file diff --git a/token-manager-for-salesforce-webflux/src/test/resources/sfSobjectSuccessResponse.json b/token-manager-for-salesforce-webflux/src/test/resources/sfSobjectSuccessResponse.json new file mode 100644 index 0000000..cef983b --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/test/resources/sfSobjectSuccessResponse.json @@ -0,0 +1,5 @@ +{ + "id": "5000v0000066K4nAAE", + "success": true, + "errors": [] +} \ No newline at end of file diff --git a/token-manager-for-salesforce-webflux/src/testintegration/java/com/tgt/crm/token/webflux/WebfluxIntegrationTest.java b/token-manager-for-salesforce-webflux/src/testintegration/java/com/tgt/crm/token/webflux/WebfluxIntegrationTest.java new file mode 100644 index 0000000..b90ee6c --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/testintegration/java/com/tgt/crm/token/webflux/WebfluxIntegrationTest.java @@ -0,0 +1,256 @@ +package com.tgt.crm.token.webflux; + +import static com.tgt.crm.token.core.MockResponseUtil.getSfAuthErrorResponse; +import static com.tgt.crm.token.core.MockResponseUtil.getSfAuthRefreshedSuccessResponse; +import static com.tgt.crm.token.core.MockResponseUtil.getSfAuthSuccessResponse; +import static com.tgt.crm.token.core.MockResponseUtil.getSfQueryResponse; +import static com.tgt.crm.token.core.MockResponseUtil.getSfQueryUnauthorizedResponse; +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_COUNTER; +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_TYPE_TAG; +import static com.tgt.crm.token.core.SalesforceConstants.TOKEN_REFRESH_EXCEPTION; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.tgt.crm.token.core.BaseIntegrationTest; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@SpringBootTest( + classes = { + SalesforceLibraryAutoConfiguration.class, + WebClientAutoConfiguration.class, + SimpleMeterRegistry.class + }) +@Slf4j +public class WebfluxIntegrationTest extends BaseIntegrationTest { + + @Qualifier("sfWebClient") + @Autowired + private WebClient webClient; + + @Autowired private MeterRegistry meterRegistry; + + @BeforeEach + public void setupEach() { + meterRegistry.clear(); + } + + @Test + public void makeRequest_noTokenInCache_authSuccess_reqSuccess() throws InterruptedException { + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount = mockWebServer.getRequestCount(); + + Mono> actual = + webClient.get().uri(SF_URL).retrieve().toEntity(new ParameterizedTypeReference<>() {}); + + StepVerifier.create(actual) + .assertNext( + nxt -> { + assertEquals(HttpStatus.OK, nxt.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, nxt.getBody()); + }) + .verifyComplete(); + + validateAuthRequest(); + validateSfRequest(); + + assertEquals(2, mockWebServer.getRequestCount() - prevReqCount); + } + + @Test + void makeRequest_tokenInCache_noAuthCall_reqSuccess() throws InterruptedException { + // === make first request to cache token === + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount1 = mockWebServer.getRequestCount(); + + Mono> actual1 = + webClient.get().uri(SF_URL).retrieve().toEntity(new ParameterizedTypeReference<>() {}); + + StepVerifier.create(actual1) + .assertNext( + nxt -> { + assertEquals(HttpStatus.OK, nxt.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, nxt.getBody()); + }) + .verifyComplete(); + + validateAuthRequest(); + validateSfRequest(); + + assertEquals(2, mockWebServer.getRequestCount() - prevReqCount1); + + // === make second request to test cached token is used === + mockWebServer.enqueue(getSfQueryResponse()); + int prevReqCount2 = mockWebServer.getRequestCount(); + + Mono> actual2 = + webClient.get().uri(SF_URL).retrieve().toEntity(new ParameterizedTypeReference<>() {}); + + StepVerifier.create(actual2) + .assertNext( + nxt -> { + assertEquals(HttpStatus.OK, nxt.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, nxt.getBody()); + }) + .verifyComplete(); + + validateSfRequest(); + assertEquals(1, mockWebServer.getRequestCount() - prevReqCount2); + } + + @Test + void makeRequest_noTokenInCache_authFail_retryAuthSuccess_reqSuccess() + throws InterruptedException { + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount = mockWebServer.getRequestCount(); + + Mono> actual = + webClient.get().uri(SF_URL).retrieve().toEntity(new ParameterizedTypeReference<>() {}); + + StepVerifier.create(actual) + .assertNext( + nxt -> { + assertEquals(HttpStatus.OK, nxt.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, nxt.getBody()); + }) + .verifyComplete(); + + validateAuthRequest(); + validateAuthRequest(); + validateSfRequest(); + + assertEquals(3, mockWebServer.getRequestCount() - prevReqCount); + } + + @Test + void makeRequest_noTokenInCache_authFail_retryAuthFail_retryAuthSuccess_reqSuccess() + throws InterruptedException { + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount = mockWebServer.getRequestCount(); + + Mono> actual = + webClient.get().uri(SF_URL).retrieve().toEntity(new ParameterizedTypeReference<>() {}); + + StepVerifier.create(actual) + .assertNext( + nxt -> { + assertEquals(HttpStatus.OK, nxt.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, nxt.getBody()); + }) + .verifyComplete(); + + validateAuthRequest(); + validateAuthRequest(); + validateAuthRequest(); + validateSfRequest(); + + assertEquals(4, mockWebServer.getRequestCount() - prevReqCount); + } + + @Test + void makeRequest_noTokenInCache_authFail_exhaustRetries_reqFail() throws InterruptedException { + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthErrorResponse()); + + int prevReqCount = mockWebServer.getRequestCount(); + + Mono> actual = + webClient.get().uri(SF_URL).retrieve().toEntity(new ParameterizedTypeReference<>() {}); + + StepVerifier.create(actual) + .expectError(RuntimeException.class) + .verify(Duration.ofSeconds(TIMEOUT * 3)); + + validateAuthRequest(); + validateAuthRequest(); + validateAuthRequest(); + validateAuthRequest(); + + assertEquals(4, mockWebServer.getRequestCount() - prevReqCount); + + assertEquals( + 1, + meterRegistry + .get(EXCEPTION_COUNTER) + .tag(EXCEPTION_TYPE_TAG, TOKEN_REFRESH_EXCEPTION) + .counter() + .count()); + } + + @Test + void makeRequest_expiredTokenInCache_reqFails_tokenRefreshed_reqSuccess() + throws InterruptedException { + // === make first request to cache token === + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount1 = mockWebServer.getRequestCount(); + + Mono> actual1 = + webClient.get().uri(SF_URL).retrieve().toEntity(new ParameterizedTypeReference<>() {}); + + StepVerifier.setDefaultTimeout(Duration.ofSeconds(TIMEOUT)); + StepVerifier.create(actual1) + .assertNext( + nxt -> { + assertEquals(HttpStatus.OK, nxt.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, nxt.getBody()); + }) + .verifyComplete(); + + assertEquals(2, mockWebServer.getRequestCount() - prevReqCount1); + + validateAuthRequest(); + validateSfRequest(); + + // === make second request to test token refresh after cached is unauthorized === + mockWebServer.enqueue(getSfQueryUnauthorizedResponse()); + mockWebServer.enqueue(getSfAuthRefreshedSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount2 = mockWebServer.getRequestCount(); + + Mono> actual2 = + webClient.get().uri(SF_URL).retrieve().toEntity(new ParameterizedTypeReference<>() {}); + + StepVerifier.create(actual2) + .assertNext( + nxt -> { + assertEquals(HttpStatus.OK, nxt.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, nxt.getBody()); + }) + .verifyComplete(); + + assertEquals(3, mockWebServer.getRequestCount() - prevReqCount2); + + validateSfRequest(); + validateAuthRequest(); + validateSfRequest("Bearer new bearerToken"); + } +} diff --git a/token-manager-for-salesforce-webflux/src/testintegration/resources/application.yml b/token-manager-for-salesforce-webflux/src/testintegration/resources/application.yml new file mode 100644 index 0000000..90ddbde --- /dev/null +++ b/token-manager-for-salesforce-webflux/src/testintegration/resources/application.yml @@ -0,0 +1,10 @@ +logging: + level: + com.tgt.crm: TRACE + +salesforce: + username: username + password: password!@#$%^&*() + client-id: clientId + client-secret: clientSecret + retry-backoff-delay: 50 diff --git a/token-manager-for-salesforce-webmvc/build.gradle b/token-manager-for-salesforce-webmvc/build.gradle new file mode 100644 index 0000000..6a10a56 --- /dev/null +++ b/token-manager-for-salesforce-webmvc/build.gradle @@ -0,0 +1,98 @@ +// === configure jacoco === + +jacoco { + toolVersion = "${jacocoPluginVersion}" +} + +def jacocoExcludes = [ + '**/AuthRestTemplate.class', + '**/SalesforceLibraryAutoConfiguration.class', + '**/SalesforceRestTemplate.class', + '**/WebMvcHttpClientConfig.class', + '**/WebMvcHttpClientConstants.class' +] + +jacocoTestReport { + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: jacocoExcludes) + })) + } +} + +jacocoTestCoverageVerification { + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: jacocoExcludes) + })) + } + violationRules { + rule { + limit { + value = 'COVEREDRATIO' + counter = 'LINE' + minimum = 0.70 + } + } + } +} + +// check will fail if minimum code coverage % is not met +check.dependsOn jacocoTestCoverageVerification +// generate a report of code coverage in build directory after test task is run +test.finalizedBy jacocoTestReport + +// === define integration test source set === + +sourceSets { + testintegration { + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } +} + +idea { + module { + // configures IntelliJ to recognize integrationTest sources as tests + testSourceDirs += sourceSets.testintegration.java.srcDirs + testResourceDirs += sourceSets.testintegration.resources.srcDirs + scopes.TEST.plus += [ + configurations.testintegrationCompileClasspath + ] + } +} + +// define new testintegration task +task testintegration(type: Test) { + systemProperty "spring.profiles.active", System.getProperty("spring.profiles.active") + description = 'Runs integration tests.' + group = 'verification' + useJUnitPlatform() // use Junit5 + testClassesDirs = sourceSets.testintegration.output.classesDirs + classpath = sourceSets.testintegration.runtimeClasspath + shouldRunAfter test + testLogging.exceptionFormat = 'full' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + // makes lombok available for integration tests + testintegrationImplementation.extendsFrom testImplementation + testintegrationCompileOnly.extendsFrom compileOnly + testintegrationAnnotationProcessor.extendsFrom annotationProcessor +} + +check.dependsOn testintegration + +// === module dependencies === + +dependencies { + api project(':token-manager-for-salesforce-core') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation "org.springframework.boot:spring-boot-starter-actuator" + testintegrationImplementation(testFixtures(project(":token-manager-for-salesforce-core"))) +} diff --git a/token-manager-for-salesforce-webmvc/settings.gradle b/token-manager-for-salesforce-webmvc/settings.gradle new file mode 100644 index 0000000..12f42ff --- /dev/null +++ b/token-manager-for-salesforce-webmvc/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'token-manager-for-salesforce-webmvc' \ No newline at end of file diff --git a/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/AuthRestTemplate.java b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/AuthRestTemplate.java new file mode 100644 index 0000000..850f992 --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/AuthRestTemplate.java @@ -0,0 +1,27 @@ +package com.tgt.crm.token.mvc; + +import com.tgt.crm.token.core.SalesforceConfig; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; + +@AllArgsConstructor +@Slf4j +@Configuration +public class AuthRestTemplate { + + private final SalesforceConfig salesforceConfig; + + @Bean + public RestTemplate sfAuthRestTemplate(final RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder + .rootUri(salesforceConfig.getHost()) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .build(); + } +} diff --git a/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceLibraryAutoConfiguration.java b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceLibraryAutoConfiguration.java new file mode 100644 index 0000000..4f84ab7 --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceLibraryAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.tgt.crm.token.mvc; + +import com.tgt.crm.token.core.HttpClientConfig; +import com.tgt.crm.token.core.SalesforceConfig; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@Configuration +@EnableConfigurationProperties({ + SalesforceConfig.class, + HttpClientConfig.class, + WebMvcHttpClientConfig.class +}) +@EnableRetry +@ComponentScan +public class SalesforceLibraryAutoConfiguration {} diff --git a/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceMvcAuthClient.java b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceMvcAuthClient.java new file mode 100644 index 0000000..2b8abab --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceMvcAuthClient.java @@ -0,0 +1,109 @@ +package com.tgt.crm.token.mvc; + +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_COUNTER; +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_TYPE_TAG; +import static com.tgt.crm.token.core.SalesforceConstants.MAX_AUTH_TOKEN_RETRIES_DEFAULT; +import static com.tgt.crm.token.core.SalesforceConstants.RETRY_BACKOFF_DELAY_DEFAULT; +import static com.tgt.crm.token.core.SalesforceConstants.RETRY_BACKOFF_MULTIPLIER_DEFAULT; +import static com.tgt.crm.token.core.SalesforceConstants.TOKEN_REFRESH_EXCEPTION; + +import com.tgt.crm.token.core.SalesforceAuthResponse; +import com.tgt.crm.token.core.SalesforceConfig; +import io.micrometer.core.instrument.MeterRegistry; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Configuration +public class SalesforceMvcAuthClient { + + private final SalesforceConfig salesforceConfig; + private final RestTemplate restTemplate; + private final MeterRegistry meterRegistry; + private String token; + + public SalesforceMvcAuthClient( + final SalesforceConfig salesforceConfig, + final @Qualifier("sfAuthRestTemplate") RestTemplate sfAuthRestTemplate, + final MeterRegistry meterRegistry) { + this.restTemplate = sfAuthRestTemplate; + this.salesforceConfig = salesforceConfig; + this.meterRegistry = meterRegistry; + } + + /** + * Use to retrieve the cached token if there is one. + * + * @return the Salesforce oAuth bearer token + */ + public String getToken() { + return this.token == null ? null : this.token; + } + + /** + * Generates a new Salesforce token by calling Salesforce OAuth endpoint. Note that the @Retryable + * annotation only works if this method is called from outside of this class. That is the reason + * we return null from getToken() instead of calling refreshToken() there. This way we can check + * if getToken() is null from wherever we call it and call generateToken() from outside of this + * class if it is. + */ + @Retryable( + maxAttemptsExpression = + "${salesforce.max-auth-token-retries:" + MAX_AUTH_TOKEN_RETRIES_DEFAULT + "}", + backoff = + @Backoff( + multiplierExpression = + "${salesforce.retry-backoff-multiplier:" + RETRY_BACKOFF_MULTIPLIER_DEFAULT + "}", + delayExpression = + "${salesforce.retry-backoff-delay:" + RETRY_BACKOFF_DELAY_DEFAULT + "}")) + public String refreshToken() { + log.debug("generateToken is called"); + + ResponseEntity salesforceAuthResponseEntity = + restTemplate.exchange( + salesforceConfig.getAuthUri(), + HttpMethod.POST, + new HttpEntity<>(initAuthString()), + SalesforceAuthResponse.class); + + assert salesforceAuthResponseEntity.getBody() != null + : "salesforce auth response body should never be null"; + this.token = salesforceAuthResponseEntity.getBody().getSalesforceAuthToken(); + log.info("token successfully generated"); + return this.token; + } + + @Recover + @SuppressWarnings("PMD.NullAssignment") + public String handleRefreshFailure(final Throwable ex) { + log.error("token refresh failed", ex); + meterRegistry + .counter(EXCEPTION_COUNTER, EXCEPTION_TYPE_TAG, TOKEN_REFRESH_EXCEPTION) + .increment(); + // set to null so the next time getToken is called, it will try to refresh token again + this.token = null; + return null; + } + + private String initAuthString() { + return "grant_type=password" + + "&username=" + + salesforceConfig.getUsername() + + "&password=" + + URLEncoder.encode(salesforceConfig.getPassword(), StandardCharsets.UTF_8) + + "&client_id=" + + salesforceConfig.getClientId() + + "&client_secret=" + + salesforceConfig.getClientSecret(); + } +} diff --git a/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceRestTemplate.java b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceRestTemplate.java new file mode 100644 index 0000000..df74cb9 --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceRestTemplate.java @@ -0,0 +1,84 @@ +package com.tgt.crm.token.mvc; + +import com.tgt.crm.token.core.HttpClientConfig; +import com.tgt.crm.token.core.SalesforceConfig; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.ServiceUnavailableRetryStrategy; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.protocol.HttpContext; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@AllArgsConstructor +@Configuration +@Slf4j +public class SalesforceRestTemplate { + + private final SalesforceRestTemplateInterceptor sfRestTemplateInterceptor; + private final SalesforceConfig salesforceConfig; + private final HttpClientConfig httpClientConfig; + private final WebMvcHttpClientConfig webMvcHttpClientConfig; + + @Bean + public RestTemplate sfRestTemplate(final RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder + .requestFactory(this::getHttpFactory) + .rootUri(salesforceConfig.getHost()) + .additionalInterceptors(sfRestTemplateInterceptor) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + private ClientHttpRequestFactory getHttpFactory() { + PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); + connectionManager.setMaxTotal(webMvcHttpClientConfig.getMaxPools()); + connectionManager.setDefaultMaxPerRoute(httpClientConfig.getMaxConnPerRoute()); + HttpClient httpClient = + HttpClientBuilder.create() + .setConnectionManager(connectionManager) + .setRetryHandler( + (exception, executionCount, context) -> { + log.error( + "{} exception thrown during execution count {} with message {}", + exception.getClass(), + executionCount, + exception.getMessage()); + return executionCount < webMvcHttpClientConfig.getRetries(); + }) + .setServiceUnavailableRetryStrategy( + new ServiceUnavailableRetryStrategy() { + @Override + public boolean retryRequest( + final HttpResponse response, + final int executionCount, + final HttpContext context) { + return HttpStatus.valueOf(response.getStatusLine().getStatusCode()) + .is5xxServerError() + && executionCount < webMvcHttpClientConfig.getRetries(); + } + + @Override + public long getRetryInterval() { + return webMvcHttpClientConfig.getRetryInterval(); + } + }) + .build(); + HttpComponentsClientHttpRequestFactory httpFactory = + new HttpComponentsClientHttpRequestFactory(httpClient); + httpFactory.setReadTimeout(httpClientConfig.getReadTimeout()); + httpFactory.setConnectionRequestTimeout(webMvcHttpClientConfig.getConnectionRequestTimeout()); + httpFactory.setConnectTimeout(httpClientConfig.getConnectionTimeout()); + return httpFactory; + } +} diff --git a/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceRestTemplateInterceptor.java b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceRestTemplateInterceptor.java new file mode 100644 index 0000000..b97b111 --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/SalesforceRestTemplateInterceptor.java @@ -0,0 +1,88 @@ +package com.tgt.crm.token.mvc; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SerializationUtils; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.NonNull; + +@AllArgsConstructor +@Slf4j +@Configuration +public class SalesforceRestTemplateInterceptor implements ClientHttpRequestInterceptor { + + private final SalesforceMvcAuthClient salesForceMvcAuthClient; + + @Override + @NonNull + public ClientHttpResponse intercept( + final HttpRequest request, + @NonNull final byte[] body, + final ClientHttpRequestExecution execution) + throws IOException { + log.debug("Entering intercept method for Salesforce call"); + + String token = + salesForceMvcAuthClient.getToken() == null + ? salesForceMvcAuthClient.refreshToken() + : salesForceMvcAuthClient.getToken(); + + request.getHeaders().add(HttpHeaders.AUTHORIZATION, token); + request.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + logRequest(request, body); + + ClientHttpResponse response = execution.execute(request, body); + + logResponse(response); + + // if we get a 401, refresh the token and try request again + if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) { + log.info("received 401 response, refreshing token"); + response.close(); + request.getHeaders().set(HttpHeaders.AUTHORIZATION, salesForceMvcAuthClient.refreshToken()); + response = execution.execute(request, body); + logResponse(response); + } + + return response; + } + + private void logRequest(final HttpRequest request, final byte[] body) { + if (log.isTraceEnabled()) { + HttpHeaders headerDeepCopy = SerializationUtils.clone(request.getHeaders()); + headerDeepCopy.setBearerAuth("************"); + log.trace( + "===========================request begin============================================="); + log.trace("URI : {}", request.getURI()); + log.trace("Method : {}", request.getMethod()); + log.trace("Headers : {}", headerDeepCopy); + log.trace("Request body: {}", new String(body, StandardCharsets.UTF_8)); + log.trace( + "==========================request end==============================================="); + } + } + + private void logResponse(final ClientHttpResponse response) throws IOException { + if (log.isTraceEnabled()) { + HttpHeaders headerDeepCopy = SerializationUtils.clone(response.getHeaders()); + headerDeepCopy.setBearerAuth("************"); + log.trace( + "============================response begin=========================================="); + log.trace("Status code : {}", response.getStatusCode()); + log.trace("Status text : {}", response.getStatusText()); + log.trace("Headers : {}", headerDeepCopy); + log.trace( + "=======================response end================================================="); + } + } +} diff --git a/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/WebMvcHttpClientConfig.java b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/WebMvcHttpClientConfig.java new file mode 100644 index 0000000..3d589ed --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/WebMvcHttpClientConfig.java @@ -0,0 +1,13 @@ +package com.tgt.crm.token.mvc; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties("salesforce.httpclient.webmvc") +public class WebMvcHttpClientConfig { + private int maxPools = 50; + private int connectionRequestTimeout = 30_000; + private int retries = 3; + private int retryInterval = 2_000; +} diff --git a/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/WebMvcHttpClientConstants.java b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/WebMvcHttpClientConstants.java new file mode 100644 index 0000000..753e57f --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/main/java/com/tgt/crm/token/mvc/WebMvcHttpClientConstants.java @@ -0,0 +1,12 @@ +package com.tgt.crm.token.mvc; + +@SuppressWarnings("PMD.LongVariable") +public final class WebMvcHttpClientConstants { + + public static final int MAX_POOLS_DEFAULT = 50; + public static final int CONNECTION_REQUEST_TIMEOUT_DEFAULT = 30_000; + public static final int RETRIES_DEFAULT = 3; + public static final int RETRY_INTERVAL_DEFAULT = 2_000; + + private WebMvcHttpClientConstants() {} +} diff --git a/token-manager-for-salesforce-webmvc/src/main/resources/META-INF/spring.factories b/token-manager-for-salesforce-webmvc/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..d19356c --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.tgt.crm.token.mvc.SalesforceLibraryAutoConfiguration \ No newline at end of file diff --git a/token-manager-for-salesforce-webmvc/src/test/java/com/tgt/crm/token/mvc/SalesforceMvcAuthClientTest.java b/token-manager-for-salesforce-webmvc/src/test/java/com/tgt/crm/token/mvc/SalesforceMvcAuthClientTest.java new file mode 100644 index 0000000..c4b54ba --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/test/java/com/tgt/crm/token/mvc/SalesforceMvcAuthClientTest.java @@ -0,0 +1,135 @@ +package com.tgt.crm.token.mvc; + +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_COUNTER; +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_TYPE_TAG; +import static com.tgt.crm.token.core.SalesforceConstants.TOKEN_REFRESH_EXCEPTION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.tgt.crm.token.core.SalesforceAuthResponse; +import com.tgt.crm.token.core.SalesforceConfig; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("PMD.LinguisticNaming") +public class SalesforceMvcAuthClientTest { + + private static final String TEST_TOKEN_TYPE = "testTokenType"; + private static final String TEST_ACCESS_TOKEN = "testAccessToken"; + private static final String EXPECTED_TOKEN = TEST_TOKEN_TYPE + " " + TEST_ACCESS_TOKEN; + private static final String TEST_USER_NAME = "testUserName"; + private static final String TEST_PASSWORD = "testPassword!@#$%^&*()"; + private static final String TEST_PASSWORD_ENCODED = "testPassword%21%40%23%24%25%5E%26*%28%29"; + private static final String TEST_CLIENT_ID = "testClientId"; + private static final String TEST_CLIENT_SECRET = "testClientSecret"; + private static final String REQUEST_ENTITY_BODY = + "grant_type=password" + + "&username=" + + TEST_USER_NAME + + "&password=" + + TEST_PASSWORD_ENCODED + + "&client_id=" + + TEST_CLIENT_ID + + "&client_secret=" + + TEST_CLIENT_SECRET; + private static final String AUTH_URI = "/services/oauth2/token"; + + @InjectMocks private SalesforceMvcAuthClient tested; + @Mock private RestTemplate restTemplate; + @Mock private SalesforceConfig salesforceConfig; + @Mock private MeterRegistry meterRegistry; + @Mock private Counter counter; + + private ResponseEntity responseEntity; + private HttpEntity requestEntity; + + @BeforeEach + public void setUpEach() { + this.requestEntity = new HttpEntity<>(REQUEST_ENTITY_BODY); + + SalesforceAuthResponse validResponse = new SalesforceAuthResponse(); + validResponse.setTokenType(TEST_TOKEN_TYPE); + validResponse.setAccessToken(TEST_ACCESS_TOKEN); + this.responseEntity = new ResponseEntity<>(validResponse, HttpStatus.OK); + } + + @Test + public void getToken_noTokenInCache_nullResponse() { + assertNull(tested.getToken()); + } + + @Test + public void refreshToken_successfulResponse_tokenReturned() { + when(salesforceConfig.getUsername()).thenReturn(TEST_USER_NAME); + when(salesforceConfig.getPassword()).thenReturn(TEST_PASSWORD); + when(salesforceConfig.getClientId()).thenReturn(TEST_CLIENT_ID); + when(salesforceConfig.getClientSecret()).thenReturn(TEST_CLIENT_SECRET); + when(salesforceConfig.getAuthUri()).thenReturn(AUTH_URI); + + when(restTemplate.exchange( + AUTH_URI, HttpMethod.POST, requestEntity, SalesforceAuthResponse.class)) + .thenReturn(responseEntity); + + assertEquals(EXPECTED_TOKEN, tested.refreshToken()); + + verify(restTemplate) + .exchange(AUTH_URI, HttpMethod.POST, requestEntity, SalesforceAuthResponse.class); + } + + @Test + public void getToken_tokenInCache_cachedTokenReturned() { + when(salesforceConfig.getUsername()).thenReturn(TEST_USER_NAME); + when(salesforceConfig.getPassword()).thenReturn(TEST_PASSWORD); + when(salesforceConfig.getClientId()).thenReturn(TEST_CLIENT_ID); + when(salesforceConfig.getClientSecret()).thenReturn(TEST_CLIENT_SECRET); + when(salesforceConfig.getAuthUri()).thenReturn(AUTH_URI); + + when(restTemplate.exchange( + AUTH_URI, HttpMethod.POST, requestEntity, SalesforceAuthResponse.class)) + .thenReturn(responseEntity); + + tested.refreshToken(); + + assertEquals(EXPECTED_TOKEN, tested.getToken()); + + verify(restTemplate) + .exchange(AUTH_URI, HttpMethod.POST, requestEntity, SalesforceAuthResponse.class); + } + + @Test + public void testHandleRefreshFailure() { + when(salesforceConfig.getUsername()).thenReturn(TEST_USER_NAME); + when(salesforceConfig.getPassword()).thenReturn(TEST_PASSWORD); + when(salesforceConfig.getClientId()).thenReturn(TEST_CLIENT_ID); + when(salesforceConfig.getClientSecret()).thenReturn(TEST_CLIENT_SECRET); + when(salesforceConfig.getAuthUri()).thenReturn(AUTH_URI); + when(meterRegistry.counter(EXCEPTION_COUNTER, EXCEPTION_TYPE_TAG, TOKEN_REFRESH_EXCEPTION)) + .thenReturn(counter); + + when(restTemplate.exchange( + AUTH_URI, HttpMethod.POST, requestEntity, SalesforceAuthResponse.class)) + .thenReturn(responseEntity); + + assertEquals(EXPECTED_TOKEN, tested.refreshToken()); + + assertNull(tested.handleRefreshFailure(new RuntimeException("test exception"))); + + assertNull(tested.getToken()); + + verify(counter).increment(); + } +} diff --git a/token-manager-for-salesforce-webmvc/src/test/java/com/tgt/crm/token/mvc/SalesforceRestTemplateInterceptorTest.java b/token-manager-for-salesforce-webmvc/src/test/java/com/tgt/crm/token/mvc/SalesforceRestTemplateInterceptorTest.java new file mode 100644 index 0000000..8053d2b --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/test/java/com/tgt/crm/token/mvc/SalesforceRestTemplateInterceptorTest.java @@ -0,0 +1,110 @@ +package com.tgt.crm.token.mvc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; + +@SuppressWarnings("ConstantConditions") +@ExtendWith(MockitoExtension.class) +public class SalesforceRestTemplateInterceptorTest { + + private static final byte[] TEST_BYTE_ARRAY = "body".getBytes(StandardCharsets.UTF_8); + private static final String TEST_TOKEN_INVALID = "test_token_invalid"; + private static final String TEST_TOKEN = "test_token"; + + @Mock private SalesforceMvcAuthClient authClient; + @Mock private ClientHttpRequestExecution execution; + @InjectMocks private SalesforceRestTemplateInterceptor tested; + + @Test + public void nullToken_refreshToken_reqSuccess() throws IOException { + MockClientHttpRequest request = new MockClientHttpRequest(); + ClientHttpResponse response = new MockClientHttpResponse(TEST_BYTE_ARRAY, HttpStatus.OK); + + when(authClient.getToken()).thenReturn(null); + when(authClient.refreshToken()).thenReturn(TEST_TOKEN); + when(execution.execute(request, TEST_BYTE_ARRAY)).thenReturn(response); + + ClientHttpResponse actualResponse = tested.intercept(request, TEST_BYTE_ARRAY, execution); + + assertEquals(response, actualResponse); + + ArgumentCaptor argument = ArgumentCaptor.forClass(HttpRequest.class); + verify(execution).execute(argument.capture(), eq(TEST_BYTE_ARRAY)); + assertEquals( + TEST_TOKEN, argument.getValue().getHeaders().get(HttpHeaders.AUTHORIZATION).get(0)); + assertEquals( + MediaType.APPLICATION_JSON_VALUE, + argument.getValue().getHeaders().get(HttpHeaders.CONTENT_TYPE).get(0)); + } + + @Test + public void validToken_processRequest_noRefresh() throws IOException { + MockClientHttpRequest request = new MockClientHttpRequest(); + ClientHttpResponse response = new MockClientHttpResponse(TEST_BYTE_ARRAY, HttpStatus.OK); + + when(authClient.getToken()).thenReturn(TEST_TOKEN); + when(execution.execute(request, TEST_BYTE_ARRAY)).thenReturn(response); + + ClientHttpResponse actualResponse = tested.intercept(request, TEST_BYTE_ARRAY, execution); + + verify(authClient, never()).refreshToken(); + assertEquals(response, actualResponse); + + ArgumentCaptor argument = ArgumentCaptor.forClass(HttpRequest.class); + verify(execution).execute(argument.capture(), eq(TEST_BYTE_ARRAY)); + assertEquals( + TEST_TOKEN, argument.getValue().getHeaders().get(HttpHeaders.AUTHORIZATION).get(0)); + assertEquals( + MediaType.APPLICATION_JSON_VALUE, + argument.getValue().getHeaders().get(HttpHeaders.CONTENT_TYPE).get(0)); + } + + @Test + public void invalidToken_reqFails_tokenRefreshed_reqSuccess() throws IOException { + MockClientHttpRequest request = new MockClientHttpRequest(); + ClientHttpResponse expectedResponse = + new MockClientHttpResponse(TEST_BYTE_ARRAY, HttpStatus.OK); + ClientHttpResponse unauthorizedResponse = + new MockClientHttpResponse(TEST_BYTE_ARRAY, HttpStatus.UNAUTHORIZED); + + when(authClient.getToken()).thenReturn(TEST_TOKEN_INVALID); + when(authClient.refreshToken()).thenReturn(TEST_TOKEN); + when(execution.execute(request, TEST_BYTE_ARRAY)) + .thenReturn(unauthorizedResponse) + .thenReturn(expectedResponse); + + ClientHttpResponse actualResponse = tested.intercept(request, TEST_BYTE_ARRAY, execution); + + assertEquals(expectedResponse, actualResponse); + + ArgumentCaptor argument = ArgumentCaptor.forClass(HttpRequest.class); + verify(execution, times(2)).execute(argument.capture(), eq(TEST_BYTE_ARRAY)); + + assertEquals( + TEST_TOKEN, argument.getValue().getHeaders().get(HttpHeaders.AUTHORIZATION).get(0)); + assertEquals( + MediaType.APPLICATION_JSON_VALUE, + argument.getValue().getHeaders().get(HttpHeaders.CONTENT_TYPE).get(0)); + } +} diff --git a/token-manager-for-salesforce-webmvc/src/testintegration/java/com/tgt/crm/token/mvc/MvcIntegrationTest.java b/token-manager-for-salesforce-webmvc/src/testintegration/java/com/tgt/crm/token/mvc/MvcIntegrationTest.java new file mode 100644 index 0000000..388d6c4 --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/testintegration/java/com/tgt/crm/token/mvc/MvcIntegrationTest.java @@ -0,0 +1,222 @@ +package com.tgt.crm.token.mvc; + +import static com.tgt.crm.token.core.MockResponseUtil.getSfAuthErrorResponse; +import static com.tgt.crm.token.core.MockResponseUtil.getSfAuthSuccessResponse; +import static com.tgt.crm.token.core.MockResponseUtil.getSfQueryResponse; +import static com.tgt.crm.token.core.MockResponseUtil.getSfQueryUnauthorizedResponse; +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_COUNTER; +import static com.tgt.crm.token.core.SalesforceConstants.EXCEPTION_TYPE_TAG; +import static com.tgt.crm.token.core.SalesforceConstants.TOKEN_REFRESH_EXCEPTION; +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 com.tgt.crm.token.core.BaseIntegrationTest; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@SpringBootTest( + classes = { + SalesforceLibraryAutoConfiguration.class, + RestTemplateAutoConfiguration.class, + SimpleMeterRegistry.class + }) +@Slf4j +public class MvcIntegrationTest extends BaseIntegrationTest { + + @Autowired + private @Qualifier("sfRestTemplate") RestTemplate restTemplate; + + @Autowired private MeterRegistry meterRegistry; + + @BeforeEach + public void setupEach() { + meterRegistry.clear(); + } + + @Test + void makeRequest_noTokenInCache_authSuccess_reqSuccess() throws InterruptedException { + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount = mockWebServer.getRequestCount(); + + ResponseEntity res = restTemplate.getForEntity(SF_URL, String.class); + + assertEquals(HttpStatus.OK, res.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, res.getBody()); + + assertEquals(2, mockWebServer.getRequestCount() - prevReqCount); + + validateAuthRequest(); + validateSfRequest(); + } + + @Test + void makeRequest_tokenInCache_noAuthCall_reqSuccess() throws InterruptedException { + // === make first request to cache token === + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount1 = mockWebServer.getRequestCount(); + + ResponseEntity firstRes = restTemplate.getForEntity(SF_URL, String.class); + + assertEquals(HttpStatus.OK, firstRes.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, firstRes.getBody()); + + assertEquals(2, mockWebServer.getRequestCount() - prevReqCount1); + + validateAuthRequest(); + validateSfRequest(); + + // === make second request to test cached token is used === + mockWebServer.enqueue(getSfQueryResponse()); + int prevReqCount2 = mockWebServer.getRequestCount(); + + ResponseEntity secondRes = restTemplate.getForEntity(SF_URL, String.class); + + assertEquals(HttpStatus.OK, secondRes.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, secondRes.getBody()); + + assertEquals(1, mockWebServer.getRequestCount() - prevReqCount2); + validateSfRequest(); + } + + @Test + void makeRequest_expiredTokenInCache_reqFails_tokenRefreshed_reqSuccess() + throws InterruptedException { + // === make first request to cache token === + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount1 = mockWebServer.getRequestCount(); + + ResponseEntity firstRes = restTemplate.getForEntity(SF_URL, String.class); + + assertEquals(HttpStatus.OK, firstRes.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, firstRes.getBody()); + + assertEquals(2, mockWebServer.getRequestCount() - prevReqCount1); + + validateAuthRequest(); + validateSfRequest(); + + // === make second request to test token refresh after cached is unauthorized === + mockWebServer.enqueue(getSfQueryUnauthorizedResponse()); + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount2 = mockWebServer.getRequestCount(); + + ResponseEntity secondRes = restTemplate.getForEntity(SF_URL, String.class); + + assertEquals(HttpStatus.OK, secondRes.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, secondRes.getBody()); + + assertEquals(3, mockWebServer.getRequestCount() - prevReqCount2); + + validateSfRequest(); + validateAuthRequest(); + validateSfRequest(); + } + + @Test + void makeRequest_noTokenInCache_authFail_retryAuthSuccess_reqSuccess() + throws InterruptedException { + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount = mockWebServer.getRequestCount(); + + ResponseEntity res = restTemplate.getForEntity(SF_URL, String.class); + + assertEquals(HttpStatus.OK, res.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, res.getBody()); + + assertEquals(3, mockWebServer.getRequestCount() - prevReqCount); + + validateAuthRequest(); + validateAuthRequest(); + validateSfRequest(); + } + + @Test + void makeRequest_noTokenInCache_authFail_retryAuthFail_retryAuthSuccess_reqSuccess() + throws InterruptedException { + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthSuccessResponse()); + mockWebServer.enqueue(getSfQueryResponse()); + + int prevReqCount = mockWebServer.getRequestCount(); + + ResponseEntity res = restTemplate.getForEntity(SF_URL, String.class); + + assertEquals(HttpStatus.OK, res.getStatusCode()); + assertEquals(QUERY_SUCCESSFUL, res.getBody()); + + assertEquals(4, mockWebServer.getRequestCount() - prevReqCount); + + validateAuthRequest(); + validateAuthRequest(); + validateAuthRequest(); + validateSfRequest(); + } + + @Test + void makeRequest_noTokenInCache_authFail_exhaustRetries_reqFail() throws InterruptedException { + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthErrorResponse()); + mockWebServer.enqueue(getSfAuthErrorResponse()); + + int prevReqCount = mockWebServer.getRequestCount(); + + HttpClientErrorException ex = + assertThrows( + HttpClientErrorException.class, () -> restTemplate.getForEntity(SF_URL, String.class)); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + + assertEquals(4, mockWebServer.getRequestCount() - prevReqCount); + + validateAuthRequest(); + validateAuthRequest(); + validateAuthRequest(); + + // will still attempt the req but token will be null + RecordedRequest reqWithNoAuth = mockWebServer.takeRequest(TIMEOUT, TimeUnit.SECONDS); + assertNotNull(reqWithNoAuth); + assertEquals(HttpMethod.GET.name(), reqWithNoAuth.getMethod()); + assertEquals("/some/sf/url", reqWithNoAuth.getPath()); + assertEquals( + MediaType.APPLICATION_JSON_VALUE, reqWithNoAuth.getHeader(HttpHeaders.CONTENT_TYPE)); + assertEquals("", reqWithNoAuth.getHeader(HttpHeaders.AUTHORIZATION)); + + assertEquals( + 1, + meterRegistry + .get(EXCEPTION_COUNTER) + .tag(EXCEPTION_TYPE_TAG, TOKEN_REFRESH_EXCEPTION) + .counter() + .count()); + } +} diff --git a/token-manager-for-salesforce-webmvc/src/testintegration/resources/application.yml b/token-manager-for-salesforce-webmvc/src/testintegration/resources/application.yml new file mode 100644 index 0000000..90ddbde --- /dev/null +++ b/token-manager-for-salesforce-webmvc/src/testintegration/resources/application.yml @@ -0,0 +1,10 @@ +logging: + level: + com.tgt.crm: TRACE + +salesforce: + username: username + password: password!@#$%^&*() + client-id: clientId + client-secret: clientSecret + retry-backoff-delay: 50