From 0adb501d23ac5bcd431a72f1dd7c8bc8f06ad043 Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Fri, 13 May 2022 15:40:22 -0600 Subject: [PATCH] Initial commit (#1) Initial commit --- .github/workflows/ci.yml | 33 + .gitignore | 1 + README.md | 249 ++- easypost_java_style.xml | 218 +++ pom.xml | 259 +++ .../easypost/easyvcr/AdvancedSettings.java | 11 + .../java/com/easypost/easyvcr/Cassette.java | 182 ++ .../java/com/easypost/easyvcr/Censors.java | 207 +++ .../com/easypost/easyvcr/HttpClientType.java | 6 + .../com/easypost/easyvcr/HttpClients.java | 130 ++ .../java/com/easypost/easyvcr/MatchRules.java | 235 +++ src/main/java/com/easypost/easyvcr/Mode.java | 8 + .../java/com/easypost/easyvcr/Statics.java | 37 + .../java/com/easypost/easyvcr/Utilities.java | 33 + src/main/java/com/easypost/easyvcr/VCR.java | 193 +++ .../com/easypost/easyvcr/VCRException.java | 16 + .../RecordableHttpURLConnection.java | 1392 +++++++++++++++ .../RecordableHttpsURLConnection.java | 1540 +++++++++++++++++ .../RecordableRequestBody.java | 64 + .../httpurlconnection/RecordableURL.java | 300 ++++ .../BaseInteractionConverter.java | 61 + ...HttpUrlConnectionInteractionConverter.java | 127 ++ .../easyvcr/internalutilities/Files.java | 78 + .../easyvcr/internalutilities/Tools.java | 197 +++ .../easyvcr/internalutilities/Utils.java | 116 ++ .../internalutilities/json/Serialization.java | 47 + .../easyvcr/requestelements/HttpElement.java | 17 + .../requestelements/HttpInteraction.java | 114 ++ .../easyvcr/requestelements/HttpVersion.java | 65 + .../easyvcr/requestelements/Request.java | 121 ++ .../easyvcr/requestelements/Response.java | 454 +++++ .../easyvcr/requestelements/Status.java | 63 + src/test/java/FakeDataService.java | 104 ++ src/test/java/HttpUrlConnectionTest.java | 245 +++ src/test/java/TestUtils.java | 64 + src/test/java/VCRTest.java | 227 +++ style_suppressions.xml | 17 + 37 files changed, 7230 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 easypost_java_style.xml create mode 100644 pom.xml create mode 100644 src/main/java/com/easypost/easyvcr/AdvancedSettings.java create mode 100644 src/main/java/com/easypost/easyvcr/Cassette.java create mode 100644 src/main/java/com/easypost/easyvcr/Censors.java create mode 100644 src/main/java/com/easypost/easyvcr/HttpClientType.java create mode 100644 src/main/java/com/easypost/easyvcr/HttpClients.java create mode 100644 src/main/java/com/easypost/easyvcr/MatchRules.java create mode 100644 src/main/java/com/easypost/easyvcr/Mode.java create mode 100644 src/main/java/com/easypost/easyvcr/Statics.java create mode 100644 src/main/java/com/easypost/easyvcr/Utilities.java create mode 100644 src/main/java/com/easypost/easyvcr/VCR.java create mode 100644 src/main/java/com/easypost/easyvcr/VCRException.java create mode 100644 src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableHttpURLConnection.java create mode 100644 src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableHttpsURLConnection.java create mode 100644 src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableRequestBody.java create mode 100644 src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableURL.java create mode 100644 src/main/java/com/easypost/easyvcr/interactionconverters/BaseInteractionConverter.java create mode 100644 src/main/java/com/easypost/easyvcr/interactionconverters/HttpUrlConnectionInteractionConverter.java create mode 100644 src/main/java/com/easypost/easyvcr/internalutilities/Files.java create mode 100644 src/main/java/com/easypost/easyvcr/internalutilities/Tools.java create mode 100644 src/main/java/com/easypost/easyvcr/internalutilities/Utils.java create mode 100644 src/main/java/com/easypost/easyvcr/internalutilities/json/Serialization.java create mode 100644 src/main/java/com/easypost/easyvcr/requestelements/HttpElement.java create mode 100644 src/main/java/com/easypost/easyvcr/requestelements/HttpInteraction.java create mode 100644 src/main/java/com/easypost/easyvcr/requestelements/HttpVersion.java create mode 100644 src/main/java/com/easypost/easyvcr/requestelements/Request.java create mode 100644 src/main/java/com/easypost/easyvcr/requestelements/Response.java create mode 100644 src/main/java/com/easypost/easyvcr/requestelements/Status.java create mode 100644 src/test/java/FakeDataService.java create mode 100644 src/test/java/HttpUrlConnectionTest.java create mode 100644 src/test/java/TestUtils.java create mode 100644 src/test/java/VCRTest.java create mode 100644 style_suppressions.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..72e7dd1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [master] + pull_request: ~ + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + javaversion: ["8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18"] + steps: + - uses: actions/checkout@v3 + - name: Set up Java ${{ matrix.javaversion }} + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: ${{ matrix.javaversion }} + - name: Build and test with Maven + run: mvn --batch-mode install -Dgpg.skip=true -Dcheckstyle.skip=true + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Run CheckStyle checks + uses: nikitasavinov/checkstyle-action@0.5.1 + with: + level: error + fail_on_error: true + checkstyle_config: easypost_java_style.xml + tool_name: "style_enforcer" diff --git a/.gitignore b/.gitignore index a1c2a23..c836fce 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* +/target/ diff --git a/README.md b/README.md index a1cd9cc..ded4e90 100644 --- a/README.md +++ b/README.md @@ -1 +1,248 @@ -# easyvcr-java \ No newline at end of file +# EasyVCR + +[![CI](https://github.com/EasyPost/easyvcr-java/workflows/CI/badge.svg)](https://github.com/EasyPost/easyvcr-java/actions?query=workflow%3ACI) + +EasyVCR is a library for recording and replaying HTTP interactions in your test suite. + +This can be useful for speeding up your test suite, or for running your tests on a CI server which doesn't have connectivity to the HTTP endpoints you need to interact with. + +## Supported HTTP Clients + +- Java 8's [HttpUrlConnection](https://docs.oracle.com/javase/8/docs/api/java/net/HttpURLConnection.html) + +## How to use EasyVCR + +#### Step 1. + +Run your test suite locally against a real HTTP endpoint in recording mode + +```java +import com.easypost.easyvcr; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableURL; + +public class Example { + public static void main(String[] args) { + + // Create a cassette to handle HTTP interactions + Cassette cassette = new Cassette("path/to/cassettes", "my_cassette"); + + // create a RecordableURL using the cassette + RecordableURL recordableURL = new RecordableURL("https://www.example.com", cassette, Mode.Record); + + // open the connection to get a Http(s)URLConnection + RecordableHttpsURLConnection connection = recordableURL.openConnectionSecure(); + + // A RecordableHttp(s)URLConnection extends a normal Http(s)URLConnection, so you can use it as you would a normal Http(s)URLConnection + connection.setConnectTimeout(1000); + connection.connect(); + int responseCode = connection.getResponseCode(); + } +} +``` + +Real HTTP calls will be made and recorded to the cassette file. + +#### Step 2. + +Switch to replay mode: + +```java +import com.easypost.easyvcr; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableURL; + +public class Example { + public static void main(String[] args) { + + // Create a cassette to handle HTTP interactions + Cassette cassette = new Cassette("path/to/cassettes", "my_cassette"); + + // create a RecordableURL using the cassette + RecordableURL recordableURL = new RecordableURL("https://www.example.com", cassette, Mode.Replay); + + // open the connection to get a Http(s)URLConnection + RecordableHttpsURLConnection connection = recordableURL.openConnectionSecure(); + + // A RecordableHttp(s)URLConnection extends a normal Http(s)URLConnection, so you can use it as you would a normal Http(s)URLConnection + int responseCode = connection.getResponseCode(); + } +} +``` +Now when tests are run, no real HTTP calls will be made. Instead, the HTTP responses will be replayed from the cassette file. + +### Available modes + +- `Mode.Auto`: Play back a request if it has been recorded before, or record a new one if not. (default mode for `VCR`) +- `Mode.Record`: Record a request, including overwriting any existing matching recording. +- `Mode.Replay`: Replay a request. Throws an exception if no matching recording is found. +- `Mode.Bypass`: Do not record or replay any requests (client will behave like a normal HttpClient). + +## Features + +`EasyVCR` comes with a number of features, many of which can be customized via the `AdvancedOptions` class. + +### Censoring + +Censor sensitive data in the request and response bodies and headers, such as API keys and auth tokens. + +**Default**: *Disabled* + +```java +import com.easypost.easyvcr; +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Censors; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableURL; + +public class Example { + public static void main(String[] args) { + Cassette cassette = new Cassette("path/to/cassettes", "my_cassette"); + + AdvancedSettings advancedSettings = new AdvancedSettings(); + advancedSettings.censors = new Censors().hideHeader("Authorization"); // Hide the Authorization header + // or + advancedSettings.censors = Censors.strict(); // use the built-in strict censoring mode (hides common sensitive data) + + RecordableURL recordableURL = + new RecordableURL("https://www.example.com", cassette, Mode.Replay, advancedSettings); + + RecordableHttpsURLConnection connection = recordableURL.openConnectionSecure(); + } +} +``` + +### Delay + +Simulate a delay when replaying a recorded request, either using a specified delay or the original request duration. + +**Default**: *No delay* + +```java +import com.easypost.easyvcr; +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableURL; + +public class Example { + public static void main(String[] args) { + Cassette cassette = new Cassette("path/to/cassettes", "my_cassette"); + + AdvancedSettings advancedSettings = new AdvancedSettings(); + advancedSettings.manualDelay = 1000; // Simulate a delay of 1000 milliseconds when replaying + advancedSettings.simulateDelay = true; // Simulate a delay of the original request duration when replaying (overrides manualDelay) + + RecordableURL recordableURL = new RecordableURL("https://www.example.com", cassette, Mode.Replay, advancedSettings); + + RecordableHttpsURLConnection connection = recordableURL.openConnectionSecure(); + } +} +``` + +### Matching + +Customize how a recorded request is determined to be a match to the current request. + +**Default**: *Method and full URL must match* + +```java +import com.easypost.easyvcr; +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.MatchRules; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableURL; + +public class Example { + public static void main(String[] args) { + Cassette cassette = new Cassette("path/to/cassettes", "my_cassette"); + + AdvancedSettings advancedSettings = new AdvancedSettings(); + advancedSettings.matchRules = new MatchRules().byBody().byHeader("X-My-Header"); // Match recorded requests by request body (i.e. POST data) and a specific header + // or + advancedSettings.matchRules = MatchRules.strict(); // use the built-in strict matching mode (matches by method, full URL and request body; useful for POST/PATCH/PUT requests) + + RecordableURL recordableURL = + new RecordableURL("https://www.example.com", cassette, Mode.Replay, advancedSettings); + + RecordableHttpsURLConnection connection = recordableURL.openConnectionSecure(); + } +} +``` + +## VCR + +In addition to individual recordable HttpClient instances, `EasyVCR` also offers a built-in VCR, which can be used to easily switch between multiple cassettes and/or modes. Any advanced settings applied to the VCR will be applied on every request made using the VCR's HTTP client. + +```java +import com.easypost.easyvcr; +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Censors; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.VCR; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableURL; + +public class Example { + public static void main(String[] args) { + AdvancedSettings advancedSettings = new AdvancedSettings(); + advancedSettings.censors = new Censors().hideQueryParameter("api_key"); // hide the api_key query parameter + + // Create a VCR with the advanced settings applied + VCR vcr = new VCR(advancedSettings); + + // Create a cassette and add it to the VCR + Cassette cassette = new Cassette("path/to/cassettes", "my_cassette"); + vcr.insert(cassette); + + // Set the VCR to record mode + vcr.record(); + + // Get a RecordableURL instance from the VCR + RecordableURL recordableURL = vcr.getHttpUrlConnection("https://www.example.com"); + + // Use the client as you would normally. + RecordableHttpsURLConnection connection = recordableURL.openConnectionSecure(); + connection.connect(); + + // Remove the cassette from the VCR + vcr.eject(); + } +} +``` + +## Development + +### Tests + +```bash +# Build project +mvn clean install -DskipTests -Dgpg.skip + +# Run tests +mvn clean test -B + +# Run tests with coverage +mvn clean test -B jacoco:report +``` + +### Testing + +The test suite in this project was specifically built to produce consistent results on every run, regardless of when they run or who is running them. + +The cassettes used in the test suite are stored in a "cassettes" directory in the project root. Most of the cassettes produced by the test suite are erased and recreated on each run. Nevertheless, the test suite may complain if the cassettes are not present, so please do not delete them manually. + +#### Credit + +- [C# EasyVCR](https://github.com/EasyPost/easyvcr-csharp), upon which this library is based +- [Scotch by Martin Leech](https://github.com/mleech/scotch), whose core functionality inspired the C# version of EasyVCR diff --git a/easypost_java_style.xml b/easypost_java_style.xml new file mode 100644 index 0000000..6a019e2 --- /dev/null +++ b/easypost_java_style.xml @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..66e474e --- /dev/null +++ b/pom.xml @@ -0,0 +1,259 @@ + + + 4.0.0 + + com.easypost + easyvcr + + 0.1.0 + jar + + com.easypost:easyvcr + A powerful library for recording and replaying HTTP requests + https://github.com/easypost/easyvcr-java + + + + MIT License + http://www.opensource.org/licenses/mit-license.php + + + + + + EasyPost Engineering + oss@easypost.com + EasyPost + https://www.easypost.com + + + + + scm:git:git@github.com:easypost/easyvcr-java.git + scm:git:git@github.com:easypost/easyvcr-java.git + git@github.com:easypost/easyvcr-java.git + + + + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + com.google.code.gson + gson + 2.8.9 + + + junit + junit + 4.13.2 + test + + + org.hamcrest + hamcrest + 2.2 + test + + + org.jetbrains + annotations + RELEASE + compile + + + + commons-beanutils + commons-beanutils + 1.9.4 + + + + + EasyPost + https://easypost.com + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2 + + + + + + + . + + VERSION + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M3 + + + junit.jupiter.execution.order.random.seed=99 + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.5 + + + + prepare-agent + + + + report + prepare-package + + report + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.0 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 8 + + 3.3.1 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.0.1 + + + sign-artifacts + verify + + sign + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.9.0 + + 1.8 + 1.8 + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + ossrh + https://oss.sonatype.org/ + true + + + + org.codehaus.mojo + templating-maven-plugin + 1.0.0 + + + filtering-java-templates + + filter-sources + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.2 + + + com.puppycrawl.tools + checkstyle + 9.2.1 + + + + + validate + validate + + ${basedir}/easypost_java_style.xml + UTF-8 + true + true + + + check + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0 + + + enforce-java + + enforce + + + + + [1.8.0,) + + + + + + + + + + diff --git a/src/main/java/com/easypost/easyvcr/AdvancedSettings.java b/src/main/java/com/easypost/easyvcr/AdvancedSettings.java new file mode 100644 index 0000000..83ef622 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/AdvancedSettings.java @@ -0,0 +1,11 @@ +package com.easypost.easyvcr; + +public final class AdvancedSettings { + public MatchRules matchRules = MatchRules.regular(); + + public Censors censors = Censors.regular(); + + public boolean simulateDelay = false; + + public int manualDelay = 0; +} diff --git a/src/main/java/com/easypost/easyvcr/Cassette.java b/src/main/java/com/easypost/easyvcr/Cassette.java new file mode 100644 index 0000000..f7531f5 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/Cassette.java @@ -0,0 +1,182 @@ +package com.easypost.easyvcr; + +import com.easypost.easyvcr.internalutilities.Files; +import com.easypost.easyvcr.internalutilities.Tools; +import com.easypost.easyvcr.internalutilities.json.Serialization; +import com.easypost.easyvcr.requestelements.HttpInteraction; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Cassette used to store and retrieve requests and responses for EasyVCR. + */ +public final class Cassette { + + /** + * The name of the cassette. + */ + public final String name; + + /** + * The path to the cassette file. + */ + private final String filePath; + + /** + * Boolean indicating if cassette is locked. + */ + private boolean locked; + + /** + * Constructor for Cassette. + * + * @param folderPath The path to the folder where the cassette file will be stored. + * @param cassetteName The name of the cassette. + */ + public Cassette(String folderPath, String cassetteName) { + name = cassetteName; + filePath = Tools.getFilePath(folderPath, cassetteName + ".json"); + } + + /** + * Gets the number of interactions in the cassette. + * + * @return The number of interactions in the cassette. + */ + public int numInteractions() { + try { + return read().size(); + } catch (VCRException ex) { + return 0; + } + } + + /** + * Gets the cassette file as a File object. + * + * @return The cassette file as a File object. + */ + private File getFile() { + return Tools.getFile(filePath); + } + + /** + * Erase this cassette by deleting the file. + */ + public void erase() { + getFile().delete(); + } + + /** + * Lock this cassette (prevent reading or writing). + */ + public void lock() { + locked = true; + } + + /** + * Unlock this cassette (allow the cassette to be used). + */ + public void unlock() { + locked = false; + } + + /** + * Read all the interactions recorded on this cassette. + * + * @return A list of HttpInteractions + * @throws VCRException If the cassette could not be read + */ + public List read() throws VCRException { + checkIfLocked(); + + if (!fileExists()) { + return new ArrayList<>(); + } + + ArrayList interactions = new ArrayList<>(); + + String jsonString = Files.readFile(filePath); + if (jsonString == null) { + return interactions; // empty list because file doesn't exist or is empty + } + JsonElement cassetteParseResult = JsonParser.parseString(jsonString); + + for (JsonElement interaction : cassetteParseResult.getAsJsonArray()) { + interactions.add(Serialization.convertJsonToObject(interaction, HttpInteraction.class)); + } + return interactions; + } + + /** + * Overwrite an existing interaction on this cassette, or add a new one if it doesn't exist. + * + * @param httpInteraction The interaction to write to the cassette + * @param matchRules The rules to match the interaction against + * @param bypassSearch If true, the cassette will not be searched for an existing interaction + * @throws VCRException If the cassette could not be written to + */ + public void updateInteraction(HttpInteraction httpInteraction, MatchRules matchRules, boolean bypassSearch) + throws VCRException { + List existingInteractions = read(); + int matchingIndex = -1; + if (!bypassSearch) { + for (int i = 0; i < existingInteractions.size(); i++) { + if (matchRules.requestsMatch(existingInteractions.get(i).getRequest(), httpInteraction.getRequest())) { + matchingIndex = i; + break; + } + } + } + if (matchingIndex < 0) { + existingInteractions.add(httpInteraction); + } else { + existingInteractions.set(matchingIndex, httpInteraction); + } + + try { + write(existingInteractions); + } catch (IOException ex) { + throw new VCRException("Could not write to to cassette file"); + } + } + + /** + * Check if this cassette is locked. + * + * @throws VCRException If the cassette is locked + */ + private void checkIfLocked() throws VCRException { + if (locked) { + throw new VCRException("Cassette is locked."); + } + } + + /** + * Check if this cassette's file exists. + * + * @return True if the cassette file exists + */ + private boolean fileExists() { + return getFile().exists(); + } + + /** + * Write a list of interactions to this cassette. + * + * @param httpInteractions The list of interactions to write to the cassette + * @throws VCRException If the cassette could not be written to + */ + private void write(List httpInteractions) throws VCRException, IOException { + checkIfLocked(); + + String cassetteString = Serialization.convertObjectToJson(httpInteractions); + + Files.writeFile(filePath, cassetteString); + } +} diff --git a/src/main/java/com/easypost/easyvcr/Censors.java b/src/main/java/com/easypost/easyvcr/Censors.java new file mode 100644 index 0000000..95ed249 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/Censors.java @@ -0,0 +1,207 @@ +package com.easypost.easyvcr; + +import com.easypost.easyvcr.internalutilities.Tools; +import com.easypost.easyvcr.internalutilities.json.Serialization; +import com.google.gson.JsonSyntaxException; +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URLEncodedUtils; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Censoring capabilities for EasyVCR. + */ +public final class Censors { + /** + * The body parameters to censor. + */ + private final List bodyParamsToCensor; + /** + * The string to replace censored data with. + */ + private final String censorText; + /** + * The headers to censor. + */ + private final List headersToCensor; + /** + * The query parameters to censor. + */ + private final List queryParamsToCensor; + + /** + * Initialize a new instance of the Censors factory, using default censor string. + */ + public Censors() { + this(Statics.DEFAULT_CENSOR_TEXT); + } + + /** + * Initialize a new instance of the Censors factory. + * + * @param censorString The string to use to censor sensitive information. + */ + public Censors(String censorString) { + queryParamsToCensor = new ArrayList<>(); + bodyParamsToCensor = new ArrayList<>(); + headersToCensor = new ArrayList<>(); + censorText = censorString; + } + + /** + * Default censors is to not censor anything. + * + * @return Default set of censors. + */ + public static Censors regular() { + return new Censors(); + } + + /** + * Default sensitive censors is to censor common private information (i.e. API keys, auth tokens, etc.) + * + * @return Default sensitive set of censors. + */ + public static Censors strict() { + Censors censors = new Censors(); + for (String key : Statics.DEFAULT_CREDENTIAL_HEADERS_TO_HIDE) { + censors.hideHeader(key); + } + for (String key : Statics.DEFAULT_CREDENTIAL_PARAMETERS_TO_HIDE) { + censors.hideQueryParameter(key); + censors.hideBodyParameter(key); + } + return censors; + } + + /** + * Add a rule to censor a specified body parameter. + * Note: Only top-level pairs can be censored. + * + * @param parameterKey Key of body parameter to censor. + * @return This Censors factory. + */ + public Censors hideBodyParameter(String parameterKey) { + bodyParamsToCensor.add(parameterKey); + return this; + } + + /** + * Add a rule to censor a specified header key. + * Note: This will censor the header key in both the request and response. + * + * @param headerKey Key of header to censor. + * @return This Censors factory. + */ + public Censors hideHeader(String headerKey) { + headersToCensor.add(headerKey); + return this; + } + + /** + * Add a rule to censor a specified query parameter. + * + * @param parameterKey Key of query parameter to censor. + * @return This Censors factory. + */ + public Censors hideQueryParameter(String parameterKey) { + queryParamsToCensor.add(parameterKey); + return this; + } + + /** + * Censor the appropriate body parameters. + * + * @param body String representation of request body to apply censors to. + * @return Censored string representation of request body. + */ + public String applyBodyParametersCensors(String body) { + if (body == null || body.length() == 0) { + // short circuit if body is null or empty + return body; + } + + Map bodyParameters; + try { + bodyParameters = Serialization.convertJsonToObject(body, Map.class); + } catch (JsonSyntaxException ignored) { + // short circuit if body is not a JSON dictionary + return body; + } + + if (bodyParameters == null || bodyParameters.size() == 0) { + // short circuit if there are no body parameters + return body; + } + + for (String parameterKey : bodyParamsToCensor) { + if (bodyParameters.containsKey(parameterKey)) { + bodyParameters.put(parameterKey, censorText); + } + } + + return Serialization.convertObjectToJson(bodyParameters); + } + + /** + * Censor the appropriate headers. + * + * @param headers Map of headers to apply censors to. + * @return Censored map of headers. + */ + public Map> applyHeadersCensors(Map> headers) { + if (headers == null || headers.size() == 0) { + // short circuit if there are no headers to censor + return headers; + } + + final Map> headersCopy = new HashMap<>(headers); + + for (String headerKey : headersToCensor) { + if (headersCopy.containsKey(headerKey)) { + headersCopy.put(headerKey, Collections.singletonList(censorText)); + } + } + return headersCopy; + } + + /** + * Censor the appropriate query parameters. + * + * @param url Full URL string to apply censors to. + * @return Censored URL string. + */ + public String applyQueryParametersCensors(String url) { + if (url == null || url.length() == 0) { + // short circuit if url is null + return url; + } + URI uri = URI.create(url); + Map queryParameters = Tools.queryParametersToMap(uri); + if (queryParameters.size() == 0) { + // short circuit if there are no query parameters to censor + return url; + } + + for (String parameterKey : queryParamsToCensor) { + if (queryParameters.containsKey(parameterKey)) { + queryParameters.put(parameterKey, censorText); + } + } + + List censoredQueryParametersList = Tools.mapToQueryParameters(queryParameters); + String formattedQueryParameters = URLEncodedUtils.format(censoredQueryParametersList, StandardCharsets.UTF_8); + if (formattedQueryParameters.length() == 0) { + // short circuit if there are no query parameters to censor + return url; + } + + return uri.getScheme() + "://" + uri.getHost() + uri.getPath() + "?" + formattedQueryParameters; + } +} diff --git a/src/main/java/com/easypost/easyvcr/HttpClientType.java b/src/main/java/com/easypost/easyvcr/HttpClientType.java new file mode 100644 index 0000000..7b9689b --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/HttpClientType.java @@ -0,0 +1,6 @@ +package com.easypost.easyvcr; + +public enum HttpClientType { + HttpUrlConnection, + HttpsUrlConnection +} diff --git a/src/main/java/com/easypost/easyvcr/HttpClients.java b/src/main/java/com/easypost/easyvcr/HttpClients.java new file mode 100644 index 0000000..2a89d1b --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/HttpClients.java @@ -0,0 +1,130 @@ +package com.easypost.easyvcr; + +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableURL; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URISyntaxException; + +/** + * HttpClient singleton for EasyVCR. + */ +public final class HttpClients { + + /** + * Get a new client configured to use cassettes. + * + * @param type HttpClientType type of client to create. + * @param url String url to use when creating the client. + * @param cassette Cassette to use when creating the client. + * @param mode Mode to use when creating the client. + * @param advancedSettings AdvancedSettings to use when creating the client. + * @return Object client. + * @throws URISyntaxException If the url is malformed. + * @throws IOException If there is an error creating the client. + */ + public static Object newClient(HttpClientType type, String url, Cassette cassette, Mode mode, + AdvancedSettings advancedSettings) throws URISyntaxException, IOException { + switch (type) { + case HttpUrlConnection: + return newHttpURLConnection(url, cassette, mode, advancedSettings); + case HttpsUrlConnection: + return newHttpsURLConnection(url, cassette, mode, advancedSettings); + default: + throw new IllegalArgumentException("Unsupported HttpClientType: " + type); + } + } + + /** + * Get a new client configured to use cassettes. + * + * @param type HttpClientType type of client to create. + * @param url String url to use when creating the client. + * @param cassette Cassette to use when creating the client. + * @param mode Mode to use when creating the client. + * @return Object client. + * @throws URISyntaxException If the url is malformed. + * @throws IOException If there is an error creating the client. + */ + public static Object newClient(HttpClientType type, String url, Cassette cassette, Mode mode) + throws URISyntaxException, IOException { + return newClient(type, url, cassette, mode, null); + } + + /** + * Get a new RecordableURL configured to use cassettes. + * + * @param url String url to use when creating the client. + * @param cassette Cassette to use when creating the client. + * @param mode Mode to use when creating the client. + * @param advancedSettings AdvancedSettings to use when creating the client. + * @return RecordableURL client. + * @throws MalformedURLException If the url is malformed. + */ + private static RecordableURL newRecordableURL(String url, Cassette cassette, Mode mode, + AdvancedSettings advancedSettings) throws MalformedURLException { + return new RecordableURL(url, cassette, mode, advancedSettings); + } + + /** + * Get a new RecordableHttpURLConnection configured to use cassettes. + * + * @param url String url to use when creating the client. + * @param cassette Cassette to use when creating the client. + * @param mode Mode to use when creating the client. + * @param advancedSettings AdvancedSettings to use when creating the client. + * @return RecordableHttpURLConnection client. + * @throws IOException If there is an error creating the client. + */ + public static RecordableHttpURLConnection newHttpURLConnection(String url, Cassette cassette, Mode mode, + AdvancedSettings advancedSettings) + throws IOException { + return newRecordableURL(url, cassette, mode, advancedSettings).openConnection(); + } + + /** + * Get a new RecordableHttpURLConnection configured to use cassettes. + * + * @param url String url to use when creating the client. + * @param cassette Cassette to use when creating the client. + * @param mode Mode to use when creating the client. + * @return RecordableHttpURLConnection client. + * @throws IOException If there is an error creating the client. + */ + public static RecordableHttpURLConnection newHttpURLConnection(String url, Cassette cassette, Mode mode) + throws IOException { + return newRecordableURL(url, cassette, mode, null).openConnection(); + } + + /** + * Get a new RecordableHttpsURLConnection configured to use cassettes. + * + * @param url String url to use when creating the client. + * @param cassette Cassette to use when creating the client. + * @param mode Mode to use when creating the client. + * @param advancedSettings AdvancedSettings to use when creating the client. + * @return RecordableHttpsURLConnection client. + * @throws IOException If there is an error creating the client. + */ + public static RecordableHttpsURLConnection newHttpsURLConnection(String url, Cassette cassette, Mode mode, + AdvancedSettings advancedSettings) + throws IOException { + return newRecordableURL(url, cassette, mode, advancedSettings).openConnectionSecure(); + } + + /** + * Get a new RecordableHttpsURLConnection configured to use cassettes. + * + * @param url String url to use when creating the client. + * @param cassette Cassette to use when creating the client. + * @param mode Mode to use when creating the client. + * @return RecordableHttpsURLConnection client. + * @throws IOException If there is an error creating the client. + */ + public static RecordableHttpsURLConnection newHttpsURLConnection(String url, Cassette cassette, Mode mode) + throws IOException { + return newRecordableURL(url, cassette, mode, null).openConnectionSecure(); + } +} diff --git a/src/main/java/com/easypost/easyvcr/MatchRules.java b/src/main/java/com/easypost/easyvcr/MatchRules.java new file mode 100644 index 0000000..d7e5311 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/MatchRules.java @@ -0,0 +1,235 @@ +package com.easypost.easyvcr; + +import com.easypost.easyvcr.internalutilities.Tools; +import com.easypost.easyvcr.requestelements.Request; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +/** + * Rule set for matching requests against recorded requests. + */ +public final class MatchRules { + private final List> rules; + + /** + * Construct a new MatchRules factory. + */ + public MatchRules() { + rules = new ArrayList<>(); + } + + /** + * Default rule is to match on the method and URL. + * + * @return Default MatchRules object. + */ + public static MatchRules regular() { + return new MatchRules().byMethod().byFullUrl(); + } + + /** + * Default strict rule is to match on the method, URL and body. + * + * @return Default strict MatchRules object. + */ + public static MatchRules strict() { + return new MatchRules().byMethod().byFullUrl().byBody(); + } + + private void by(BiFunction rule) { + rules.add(rule); + } + + /** + * Add a rule to compare the base URLs of the requests. + * + * @return This MatchRules factory. + */ + public MatchRules byBaseUrl() { + by((received, recorded) -> { + String receivedUri = received.getUri().getPath(); + String recordedUri = recorded.getUri().getPath(); + return receivedUri.equalsIgnoreCase(recordedUri); + }); + return this; + } + + /** + * Add a rule to compare the bodies of the requests. + * + * @return This MatchRules factory. + */ + public MatchRules byBody() { + by((received, recorded) -> { + if (received.getBody() == null && recorded.getBody() == null) { + // both have null bodies, so they match + return true; + } + + if (received.getBody() == null || recorded.getBody() == null) { + // one has a null body, so they don't match + return false; + } + + // convert body to base64string to assist comparison by removing special characters + String receivedBody = Tools.toBase64String(received.getBody()); + String recordedBody = Tools.toBase64String(recorded.getBody()); + return receivedBody.equalsIgnoreCase(recordedBody); + }); + return this; + } + + /** + * Add a rule to compare the entire requests. + * Note, this rule is very strict, and will fail if the requests are not identical (including duration). + * It is highly recommended to use the other rules to compare the requests. + * + * @return This MatchRules factory. + */ + public MatchRules byEverything() { + by((received, recorded) -> { + String receivedRequest = received.toJson(); + String recordedRequest = recorded.toJson(); + return receivedRequest.equalsIgnoreCase(recordedRequest); + }); + return this; + } + + /** + * Add a rule to compare the full URLs (including query parameters) of the requests. + * Default: query parameter order does not matter. + * + * @return This MatchRules factory. + */ + public MatchRules byFullUrl() { + return byFullUrl(false); + } + + /** + * Add a rule to compare the full URLs (including query parameters) of the requests. + * + * @param exact If true, query parameters must be in the same exact order to match. + * If false, query parameter order doesn't matter. + * @return This MatchRules factory. + */ + public MatchRules byFullUrl(boolean exact) { + if (exact) { + by((received, recorded) -> { + String receivedUri = Tools.toBase64String(received.getUriString()); + String recordedUri = Tools.toBase64String(recorded.getUriString()); + return receivedUri.equalsIgnoreCase(recordedUri); + }); + } else { + byBaseUrl(); + by((received, recorded) -> { + Map receivedQuery = Tools.queryParametersToMap(received.getUri()); + Map recordedQuery = Tools.queryParametersToMap(recorded.getUri()); + if (receivedQuery.size() != recordedQuery.size()) { + return false; + } + for (Map.Entry entry : receivedQuery.entrySet()) { + if (!recordedQuery.containsKey(entry.getKey())) { + return false; + } + } + return true; + }); + } + + return this; + } + + /** + * Add a rule to compare a specific header of the requests. + * + * @param name Key of the header to compare. + * @return This MatchRules factory. + */ + public MatchRules byHeader(String name) { + by((received, recorded) -> { + Map> receivedHeaders = received.getHeaders(); + Map> recordedHeaders = recorded.getHeaders(); + if (!receivedHeaders.containsKey(name) || !recordedHeaders.containsKey(name)) { + return false; + } + List receivedHeader = receivedHeaders.get(name); + List recordedHeader = recordedHeaders.get(name); + return receivedHeader.equals(recordedHeader); + }); + return this; + } + + /** + * Add a rule to compare the headers of the requests. + * Default: headers strictness is set to false. + * + * @return This MatchRules factory. + */ + public MatchRules byHeaders() { + return byHeaders(false); + } + + /** + * Add a rule to compare the headers of the requests. + * + * @param exact If true, both requests must have the exact same headers. + * If false, as long as the evaluated request has all the headers of the matching request + * (and potentially more), the match is considered valid. + * @return This MatchRules factory. + */ + public MatchRules byHeaders(boolean exact) { + if (exact) { + // first, we'll check that there are the same number of headers in both requests. + // If they are, then the second check is guaranteed to compare all headers. + by((received, recorded) -> received.getHeaders().size() == recorded.getHeaders().size()); + } + + by((received, recorded) -> { + Map> receivedHeaders = received.getHeaders(); + Map> recordedHeaders = recorded.getHeaders(); + for (String headerName : receivedHeaders.keySet()) { + if (!recordedHeaders.containsKey(headerName)) { + return false; + } + if (!receivedHeaders.get(headerName).equals(recordedHeaders.get(headerName))) { + return false; + } + } + return true; + }); + return this; + } + + /** + * Add a rule to compare the HTTP methods of the requests. + * + * @return This MatchRules factory. + */ + public MatchRules byMethod() { + by((received, recorded) -> received.getMethod().equalsIgnoreCase(recorded.getMethod())); + return this; + } + + /** + * Execute rules to determine if the received request matches the recorded request. + * + * @param receivedRequest Request to find a match for. + * @param recordedRequest Request to compare against. + * @return True if the received request matches the recorded request, false otherwise. + */ + public boolean requestsMatch(Request receivedRequest, Request recordedRequest) { + if (rules.size() == 0) { + return true; + } + + for (BiFunction rule : rules) { + if (!rule.apply(receivedRequest, recordedRequest)) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/com/easypost/easyvcr/Mode.java b/src/main/java/com/easypost/easyvcr/Mode.java new file mode 100644 index 0000000..bc1a323 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/Mode.java @@ -0,0 +1,8 @@ +package com.easypost.easyvcr; + +public enum Mode { + Bypass, + Auto, + Record, + Replay +} diff --git a/src/main/java/com/easypost/easyvcr/Statics.java b/src/main/java/com/easypost/easyvcr/Statics.java new file mode 100644 index 0000000..dfbe511 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/Statics.java @@ -0,0 +1,37 @@ +package com.easypost.easyvcr; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class Statics { + public static final String VIA_RECORDING_HEADER_KEY = "X-Via-EasyVCR-Recording"; + /** + * Default headers to censor (credential-related headers). + */ + public static final List DEFAULT_CREDENTIAL_HEADERS_TO_HIDE = + new ArrayList<>(Collections.singletonList("Authorization")); + /** + * Default parameters to censor (credential-related parameters). + */ + public static final List DEFAULT_CREDENTIAL_PARAMETERS_TO_HIDE = new ArrayList<>( + Arrays.asList("api_key", "apiKey", "key", "api_token", "apiToken", "token", "access_token", "client_id", + "client_secret", "password", "secret", "username")); + /** + * Default string to use to censor sensitive information. + */ + public static final String DEFAULT_CENSOR_TEXT = "*****"; + + /** + * Get a map of the EasyVCR replay headers. + * @return a map of the EasyVCR replay headers. + */ + public static Map getReplayHeaders() { + Map replayHeaders = new HashMap(); + replayHeaders.put(VIA_RECORDING_HEADER_KEY, "true"); + return replayHeaders; + } +} diff --git a/src/main/java/com/easypost/easyvcr/Utilities.java b/src/main/java/com/easypost/easyvcr/Utilities.java new file mode 100644 index 0000000..8f7ebdd --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/Utilities.java @@ -0,0 +1,33 @@ +package com.easypost.easyvcr; + +import javax.net.ssl.HttpsURLConnection; +import java.net.HttpURLConnection; +import java.util.List; +import java.util.Map; + +public class Utilities { + + /** + * Check if the connection came from an EasyVCR recording. + * @param connection The connection to check. + * @return True if the connection came from an EasyVCR recording. + */ + public static boolean responseCameFromRecording(HttpsURLConnection connection) { + return responseCameFromRecording((HttpURLConnection) connection); + } + + /** + * Check if the connection came from an EasyVCR recording. + * @param connection The connection to check. + * @return True if the connection came from an EasyVCR recording. + */ + public static boolean responseCameFromRecording(HttpURLConnection connection) { + Map> headers = connection.getHeaderFields(); + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() != null && entry.getKey().equals(Statics.VIA_RECORDING_HEADER_KEY)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/easypost/easyvcr/VCR.java b/src/main/java/com/easypost/easyvcr/VCR.java new file mode 100644 index 0000000..c897184 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/VCR.java @@ -0,0 +1,193 @@ +package com.easypost.easyvcr; + +import com.easypost.easyvcr.clients.httpurlconnection.RecordableURL; + +import java.net.MalformedURLException; +import java.net.URL; + +/** + * Built-in VCR tool for EasyVCR. + */ +public final class VCR { + /** + * The current cassette in the VCR. + */ + private Cassette currentCassette; + /** + * The operating mode of the VCR. + */ + private Mode mode; + /** + * Advanced settings for the VCR. + */ + private AdvancedSettings advancedSettings; + + /** + * Constructor for VCR. + * + * @param advancedSettings Advanced settings for the VCR. + */ + public VCR(AdvancedSettings advancedSettings) { + this.advancedSettings = advancedSettings; + } + + /** + * Constructor for VCR. + */ + public VCR() { + this.advancedSettings = new AdvancedSettings(); + } + + /** + * Gets the name of the current cassette in the VCR. + * + * @return The name of the current cassette in the VCR. + */ + public String getCassetteName() { + if (this.currentCassette == null) { + return null; + } + return this.currentCassette.name; + } + + /** + * Retrieve a pre-configured RecordableURL object that will use the VCR. + * + * @param url The URL to use to create the RecordableURL object. + * @return A pre-configured RecordableURL object that will use the VCR. + * @throws MalformedURLException If the URL is malformed. + * @throws VCRException If the cassette is not loaded. + */ + public RecordableURL getHttpUrlConnection(URL url) throws MalformedURLException, VCRException { + if (this.currentCassette == null) { + throw new VCRException("No cassette is currently loaded."); + } + return new RecordableURL(url, this.currentCassette, this.mode, this.advancedSettings); + } + + /** + * Retrieve a pre-configured RecordableURL object that will use the VCR. + * + * @param url The URL string to use to create the RecordableURL object. + * @return A pre-configured RecordableURL object that will use the VCR. + * @throws MalformedURLException If the URL is malformed. + * @throws VCRException If the cassette is not loaded. + */ + public RecordableURL getHttpUrlConnection(String url) throws MalformedURLException, VCRException { + if (this.currentCassette == null) { + throw new VCRException("No cassette is currently loaded."); + } + return new RecordableURL(url, this.currentCassette, this.mode, this.advancedSettings); + } + + /** + * Gets the current operating mode of the VCR. + * + * @return The current operating mode of the VCR. + */ + public Mode getMode() { + if (this.mode == Mode.Bypass) { + return Mode.Bypass; + } + Mode environmentMode = getModeFromEnvironment(); + return environmentMode != null ? environmentMode : this.mode; + } + + /** + * Set the mode for the VCR. + * + * @param mode The mode to set for the VCR. + */ + private void setMode(Mode mode) { + this.mode = mode; + } + + /** + * Get the advanced settings for the VCR. + * + * @return Advanced settings for the VCR. + */ + public AdvancedSettings getAdvancedSettings() { + return this.advancedSettings; + } + + /** + * Set the advanced settings for the VCR. + * + * @param advancedSettings Advanced settings for the VCR. + */ + public void setAdvancedSettings(AdvancedSettings advancedSettings) { + this.advancedSettings = advancedSettings; + } + + /** + * Remove the current cassette from the VCR. + */ + public void eject() { + this.currentCassette = null; + } + + /** + * Erase the cassette in the VCR. + */ + public void erase() { + if (this.currentCassette != null) { + this.currentCassette.erase(); + } + } + + /** + * Add a cassette to the VCR (or replace the current one). + * + * @param cassette The cassette to add to the VCR. + */ + public void insert(Cassette cassette) { + this.currentCassette = cassette; + } + + /** + * Enable passthrough mode on the VCR (HTTP requests will be made as normal). + */ + public void pause() { + setMode(Mode.Bypass); + } + + /** + * Enable recording mode on the VCR. + */ + public void record() { + setMode(Mode.Record); + } + + /** + * Enable playback mode on the VCR. + */ + public void replay() { + setMode(Mode.Replay); + } + + /** + * Enable auto mode on the VCR (record if needed, replay otherwise). + */ + public void recordIfNeeded() { + setMode(Mode.Auto); + } + + /** + * Get the current operating mode of the VCR from an environment variable. + * + * @return The current operating mode of the VCR from an environment variable. + */ + private Mode getModeFromEnvironment() { + final String keyName = "EASYVCR_MODE"; + try { + String keyValue = System.getenv(keyName); + if (keyValue == null) { + return null; + } + return Mode.valueOf(keyValue); + } catch (Exception ignored) { + return null; + } + } +} diff --git a/src/main/java/com/easypost/easyvcr/VCRException.java b/src/main/java/com/easypost/easyvcr/VCRException.java new file mode 100644 index 0000000..54a6e94 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/VCRException.java @@ -0,0 +1,16 @@ +package com.easypost.easyvcr; + +/** + * Custom exception for EasyVCR. + */ +public final class VCRException extends Exception { + + /** + * Constructs a new VCRException with the specified detail message. + * + * @param message the error message. + */ + public VCRException(String message) { + super(message); + } +} diff --git a/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableHttpURLConnection.java b/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableHttpURLConnection.java new file mode 100644 index 0000000..57acf51 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableHttpURLConnection.java @@ -0,0 +1,1392 @@ +package com.easypost.easyvcr.clients.httpurlconnection; + +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.VCRException; +import com.easypost.easyvcr.interactionconverters.HttpUrlConnectionInteractionConverter; +import com.easypost.easyvcr.requestelements.HttpInteraction; +import com.easypost.easyvcr.requestelements.Request; + +import javax.net.ssl.HttpsURLConnection; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpRetryException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.ProtocolException; +import java.net.Proxy; +import java.net.SocketPermission; +import java.net.URL; +import java.net.UnknownServiceException; +import java.security.Permission; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static com.easypost.easyvcr.internalutilities.Tools.createInputStream; +import static com.easypost.easyvcr.internalutilities.Tools.simulateDelay; + +public final class RecordableHttpURLConnection extends HttpURLConnection { + + // interaction is not actually recorded until you getX() from the result + // connect() or any getX() function will cache the interaction + // you cannot setX() after the interaction has been cached + + // TODO: ^ Eventually allow users to set after cache (update cache) + + /** + * The internal HttpsURLConnection that this class wraps. + */ + private HttpURLConnection connection; + + private final RecordableRequestBody requestBody; + /** + * The HttpUrlConnectionInteractionConverter that converts the HttpsURLConnection to an HttpInteraction. + */ + private final HttpUrlConnectionInteractionConverter converter; + /** + * The Cassette that this class is recording to and reading from. + */ + private final Cassette cassette; + /** + * The VCR mode that this class is using. + */ + private final Mode mode; + /** + * The AdvancedSettings that this class is using. + */ + private final AdvancedSettings advancedSettings; + /** + * Internal cached HttpInteraction storing the request and response details. + */ + private HttpInteraction cachedInteraction; + + /** + * Constructor for the RecordableHttpsURLConnection class. + * + * @param url The URL to connect to. + * @param proxy The proxy to use. + * @param cassette The cassette to use. + * @param mode The mode to use. + * @param advancedSettings The advanced settings to use. + * @throws IOException If an error occurs. + */ + public RecordableHttpURLConnection(URL url, Proxy proxy, Cassette cassette, Mode mode, + AdvancedSettings advancedSettings) throws IOException { + // this super is not used + super(url); + if (proxy == null) { + this.connection = (HttpsURLConnection) url.openConnection(); + } else { + this.connection = (HttpsURLConnection) url.openConnection(proxy); + } + this.requestBody = new RecordableRequestBody(); + this.cachedInteraction = null; + this.cassette = cassette; + this.mode = mode; + this.advancedSettings = advancedSettings; + this.converter = new HttpUrlConnectionInteractionConverter(); + } + + /** + * Constructor for the RecordableHttpsURLConnection class. + * + * @param url The URL to connect to. + * @param cassette The cassette to use. + * @param mode The mode to use. + * @param advancedSettings The advanced settings to use. + * @throws IOException If an error occurs. + */ + public RecordableHttpURLConnection(URL url, Cassette cassette, Mode mode, AdvancedSettings advancedSettings) + throws IOException { + this(url, null, cassette, mode, advancedSettings); + } + + /** + * Constructor for the RecordableHttpsURLConnection class. + * + * @param url The URL to connect to. + * @param proxy The proxy to use. + * @param cassette The cassette to use. + * @param mode The mode to use. + * @throws IOException If an error occurs. + */ + public RecordableHttpURLConnection(URL url, Proxy proxy, Cassette cassette, Mode mode) throws IOException { + this(url, proxy, cassette, mode, new AdvancedSettings()); + } + + /** + * Constructor for the RecordableHttpsURLConnection class. + * + * @param url The URL to connect to. + * @param cassette The cassette to use. + * @param mode The mode to use. + * @throws IOException If an error occurs. + */ + public RecordableHttpURLConnection(URL url, Cassette cassette, Mode mode) throws IOException { + this(url, cassette, mode, new AdvancedSettings()); + } + + /** + * Get an object from the cache. + * + * @param getter Function to get the object from the cache. + * @param defaultValue The default value to return if the object is not in the cache. + * @return The object from the cache. + * @throws VCRException If an error occurs. + */ + private Object getObjectElementFromCache(Function getter, Object defaultValue) + throws VCRException { + if (this.cachedInteraction == null) { + return defaultValue; + } + try { + return getter.apply(this.cachedInteraction); + } catch (Exception e) { + throw new VCRException("Error getting string element from cache"); + } + } + + /** + * Get a string from the cache. + * + * @param getter Function to get the string from the cache. + * @param defaultValue The default value to return if the string is not in the cache. + * @return The string from the cache. + * @throws VCRException If an error occurs. + */ + private String getStringElementFromCache(Function getter, String defaultValue) + throws VCRException { + if (this.cachedInteraction == null) { + return defaultValue; + } + try { + return getter.apply(this.cachedInteraction); + } catch (Exception e) { + throw new VCRException("Error getting string element from cache"); + } + } + + /** + * Get a boolean from the cache. + * + * @param getter Function to get the boolean from the cache. + * @param defaultValue The default value to return if the boolean is not in the cache. + * @return The boolean from the cache. + * @throws VCRException If an error occurs. + */ + private boolean getBooleanElementFromCache(Function getter, boolean defaultValue) + throws VCRException { + if (this.cachedInteraction == null) { + return defaultValue; + } + try { + return getter.apply(this.cachedInteraction); + } catch (Exception e) { + throw new VCRException("Error getting boolean element from cache"); + } + } + + /** + * Get an integer from the cache. + * + * @param getter Function to get the integer from the cache. + * @param defaultValue The default value to return if the integer is not in the cache. + * @return The integer from the cache. + * @throws VCRException If an error occurs. + */ + private int getIntegerElementFromCache(Function getter, int defaultValue) + throws VCRException { + if (this.cachedInteraction == null) { + return defaultValue; + } + try { + return getter.apply(this.cachedInteraction); + } catch (Exception e) { + throw new VCRException("Error getting integer element from cache"); + } + } + + /** + * Cache the current request and response to an in-memory HttpInteraction. + * This is done once on the first attempt to retrieve request or response details. + * + * @param recordToCassette Whether to also record the cached interaction to the cassette. + * @throws VCRException If an error occurs. + */ + private void cacheInteraction(boolean recordToCassette) throws VCRException { + // record this interaction + // only need to execute this once, on the first getX(), since no more setX() is allowed at that point + // so the request and response won't be changing + // important to call directly on connection, rather than this.function() to avoid potential recursion + this.cachedInteraction = + this.converter.createInteraction(this.connection, this.requestBody, this.advancedSettings.censors); + if (recordToCassette) { + this.cassette.updateInteraction(this.cachedInteraction, this.advancedSettings.matchRules, false); + } + } + + /** + * Load an existing interaction from the cassette and cache it. + * + * @return boolean indicating whether the interaction was found and cached successfully. + * @throws VCRException If an error occurs. + * @throws InterruptedException If the thread is interrupted. + */ + private boolean loadExistingInteraction() throws VCRException, InterruptedException { + Request request = + converter.createRecordedRequest(this.connection, this.requestBody, this.advancedSettings.censors); + // null because couldn't be created + if (request == null) { + return false; + } + HttpInteraction matchingInteraction = + converter.findMatchingInteraction(this.cassette, request, advancedSettings.matchRules); + if (matchingInteraction == null) { + return false; + } + simulateDelay(matchingInteraction, this.advancedSettings); + this.cachedInteraction = matchingInteraction; + this.cachedInteraction.getResponse().addReplayHeaders(); + return true; + } + + /** + * Build an in-memory cache of the current request and response details if needed. + * + * @throws VCRException If an error occurs. + */ + private void buildCache() throws VCRException { + // run every time a user attempts to getX() + + // can't setX() after the first getX(), so if cache has already been built, can't build again + if (this.cachedInteraction != null) { + return; + } + + switch (mode) { + case Record: + cacheInteraction(true); + break; + case Replay: + try { + loadExistingInteraction(); + } catch (VCRException | InterruptedException e) { + throw new RuntimeException(e); + } + break; + case Auto: + try { + if (!loadExistingInteraction()) { + cacheInteraction(true); + } + } catch (VCRException | InterruptedException e) { + throw new RuntimeException(e); + } + break; + case Bypass: + default: + break; + } + } + + /** + * Clear the in-memory cache of the current request and response details. + */ + private void clearCache() { + this.cachedInteraction = null; + } + + @Override + public void connect() throws IOException { + try { + buildCache(); // can't set anything after connecting, so might as well build the cache now + // will establish connection as a result of caching, so need to disconnect afterwards + if (this.requestBody.hasData()) { + this.connection.setDoOutput( + true); // have to set this to true to allow the ability to get and write to output stream + this.requestBody.writeToOutputStream( + this.connection.getOutputStream()); + // have to write this at the last second, otherwise locks us out + } + this.connection.disconnect(); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + @Override + public void disconnect() { + this.connection.disconnect(); + clearCache(); + } + + @Override + public boolean usingProxy() { + return this.connection.usingProxy(); + } + + /** + * Returns the key for the {@code n}th header field. + * Some implementations may treat the {@code 0}th + * header field as special, i.e. as the status line returned by the HTTP + * server. In this case, {@link #getHeaderField(int) getHeaderField(0)} returns the status + * line, but {@code getHeaderFieldKey(0)} returns null. + * + * @param n an index, where {@code n >=0}. + * @return the key for the {@code n}th header field, + * or {@code null} if the key does not exist. + */ + @Override + public String getHeaderFieldKey(int n) { + if (mode == Mode.Bypass) { + return this.connection.getHeaderFieldKey(n); + } + try { + buildCache(); + return getStringElementFromCache( + (interaction) -> interaction.getResponse().getHeaders().keySet().toArray()[n].toString(), null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * This method is used to enable streaming of a HTTP request body + * without internal buffering, when the content length is known in + * advance. + *

+ * An exception will be thrown if the application + * attempts to write more data than the indicated + * content-length, or if the application closes the OutputStream + * before writing the indicated amount. + *

+ * When output streaming is enabled, authentication + * and redirection cannot be handled automatically. + * A HttpRetryException will be thrown when reading + * the response if authentication or redirection are required. + * This exception can be queried for the details of the error. + *

+ * This method must be called before the URLConnection is connected. + *

+ * NOTE: {@link #setFixedLengthStreamingMode(long)} is recommended + * instead of this method as it allows larger content lengths to be set. + * + * @param contentLength The number of bytes which will be written + * to the OutputStream. + * @throws IllegalStateException if URLConnection is already connected + * or if a different streaming mode is already enabled. + * @throws IllegalArgumentException if a content length less than + * zero is specified. + * @see #setChunkedStreamingMode(int) + * @since 1.5 + */ + @Override + public void setFixedLengthStreamingMode(int contentLength) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setFixedLengthStreamingMode(contentLength); + } + + /** + * This method is used to enable streaming of a HTTP request body + * without internal buffering, when the content length is known in + * advance. + * + *

An exception will be thrown if the application attempts to write + * more data than the indicated content-length, or if the application + * closes the OutputStream before writing the indicated amount. + * + *

When output streaming is enabled, authentication and redirection + * cannot be handled automatically. A {@linkplain HttpRetryException} will + * be thrown when reading the response if authentication or redirection + * are required. This exception can be queried for the details of the + * error. + * + *

This method must be called before the URLConnection is connected. + * + *

The content length set by invoking this method takes precedence + * over any value set by {@link #setFixedLengthStreamingMode(int)}. + * + * @param contentLength The number of bytes which will be written to the OutputStream. + * @throws IllegalStateException if URLConnection is already connected or if a different + * streaming mode is already enabled. + * @throws IllegalArgumentException if a content length less than zero is specified. + * @since 1.7 + */ + @Override + public void setFixedLengthStreamingMode(long contentLength) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setFixedLengthStreamingMode(contentLength); + } + + /** + * This method is used to enable streaming of a HTTP request body + * without internal buffering, when the content length is not + * known in advance. In this mode, chunked transfer encoding + * is used to send the request body. Note, not all HTTP servers + * support this mode. + *

+ * When output streaming is enabled, authentication + * and redirection cannot be handled automatically. + * A HttpRetryException will be thrown when reading + * the response if authentication or redirection are required. + * This exception can be queried for the details of the error. + *

+ * This method must be called before the URLConnection is connected. + * + * @param chunklen The number of bytes to write in each chunk. + * If chunklen is less than or equal to zero, a default + * value will be used. + * @throws IllegalStateException if URLConnection is already connected + * or if a different streaming mode is already enabled. + * @see #setFixedLengthStreamingMode(int) + * @since 1.5 + */ + @Override + public void setChunkedStreamingMode(int chunklen) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setChunkedStreamingMode(chunklen); + } + + /** + * Returns the value for the {@code n}th header field. + * Some implementations may treat the {@code 0}th + * header field as special, i.e. as the status line returned by the HTTP + * server. + *

+ * This method can be used in conjunction with the + * {@link #getHeaderFieldKey getHeaderFieldKey} method to iterate through all + * the headers in the message. + * + * @param n an index, where {@code n>=0}. + * @return the value of the {@code n}th header field, + * or {@code null} if the value does not exist. + * @see java.net.HttpURLConnection#getHeaderFieldKey(int) + */ + @Override + public String getHeaderField(int n) { + if (mode == Mode.Bypass) { + return this.connection.getHeaderField(n); + } + try { + buildCache(); + return getStringElementFromCache( + (interaction) -> interaction.getResponse().getHeaders().values().toArray()[n].toString(), null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns the value of this {@code RecordableHttpUrlConnection}'s + * {@code instanceFollowRedirects} field. + * + * @return the value of this {@code RecordableHttpUrlConnection}'s + * {@code instanceFollowRedirects} field. + * @see #setInstanceFollowRedirects(boolean) + * @since 1.3 + */ + @Override + public boolean getInstanceFollowRedirects() { + // not in cassette, go to real connection + return this.connection.getInstanceFollowRedirects(); + } + + /** + * Sets whether HTTP redirects (requests with response code 3xx) should + * be automatically followed by this {@code RecordableHttpUrlConnection} + * instance. + *

+ * The default value comes from followRedirects, which defaults to + * true. + * + * @param followRedirects a {@code boolean} indicating + * whether or not to follow HTTP redirects. + * @see #getInstanceFollowRedirects + * @since 1.3 + */ + @Override + public void setInstanceFollowRedirects(boolean followRedirects) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setInstanceFollowRedirects(followRedirects); + } + + /** + * get the request method. + * + * @return the HTTP request method + * @see #setRequestMethod(java.lang.String) + */ + @Override + public String getRequestMethod() { + if (mode == Mode.Bypass) { + return this.connection.getRequestMethod(); + } + try { + buildCache(); + return getStringElementFromCache((interaction) -> interaction.getRequest().getMethod(), null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Set the method for the URL request, one of: + *

    + *
  • GET + *
  • POST + *
  • HEAD + *
  • OPTIONS + *
  • PUT + *
  • DELETE + *
  • TRACE + *
are legal, subject to protocol restrictions. The default + * method is GET. + * + * @param method the HTTP method + * @throws ProtocolException if the method cannot be reset or if + * the requested method isn't valid for HTTP. + * @throws SecurityException if a security manager is set and the + * method is "TRACE", but the "allowHttpTrace" + * NetPermission is not granted. + * @see #getRequestMethod() + */ + @Override + public void setRequestMethod(String method) throws ProtocolException { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setRequestMethod(method); + } + + /** + * gets the status code from an HTTP response message. + * For example, in the case of the following status lines: + *
+     * HTTP/1.0 200 OK
+     * HTTP/1.0 401 Unauthorized
+     * 
+ * It will return 200 and 401 respectively. + * Returns -1 if no code can be discerned + * from the response (i.e., the response is not valid HTTP). + * + * @return the HTTP Status-Code, or -1 + * @throws IOException if an error occurred connecting to the server. + */ + @Override + public int getResponseCode() throws IOException { + if (mode == Mode.Bypass) { + return this.connection.getResponseCode(); + } + try { + buildCache(); + return getIntegerElementFromCache((interaction) -> interaction.getResponse().getStatus().getCode(), 0); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * gets the HTTP response message, if any, returned along with the + * response code from a server. From responses like: + *
+     * HTTP/1.0 200 OK
+     * HTTP/1.0 404 Not Found
+     * 
+ * Extracts the Strings "OK" and "Not Found" respectively. + * Returns null if none could be discerned from the responses + * (the result was not valid HTTP). + * + * @return the HTTP response message, or {@code null} + * @throws IOException if an error occurred connecting to the server. + */ + @Override + public String getResponseMessage() throws IOException { + if (mode == Mode.Bypass) { + return this.connection.getResponseMessage(); + } + try { + buildCache(); + return getStringElementFromCache((interaction) -> interaction.getResponse().getStatus().getMessage(), null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns a {@link SocketPermission} object representing the + * permission necessary to connect to the destination host and port. + * + * @return a {@code SocketPermission} object representing the + * permission necessary to connect to the destination + * host and port. + * @throws IOException if an error occurs while computing + * the permission. + */ + @Override + public Permission getPermission() throws IOException { + // not in cassette, go to real connection + return this.connection.getPermission(); + } + + /** + * Returns the error stream if the connection failed + * but the server sent useful data nonetheless. The + * typical example is when an HTTP server responds + * with a 404, which will cause a FileNotFoundException + * to be thrown in connect, but the server sent an HTML + * help page with suggestions as to what to do. + * + *

This method will not cause a connection to be initiated. If + * the connection was not connected, or if the server did not have + * an error while connecting or if the server had an error but + * no error data was sent, this method will return null. This is + * the default. + * + * @return an error stream if any, null if there have been no + * errors, the connection is not connected or the server sent no + * useful data. + */ + @Override + public InputStream getErrorStream() { + // not in cassette, get from real request + return this.connection.getErrorStream(); + } + + /** + * Returns setting for connect timeout. + *

+ * 0 return implies that the option is disabled + * (i.e., timeout of infinity). + * + * @return an {@code int} that indicates the connect timeout + * value in milliseconds + * @see #setConnectTimeout(int) + * @see #connect() + * @since 1.5 + */ + @Override + public int getConnectTimeout() { + // not in cassette, go to real connection + return this.connection.getConnectTimeout(); + } + + /** + * Sets a specified timeout value, in milliseconds, to be used + * when opening a communications link to the resource referenced + * by this URLConnection. If the timeout expires before the + * connection can be established, a + * java.net.SocketTimeoutException is raised. A timeout of zero is + * interpreted as an infinite timeout. + * + *

Some non-standard implementation of this method may ignore + * the specified timeout. To see the connect timeout set, please + * call getConnectTimeout(). + * + * @param timeout an {@code int} that specifies the connect + * timeout value in milliseconds + * @throws IllegalArgumentException if the timeout parameter is negative + * @see #getConnectTimeout() + * @see #connect() + * @since 1.5 + */ + @Override + public void setConnectTimeout(int timeout) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setConnectTimeout(timeout); + } + + /** + * Returns setting for read timeout. 0 return implies that the + * option is disabled (i.e., timeout of infinity). + * + * @return an {@code int} that indicates the read timeout + * value in milliseconds + * @see #setReadTimeout(int) + * @see InputStream#read() + * @since 1.5 + */ + @Override + public int getReadTimeout() { + // not in cassette, go to real connection + return this.connection.getReadTimeout(); + } + + /** + * Sets the read timeout to a specified timeout, in + * milliseconds. A non-zero value specifies the timeout when + * reading from Input stream when a connection is established to a + * resource. If the timeout expires before there is data available + * for read, a java.net.SocketTimeoutException is raised. A + * timeout of zero is interpreted as an infinite timeout. + * + *

Some non-standard implementation of this method ignores the + * specified timeout. To see the read timeout set, please call + * getReadTimeout(). + * + * @param timeout an {@code int} that specifies the timeout + * value to be used in milliseconds + * @throws IllegalArgumentException if the timeout parameter is negative + * @see #getReadTimeout() + * @see InputStream#read() + * @since 1.5 + */ + @Override + public void setReadTimeout(int timeout) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setReadTimeout(timeout); + } + + /** + * Returns the value of this {@code URLConnection}'s {@code URL} + * field. + * + * @return the value of this {@code URLConnection}'s {@code URL} + * field. + */ + @Override + public URL getURL() { + if (mode == Mode.Bypass) { + return this.connection.getURL(); + } + try { + buildCache(); + String urlString = + getStringElementFromCache((interaction) -> interaction.getResponse().getUriString(), null); + if (urlString == null) { + throw new IllegalStateException("Could not load URL from cache"); + } + return new URL(urlString); + } catch (VCRException | MalformedURLException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns the value of the {@code content-type} header field. + * + * @return the content type of the resource that the URL references, + * or {@code null} if not known. + * @see java.net.URLConnection#getHeaderField(java.lang.String) + */ + @Override + public String getContentType() { + return getHeaderField("content-type"); + } + + /** + * Returns the value of the {@code content-encoding} header field. + * + * @return the content encoding of the resource that the URL references, + * or {@code null} if not known. + * @see java.net.URLConnection#getHeaderField(java.lang.String) + */ + @Override + public String getContentEncoding() { + return getHeaderField("content-encoding"); + } + + /** + * Returns the value of the {@code expires} header field. + * + * @return the expiration date of the resource that this URL references, + * or 0 if not known. The value is the number of milliseconds since + * January 1, 1970 GMT. + * @see java.net.URLConnection#getHeaderField(java.lang.String) + */ + @Override + public long getExpiration() { + // not in cassette, go to real connection + return this.connection.getExpiration(); + } + + /** + * Returns the value of the named header field. + *

+ * If called on a connection that sets the same header multiple times + * with possibly different values, only the last value is returned. + * + * @param name the name of a header field. + * @return the value of the named header field, or {@code null} + * if there is no such field in the header. + */ + @Override + public String getHeaderField(String name) { + if (mode == Mode.Bypass) { + return this.connection.getHeaderField(name); + } + try { + buildCache(); + return getStringElementFromCache((interaction) -> interaction.getResponse().getHeaders().get(name).get(0), + null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns an unmodifiable Map of the header fields. + * The Map keys are Strings that represent the + * response-header field names. Each Map value is an + * unmodifiable List of Strings that represents + * the corresponding field values. + * + * @return a Map of header fields + * @since 1.4 + */ + @Override + public Map> getHeaderFields() { + if (mode == Mode.Bypass) { + return this.connection.getHeaderFields(); + } + try { + buildCache(); + if (cachedInteraction == null) { + return Collections.emptyMap(); + } + return cachedInteraction.getResponse().getHeaders(); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Retrieves the contents of this URL connection. + *

+ * This method first determines the content type of the object by + * calling the {@code getContentType} method. If this is + * the first time that the application has seen that specific content + * type, a content handler for that content type is created. + *

This is done as follows: + *

    + *
  1. If the application has set up a content handler factory instance + * using the {@code setContentHandlerFactory} method, the + * {@code createContentHandler} method of that instance is called + * with the content type as an argument; the result is a content + * handler for that content type. + *
  2. If no {@code ContentHandlerFactory} has yet been set up, + * or if the factory's {@code createContentHandler} method + * returns {@code null}, then the {@linkplain java.util.ServiceLoader + * ServiceLoader} mechanism is used to locate {@linkplain + * java.net.ContentHandlerFactory ContentHandlerFactory} + * implementations using the system class + * loader. The order that factories are located is implementation + * specific, and an implementation is free to cache the located + * factories. A {@linkplain java.util.ServiceConfigurationError + * ServiceConfigurationError}, {@code Error} or {@code RuntimeException} + * thrown from the {@code createContentHandler}, if encountered, will + * be propagated to the calling thread. The {@code + * createContentHandler} method of each factory, if instantiated, is + * invoked, with the content type, until a factory returns non-null, + * or all factories have been exhausted. + *
  3. Failing that, this method tries to load a content handler + * class as defined by {@link java.net.ContentHandler ContentHandler}. + * If the class does not exist, or is not a subclass of {@code + * ContentHandler}, then an {@code UnknownServiceException} is thrown. + *
+ * + * @return the object fetched. The {@code instanceof} operator + * should be used to determine the specific kind of object + * returned. + * @throws IOException if an I/O error occurs while + * getting the content. + * @throws UnknownServiceException if the protocol does not support + * the content type. + * @see java.net.ContentHandlerFactory#createContentHandler(java.lang.String) + * @see java.net.URLConnection#getContentType() + * @see java.net.URLConnection#setContentHandlerFactory(java.net.ContentHandlerFactory) + */ + @Override + public Object getContent() throws IOException { + if (mode == Mode.Bypass) { + return this.connection.getContent(); + } + try { + buildCache(); + return getObjectElementFromCache((interaction) -> interaction.getResponse().getBody(), null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns a {@code String} representation of this URL connection. + * + * @return a string representation of this {@code URLConnection}. + */ + @Override + public String toString() { + // use the built-in toString() method + return this.connection.toString(); + } + + /** + * Returns the value of this {@code URLConnection}'s + * {@code doInput} flag. + * + * @return the value of this {@code URLConnection}'s + * {@code doInput} flag. + * @see #setDoInput(boolean) + */ + @Override + public boolean getDoInput() { + // not in cassette, go to real connection + return this.connection.getDoInput(); + } + + /** + * Sets the value of the {@code doInput} field for this + * {@code URLConnection} to the specified value. + *

+ * A URL connection can be used for input and/or output. Set the doInput + * flag to true if you intend to use the URL connection for input, + * false if not. The default is true. + * + * @param doinput the new value. + * @throws IllegalStateException if already connected + * @see #getDoInput() + */ + @Override + public void setDoInput(boolean doinput) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setDoInput(doinput); + } + + /** + * Returns the value of this {@code URLConnection}'s + * {@code doOutput} flag. + * + * @return the value of this {@code URLConnection}'s + * {@code doOutput} flag. + * @see #setDoOutput(boolean) + */ + @Override + public boolean getDoOutput() { + // not in cassette, go to real connection + return this.connection.getDoOutput(); + } + + /** + * Sets the value of the {@code doOutput} field for this + * {@code URLConnection} to the specified value. + *

+ * A URL connection can be used for input and/or output. Set the doOutput + * flag to true if you intend to use the URL connection for output, + * false if not. The default is false. + * + * @param dooutput the new value. + * @throws IllegalStateException if already connected + * @see #getDoOutput() + */ + @Override + public void setDoOutput(boolean dooutput) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + if (this.connection.getDoOutput() != dooutput) { + this.connection.setDoOutput(dooutput); + } + } + + /** + * Returns the value of the {@code allowUserInteraction} field for + * this object. + * + * @return the value of the {@code allowUserInteraction} field for + * this object. + * @see #setAllowUserInteraction(boolean) + */ + @Override + public boolean getAllowUserInteraction() { + // not in cassette, go to real connection + return this.connection.getAllowUserInteraction(); + } + + /** + * Set the value of the {@code allowUserInteraction} field of + * this {@code URLConnection}. + * + * @param allowuserinteraction the new value. + * @throws IllegalStateException if already connected + * @see #getAllowUserInteraction() + */ + @Override + public void setAllowUserInteraction(boolean allowuserinteraction) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setAllowUserInteraction(allowuserinteraction); + } + + /** + * Returns the value of this {@code URLConnection}'s + * {@code useCaches} field. + * + * @return the value of this {@code URLConnection}'s + * {@code useCaches} field. + * @see #setUseCaches(boolean) + */ + @Override + public boolean getUseCaches() { + // not in cassette, go to real connection + return this.connection.getUseCaches(); + } + + /** + * Sets the value of the {@code useCaches} field of this + * {@code URLConnection} to the specified value. + *

+ * Some protocols do caching of documents. Occasionally, it is important + * to be able to "tunnel through" and ignore the caches (e.g., the + * "reload" button in a browser). If the UseCaches flag on a connection + * is true, the connection is allowed to use whatever caches it can. + * If false, caches are to be ignored. + * The default value comes from defaultUseCaches, which defaults to + * true. + * + * @param usecaches a {@code boolean} indicating whether + * or not to allow caching + * @throws IllegalStateException if already connected + * @see #getUseCaches() + */ + @Override + public void setUseCaches(boolean usecaches) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setUseCaches(usecaches); + } + + /** + * Returns the value of this object's {@code ifModifiedSince} field. + * + * @return the value of this object's {@code ifModifiedSince} field. + * @see #setIfModifiedSince(long) + */ + @Override + public long getIfModifiedSince() { + // not in cassette, go to real connection + return this.connection.getIfModifiedSince(); + } + + /** + * Sets the value of the {@code ifModifiedSince} field of + * this {@code URLConnection} to the specified value. + * + * @param ifmodifiedsince the new value. + * @throws IllegalStateException if already connected + * @see #getIfModifiedSince() + */ + @Override + public void setIfModifiedSince(long ifmodifiedsince) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setIfModifiedSince(ifmodifiedsince); + } + + /** + * Returns the default value of a {@code URLConnection}'s + * {@code useCaches} flag. + *

+ * This default is "sticky", being a part of the static state of all + * URLConnections. This flag applies to the next, and all following + * URLConnections that are created. + * + * @return the default value of a {@code URLConnection}'s + * {@code useCaches} flag. + * @see #setDefaultUseCaches(boolean) + */ + @Override + public boolean getDefaultUseCaches() { + // not in cassette, go to real connection + return this.connection.getDefaultUseCaches(); + } + + /** + * Sets the default value of the {@code useCaches} field to the + * specified value. + * + * @param defaultusecaches the new value. + * @see #getDefaultUseCaches() + */ + @Override + public void setDefaultUseCaches(boolean defaultusecaches) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setDefaultUseCaches(defaultusecaches); + } + + /** + * Sets the general request property. If a property with the key already + * exists, overwrite its value with the new value. + * + *

NOTE: HTTP requires all request properties which can + * legally have multiple instances with the same key + * to use a comma-separated list syntax which enables multiple + * properties to be appended into a single property. + * + * @param key the keyword by which the request is known + * (e.g., "{@code Accept}"). + * @param value the value associated with it. + * @throws IllegalStateException if already connected + * @throws NullPointerException if key is {@code null} + * @see #getRequestProperty(java.lang.String) + */ + @Override + public void setRequestProperty(String key, String value) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setRequestProperty(key, value); + } + + /** + * Adds a general request property specified by a + * key-value pair. This method will not overwrite + * existing values associated with the same key. + * + * @param key the keyword by which the request is known + * (e.g., "{@code Accept}"). + * @param value the value associated with it. + * @throws IllegalStateException if already connected + * @throws NullPointerException if key is null + * @see #getRequestProperties() + * @since 1.4 + */ + @Override + public void addRequestProperty(String key, String value) { + this.connection.addRequestProperty(key, value); + } + + /** + * Returns the value of the named general request property for this + * connection. + * + * @param key the keyword by which the request is known (e.g., "Accept"). + * @return the value of the named general request property for this + * connection. If key is null, then null is returned. + * @throws IllegalStateException if already connected + * @see #setRequestProperty(java.lang.String, java.lang.String) + */ + @Override + public String getRequestProperty(String key) { + if (mode == Mode.Bypass) { + return this.connection.getRequestProperty(key); + } + try { + buildCache(); + return getStringElementFromCache((interaction) -> interaction.getRequest().getHeaders().get(key).toString(), + null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns an unmodifiable Map of general request + * properties for this connection. The Map keys + * are Strings that represent the request-header + * field names. Each Map value is a unmodifiable List + * of Strings that represents the corresponding + * field values. + * + * @return a Map of the general request properties for this connection. + * @throws IllegalStateException if already connected + * @since 1.4 + */ + @Override + public Map> getRequestProperties() { + // always return the real connection's request properties + return this.connection.getRequestProperties(); + } + + @Override + public InputStream getInputStream() throws IOException { + if (mode == Mode.Bypass) { + return this.connection.getInputStream(); + } + try { + buildCache(); + return createInputStream(this.cachedInteraction.getResponse().getBody()); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings ("deprecation") + @Override + //CHECKSTYLE.OFF: ParameterName + public long getHeaderFieldDate(String name, long Default) { + // not in cassette, go to real connection + return this.connection.getHeaderFieldDate(name, Default); + } + //CHECKSTYLE.ON: ParameterName + + /** + * Returns the value of the {@code content-length} header field. + *

+ * Note: {@link #getContentLengthLong() getContentLengthLong()} + * should be preferred over this method, since it returns a {@code long} + * instead and is therefore more portable.

+ * + * @return the content length of the resource that this connection's URL + * references, {@code -1} if the content length is not known, + * or if the content length is greater than Integer.MAX_VALUE. + */ + @Override + public int getContentLength() { + // not in cassette, go to real connection + return this.connection.getContentLength(); + } + + /** + * Returns the value of the {@code content-length} header field as a + * long. + * + * @return the content length of the resource that this connection's URL + * references, or {@code -1} if the content length is + * not known. + * @since 1.7 + */ + @Override + public long getContentLengthLong() { + // not in cassette, go to real connection + return this.connection.getContentLengthLong(); + } + + /** + * Returns the value of the {@code date} header field. + * + * @return the sending date of the resource that the URL references, + * or {@code 0} if not known. The value returned is the + * number of milliseconds since January 1, 1970 GMT. + * @see java.net.URLConnection#getHeaderField(java.lang.String) + */ + @Override + public long getDate() { + // not in cassette, go to real connection + return this.connection.getDate(); + } + + /** + * Returns the value of the {@code last-modified} header field. + * The result is the number of milliseconds since January 1, 1970 GMT. + * + * @return the date the resource referenced by this + * {@code URLConnection} was last modified, or 0 if not known. + * @see java.net.URLConnection#getHeaderField(java.lang.String) + */ + @Override + public long getLastModified() { + // not in cassette, go to real connection + return this.connection.getLastModified(); + } + + /** + * Returns the value of the named field parsed as a number. + *

+ * This form of {@code getHeaderField} exists because some + * connection types (e.g., {@code http-ng}) have pre-parsed + * headers. Classes for that connection type can override this method + * and short-circuit the parsing. + * + * @param name the name of the header field. + * @param Default the default value. + * @return the value of the named field, parsed as an integer. The + * {@code Default} value is returned if the field is + * missing or malformed. + */ + @Override + //CHECKSTYLE.OFF: ParameterName + public int getHeaderFieldInt(String name, int Default) { + // not in cassette, go to real connection + return this.connection.getHeaderFieldInt(name, Default); + } + //CHECKSTYLE.ON: ParameterName + + /** + * Returns the value of the named field parsed as a number. + *

+ * This form of {@code getHeaderField} exists because some + * connection types (e.g., {@code http-ng}) have pre-parsed + * headers. Classes for that connection type can override this method + * and short-circuit the parsing. + * + * @param name the name of the header field. + * @param Default the default value. + * @return the value of the named field, parsed as a long. The + * {@code Default} value is returned if the field is + * missing or malformed. + * @since 1.7 + */ + @Override + //CHECKSTYLE.OFF: ParameterName + public long getHeaderFieldLong(String name, long Default) { + // not in cassette, go to real connection + return this.connection.getHeaderFieldLong(name, Default); + } + //CHECKSTYLE.ON: ParameterName + + /** + * Retrieves the contents of this URL connection. + * + * @param classes the {@code Class} array + * indicating the requested types + * @return the object fetched that is the first match of the type + * specified in the classes array. null if none of + * the requested types are supported. + * The {@code instanceof} operator should be used to + * determine the specific kind of object returned. + * @throws IOException if an I/O error occurs while + * getting the content. + * @throws UnknownServiceException if the protocol does not support + * the content type. + * @see java.net.URLConnection#getContent() + * @see java.net.ContentHandlerFactory#createContentHandler(java.lang.String) + * @see java.net.URLConnection#getContent(java.lang.Class[]) + * @see java.net.URLConnection#setContentHandlerFactory(java.net.ContentHandlerFactory) + * @since 1.3 + */ + @Override + public Object getContent(Class[] classes) throws IOException { + // not in cassette, go to real connection + return this.connection.getContent(classes); + } + + /** + * Returns an output stream that writes to this connection. + * + * @return an output stream that writes to this connection. + * @throws IOException if an I/O error occurs while + * creating the output stream. + * @throws UnknownServiceException if the protocol does not support + * output. + */ + @Override + public OutputStream getOutputStream() throws IOException { + // use proxy requestBody to store inputted data + if (!this.getDoOutput()) { + throw new IOException("Cannot get output stream when doOutput is false"); + } + return this.requestBody; + } +} diff --git a/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableHttpsURLConnection.java b/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableHttpsURLConnection.java new file mode 100644 index 0000000..c73b646 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableHttpsURLConnection.java @@ -0,0 +1,1540 @@ +package com.easypost.easyvcr.clients.httpurlconnection; + +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.VCRException; +import com.easypost.easyvcr.interactionconverters.HttpUrlConnectionInteractionConverter; +import com.easypost.easyvcr.requestelements.HttpInteraction; +import com.easypost.easyvcr.requestelements.Request; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpRetryException; +import java.net.MalformedURLException; +import java.net.ProtocolException; +import java.net.Proxy; +import java.net.SocketPermission; +import java.net.URL; +import java.net.UnknownServiceException; +import java.security.Permission; +import java.security.Principal; +import java.security.cert.Certificate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static com.easypost.easyvcr.internalutilities.Tools.createInputStream; +import static com.easypost.easyvcr.internalutilities.Tools.simulateDelay; + +public final class RecordableHttpsURLConnection extends HttpsURLConnection { + + // interaction is not actually recorded until you getX() from the result + // connect() or any getX() function will cache the interaction + // you cannot setX() after the interaction has been cached + + // TODO: ^ Eventually allow users to set after cache (update cache) + + /** + * The internal HttpsURLConnection that this class wraps. + */ + private HttpsURLConnection connection; + + private final RecordableRequestBody requestBody; + /** + * The HttpUrlConnectionInteractionConverter that converts the HttpsURLConnection to an HttpInteraction. + */ + private final HttpUrlConnectionInteractionConverter converter; + /** + * The Cassette that this class is recording to and reading from. + */ + private final Cassette cassette; + /** + * The VCR mode that this class is using. + */ + private final Mode mode; + /** + * The AdvancedSettings that this class is using. + */ + private final AdvancedSettings advancedSettings; + /** + * Internal cached HttpInteraction storing the request and response details. + */ + private HttpInteraction cachedInteraction; + + /** + * Constructor for the RecordableHttpsURLConnection class. + * + * @param url The URL to connect to. + * @param proxy The proxy to use. + * @param cassette The cassette to use. + * @param mode The mode to use. + * @param advancedSettings The advanced settings to use. + * @throws IOException If an error occurs. + */ + public RecordableHttpsURLConnection(URL url, Proxy proxy, Cassette cassette, Mode mode, + AdvancedSettings advancedSettings) throws IOException { + // this super is not used + super(url); + if (proxy == null) { + this.connection = (HttpsURLConnection) url.openConnection(); + } else { + this.connection = (HttpsURLConnection) url.openConnection(proxy); + } + this.requestBody = new RecordableRequestBody(); + this.cachedInteraction = null; + this.cassette = cassette; + this.mode = mode; + this.advancedSettings = advancedSettings; + this.converter = new HttpUrlConnectionInteractionConverter(); + } + + /** + * Constructor for the RecordableHttpsURLConnection class. + * + * @param url The URL to connect to. + * @param cassette The cassette to use. + * @param mode The mode to use. + * @param advancedSettings The advanced settings to use. + * @throws IOException If an error occurs. + */ + public RecordableHttpsURLConnection(URL url, Cassette cassette, Mode mode, AdvancedSettings advancedSettings) + throws IOException { + this(url, null, cassette, mode, advancedSettings); + } + + /** + * Constructor for the RecordableHttpsURLConnection class. + * + * @param url The URL to connect to. + * @param proxy The proxy to use. + * @param cassette The cassette to use. + * @param mode The mode to use. + * @throws IOException If an error occurs. + */ + public RecordableHttpsURLConnection(URL url, Proxy proxy, Cassette cassette, Mode mode) throws IOException { + this(url, proxy, cassette, mode, new AdvancedSettings()); + } + + /** + * Constructor for the RecordableHttpsURLConnection class. + * + * @param url The URL to connect to. + * @param cassette The cassette to use. + * @param mode The mode to use. + * @throws IOException If an error occurs. + */ + public RecordableHttpsURLConnection(URL url, Cassette cassette, Mode mode) throws IOException { + this(url, cassette, mode, new AdvancedSettings()); + } + + /** + * Get an object from the cache. + * + * @param getter Function to get the object from the cache. + * @param defaultValue The default value to return if the object is not in the cache. + * @return The object from the cache. + * @throws VCRException If an error occurs. + */ + private Object getObjectElementFromCache(Function getter, Object defaultValue) + throws VCRException { + if (this.cachedInteraction == null) { + return defaultValue; + } + try { + return getter.apply(this.cachedInteraction); + } catch (Exception e) { + throw new VCRException("Error getting string element from cache"); + } + } + + /** + * Get a string from the cache. + * + * @param getter Function to get the string from the cache. + * @param defaultValue The default value to return if the string is not in the cache. + * @return The string from the cache. + * @throws VCRException If an error occurs. + */ + private String getStringElementFromCache(Function getter, String defaultValue) + throws VCRException { + if (this.cachedInteraction == null) { + return defaultValue; + } + try { + return getter.apply(this.cachedInteraction); + } catch (Exception e) { + throw new VCRException("Error getting string element from cache"); + } + } + + /** + * Get a boolean from the cache. + * + * @param getter Function to get the boolean from the cache. + * @param defaultValue The default value to return if the boolean is not in the cache. + * @return The boolean from the cache. + * @throws VCRException If an error occurs. + */ + private boolean getBooleanElementFromCache(Function getter, boolean defaultValue) + throws VCRException { + if (this.cachedInteraction == null) { + return defaultValue; + } + try { + return getter.apply(this.cachedInteraction); + } catch (Exception e) { + throw new VCRException("Error getting boolean element from cache"); + } + } + + /** + * Get an integer from the cache. + * + * @param getter Function to get the integer from the cache. + * @param defaultValue The default value to return if the integer is not in the cache. + * @return The integer from the cache. + * @throws VCRException If an error occurs. + */ + private int getIntegerElementFromCache(Function getter, int defaultValue) + throws VCRException { + if (this.cachedInteraction == null) { + return defaultValue; + } + try { + return getter.apply(this.cachedInteraction); + } catch (Exception e) { + throw new VCRException("Error getting integer element from cache"); + } + } + + /** + * Cache the current request and response to an in-memory HttpInteraction. + * This is done once on the first attempt to retrieve request or response details. + * + * @param recordToCassette Whether to also record the cached interaction to the cassette. + * @throws VCRException If an error occurs. + */ + private void cacheInteraction(boolean recordToCassette) throws VCRException { + // record this interaction + // only need to execute this once, on the first getX(), since no more setX() is allowed at that point + // so the request and response won't be changing + // important to call directly on connection, rather than this.function() to avoid potential recursion + this.cachedInteraction = + this.converter.createInteraction(this.connection, this.requestBody, this.advancedSettings.censors); + if (recordToCassette) { + this.cassette.updateInteraction(this.cachedInteraction, this.advancedSettings.matchRules, false); + } + } + + /** + * Load an existing interaction from the cassette and cache it. + * + * @return boolean indicating whether the interaction was found and cached successfully. + * @throws VCRException If an error occurs. + * @throws InterruptedException If the thread is interrupted. + */ + private boolean loadExistingInteraction() throws VCRException, InterruptedException { + Request request = + converter.createRecordedRequest(this.connection, this.requestBody, this.advancedSettings.censors); + // null because couldn't be created + if (request == null) { + return false; + } + HttpInteraction matchingInteraction = + converter.findMatchingInteraction(this.cassette, request, advancedSettings.matchRules); + if (matchingInteraction == null) { + return false; + } + simulateDelay(matchingInteraction, this.advancedSettings); + this.cachedInteraction = matchingInteraction; + this.cachedInteraction.getResponse().addReplayHeaders(); + return true; + } + + /** + * Build an in-memory cache of the current request and response details if needed. + * + * @throws VCRException If an error occurs. + */ + private void buildCache() throws VCRException { + // run every time a user attempts to getX() + + // can't setX() after the first getX(), so if cache has already been built, can't build again + if (this.cachedInteraction != null) { + return; + } + + switch (mode) { + case Record: + cacheInteraction(true); + break; + case Replay: + try { + loadExistingInteraction(); + } catch (VCRException | InterruptedException e) { + throw new RuntimeException(e); + } + break; + case Auto: + try { + if (!loadExistingInteraction()) { + cacheInteraction(true); + } + } catch (VCRException | InterruptedException e) { + throw new RuntimeException(e); + } + break; + case Bypass: + default: + break; + } + } + + /** + * Clear the in-memory cache of the current request and response details. + */ + private void clearCache() { + this.cachedInteraction = null; + } + + @Override + public void connect() throws IOException { + try { + if (this.requestBody.hasData()) { + this.connection.setDoOutput( + true); // have to set this to true to allow the ability to get and write to output stream + this.requestBody.writeToOutputStream( + this.connection.getOutputStream()); + // have to write this at the last second, otherwise locks us out + } + buildCache(); // can't set anything after connecting, so might as well build the cache now + // will establish connection as a result of caching, so need to disconnect afterwards + this.connection.disconnect(); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + @Override + public void disconnect() { + this.connection.disconnect(); + clearCache(); + } + + @Override + public boolean usingProxy() { + return this.connection.usingProxy(); + } + + /** + * Returns the key for the {@code n}th header field. + * Some implementations may treat the {@code 0}th + * header field as special, i.e. as the status line returned by the HTTP + * server. In this case, {@link #getHeaderField(int) getHeaderField(0)} returns the status + * line, but {@code getHeaderFieldKey(0)} returns null. + * + * @param n an index, where {@code n >=0}. + * @return the key for the {@code n}th header field, + * or {@code null} if the key does not exist. + */ + @Override + public String getHeaderFieldKey(int n) { + if (mode == Mode.Bypass) { + return this.connection.getHeaderFieldKey(n); + } + try { + buildCache(); + return getStringElementFromCache( + (interaction) -> interaction.getResponse().getHeaders().keySet().toArray()[n].toString(), null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * This method is used to enable streaming of a HTTP request body + * without internal buffering, when the content length is known in + * advance. + *

+ * An exception will be thrown if the application + * attempts to write more data than the indicated + * content-length, or if the application closes the OutputStream + * before writing the indicated amount. + *

+ * When output streaming is enabled, authentication + * and redirection cannot be handled automatically. + * A HttpRetryException will be thrown when reading + * the response if authentication or redirection are required. + * This exception can be queried for the details of the error. + *

+ * This method must be called before the URLConnection is connected. + *

+ * NOTE: {@link #setFixedLengthStreamingMode(long)} is recommended + * instead of this method as it allows larger content lengths to be set. + * + * @param contentLength The number of bytes which will be written + * to the OutputStream. + * @throws IllegalStateException if URLConnection is already connected + * or if a different streaming mode is already enabled. + * @throws IllegalArgumentException if a content length less than + * zero is specified. + * @see #setChunkedStreamingMode(int) + * @since 1.5 + */ + @Override + public void setFixedLengthStreamingMode(int contentLength) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setFixedLengthStreamingMode(contentLength); + } + + /** + * This method is used to enable streaming of a HTTP request body + * without internal buffering, when the content length is known in + * advance. + * + *

An exception will be thrown if the application attempts to write + * more data than the indicated content-length, or if the application + * closes the OutputStream before writing the indicated amount. + * + *

When output streaming is enabled, authentication and redirection + * cannot be handled automatically. A {@linkplain HttpRetryException} will + * be thrown when reading the response if authentication or redirection + * are required. This exception can be queried for the details of the + * error. + * + *

This method must be called before the URLConnection is connected. + * + *

The content length set by invoking this method takes precedence + * over any value set by {@link #setFixedLengthStreamingMode(int)}. + * + * @param contentLength The number of bytes which will be written to the OutputStream. + * @throws IllegalStateException if URLConnection is already connected or if a different + * streaming mode is already enabled. + * @throws IllegalArgumentException if a content length less than zero is specified. + * @since 1.7 + */ + @Override + public void setFixedLengthStreamingMode(long contentLength) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setFixedLengthStreamingMode(contentLength); + } + + /** + * This method is used to enable streaming of a HTTP request body + * without internal buffering, when the content length is not + * known in advance. In this mode, chunked transfer encoding + * is used to send the request body. Note, not all HTTP servers + * support this mode. + *

+ * When output streaming is enabled, authentication + * and redirection cannot be handled automatically. + * A HttpRetryException will be thrown when reading + * the response if authentication or redirection are required. + * This exception can be queried for the details of the error. + *

+ * This method must be called before the URLConnection is connected. + * + * @param chunklen The number of bytes to write in each chunk. + * If chunklen is less than or equal to zero, a default + * value will be used. + * @throws IllegalStateException if URLConnection is already connected + * or if a different streaming mode is already enabled. + * @see #setFixedLengthStreamingMode(int) + * @since 1.5 + */ + @Override + public void setChunkedStreamingMode(int chunklen) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setChunkedStreamingMode(chunklen); + } + + /** + * Returns the value for the {@code n}th header field. + * Some implementations may treat the {@code 0}th + * header field as special, i.e. as the status line returned by the HTTP + * server. + *

+ * This method can be used in conjunction with the + * {@link #getHeaderFieldKey getHeaderFieldKey} method to iterate through all + * the headers in the message. + * + * @param n an index, where {@code n>=0}. + * @return the value of the {@code n}th header field, + * or {@code null} if the value does not exist. + * @see java.net.HttpURLConnection#getHeaderFieldKey(int) + */ + @Override + public String getHeaderField(int n) { + if (mode == Mode.Bypass) { + return this.connection.getHeaderField(n); + } + try { + buildCache(); + return getStringElementFromCache( + (interaction) -> interaction.getResponse().getHeaders().values().toArray()[n].toString(), null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns the value of this {@code RecordableHttpUrlConnection}'s + * {@code instanceFollowRedirects} field. + * + * @return the value of this {@code RecordableHttpUrlConnection}'s + * {@code instanceFollowRedirects} field. + * @see #setInstanceFollowRedirects(boolean) + * @since 1.3 + */ + @Override + public boolean getInstanceFollowRedirects() { + // not in cassette, go to real connection + return this.connection.getInstanceFollowRedirects(); + } + + /** + * Sets whether HTTP redirects (requests with response code 3xx) should + * be automatically followed by this {@code RecordableHttpUrlConnection} + * instance. + *

+ * The default value comes from followRedirects, which defaults to + * true. + * + * @param followRedirects a {@code boolean} indicating + * whether or not to follow HTTP redirects. + * @see #getInstanceFollowRedirects + * @since 1.3 + */ + @Override + public void setInstanceFollowRedirects(boolean followRedirects) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setInstanceFollowRedirects(followRedirects); + } + + /** + * get the request method. + * + * @return the HTTP request method + * @see #setRequestMethod(java.lang.String) + */ + @Override + public String getRequestMethod() { + if (mode == Mode.Bypass) { + return this.connection.getRequestMethod(); + } + try { + buildCache(); + return getStringElementFromCache((interaction) -> interaction.getRequest().getMethod(), null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Set the method for the URL request, one of: + *

    + *
  • GET + *
  • POST + *
  • HEAD + *
  • OPTIONS + *
  • PUT + *
  • DELETE + *
  • TRACE + *
are legal, subject to protocol restrictions. The default + * method is GET. + * + * @param method the HTTP method + * @throws ProtocolException if the method cannot be reset or if + * the requested method isn't valid for HTTP. + * @throws SecurityException if a security manager is set and the + * method is "TRACE", but the "allowHttpTrace" + * NetPermission is not granted. + * @see #getRequestMethod() + */ + @Override + public void setRequestMethod(String method) throws ProtocolException { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setRequestMethod(method); + } + + /** + * gets the status code from an HTTP response message. + * For example, in the case of the following status lines: + *
+     * HTTP/1.0 200 OK
+     * HTTP/1.0 401 Unauthorized
+     * 
+ * It will return 200 and 401 respectively. + * Returns -1 if no code can be discerned + * from the response (i.e., the response is not valid HTTP). + * + * @return the HTTP Status-Code, or -1 + * @throws IOException if an error occurred connecting to the server. + */ + @Override + public int getResponseCode() throws IOException { + if (mode == Mode.Bypass) { + return this.connection.getResponseCode(); + } + try { + buildCache(); + return getIntegerElementFromCache((interaction) -> interaction.getResponse().getStatus().getCode(), 0); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * gets the HTTP response message, if any, returned along with the + * response code from a server. From responses like: + *
+     * HTTP/1.0 200 OK
+     * HTTP/1.0 404 Not Found
+     * 
+ * Extracts the Strings "OK" and "Not Found" respectively. + * Returns null if none could be discerned from the responses + * (the result was not valid HTTP). + * + * @return the HTTP response message, or {@code null} + * @throws IOException if an error occurred connecting to the server. + */ + @Override + public String getResponseMessage() throws IOException { + if (mode == Mode.Bypass) { + return this.connection.getResponseMessage(); + } + try { + buildCache(); + return getStringElementFromCache((interaction) -> interaction.getResponse().getStatus().getMessage(), null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns a {@link SocketPermission} object representing the + * permission necessary to connect to the destination host and port. + * + * @return a {@code SocketPermission} object representing the + * permission necessary to connect to the destination + * host and port. + * @throws IOException if an error occurs while computing + * the permission. + */ + @Override + public Permission getPermission() throws IOException { + // not in cassette, go to real connection + return this.connection.getPermission(); + } + + /** + * Returns the error stream if the connection failed + * but the server sent useful data nonetheless. The + * typical example is when an HTTP server responds + * with a 404, which will cause a FileNotFoundException + * to be thrown in connect, but the server sent an HTML + * help page with suggestions as to what to do. + * + *

This method will not cause a connection to be initiated. If + * the connection was not connected, or if the server did not have + * an error while connecting or if the server had an error but + * no error data was sent, this method will return null. This is + * the default. + * + * @return an error stream if any, null if there have been no + * errors, the connection is not connected or the server sent no + * useful data. + */ + @Override + public InputStream getErrorStream() { + // not in cassette, get from real request + return this.connection.getErrorStream(); + } + + /** + * Returns setting for connect timeout. + *

+ * 0 return implies that the option is disabled + * (i.e., timeout of infinity). + * + * @return an {@code int} that indicates the connect timeout + * value in milliseconds + * @see #setConnectTimeout(int) + * @see #connect() + * @since 1.5 + */ + @Override + public int getConnectTimeout() { + // not in cassette, go to real connection + return this.connection.getConnectTimeout(); + } + + /** + * Sets a specified timeout value, in milliseconds, to be used + * when opening a communications link to the resource referenced + * by this URLConnection. If the timeout expires before the + * connection can be established, a + * java.net.SocketTimeoutException is raised. A timeout of zero is + * interpreted as an infinite timeout. + * + *

Some non-standard implementation of this method may ignore + * the specified timeout. To see the connect timeout set, please + * call getConnectTimeout(). + * + * @param timeout an {@code int} that specifies the connect + * timeout value in milliseconds + * @throws IllegalArgumentException if the timeout parameter is negative + * @see #getConnectTimeout() + * @see #connect() + * @since 1.5 + */ + @Override + public void setConnectTimeout(int timeout) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setConnectTimeout(timeout); + } + + /** + * Returns setting for read timeout. 0 return implies that the + * option is disabled (i.e., timeout of infinity). + * + * @return an {@code int} that indicates the read timeout + * value in milliseconds + * @see #setReadTimeout(int) + * @see InputStream#read() + * @since 1.5 + */ + @Override + public int getReadTimeout() { + // not in cassette, go to real connection + return this.connection.getReadTimeout(); + } + + /** + * Sets the read timeout to a specified timeout, in + * milliseconds. A non-zero value specifies the timeout when + * reading from Input stream when a connection is established to a + * resource. If the timeout expires before there is data available + * for read, a java.net.SocketTimeoutException is raised. A + * timeout of zero is interpreted as an infinite timeout. + * + *

Some non-standard implementation of this method ignores the + * specified timeout. To see the read timeout set, please call + * getReadTimeout(). + * + * @param timeout an {@code int} that specifies the timeout + * value to be used in milliseconds + * @throws IllegalArgumentException if the timeout parameter is negative + * @see #getReadTimeout() + * @see InputStream#read() + * @since 1.5 + */ + @Override + public void setReadTimeout(int timeout) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setReadTimeout(timeout); + } + + /** + * Returns the value of this {@code URLConnection}'s {@code URL} + * field. + * + * @return the value of this {@code URLConnection}'s {@code URL} + * field. + */ + @Override + public URL getURL() { + if (mode == Mode.Bypass) { + return this.connection.getURL(); + } + try { + buildCache(); + String urlString = + getStringElementFromCache((interaction) -> interaction.getResponse().getUriString(), null); + if (urlString == null) { + throw new IllegalStateException("Could not load URL from cache"); + } + return new URL(urlString); + } catch (VCRException | MalformedURLException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns the value of the {@code content-type} header field. + * + * @return the content type of the resource that the URL references, + * or {@code null} if not known. + * @see java.net.URLConnection#getHeaderField(java.lang.String) + */ + @Override + public String getContentType() { + return getHeaderField("content-type"); + } + + /** + * Returns the value of the {@code content-encoding} header field. + * + * @return the content encoding of the resource that the URL references, + * or {@code null} if not known. + * @see java.net.URLConnection#getHeaderField(java.lang.String) + */ + @Override + public String getContentEncoding() { + return getHeaderField("content-encoding"); + } + + /** + * Returns the value of the {@code expires} header field. + * + * @return the expiration date of the resource that this URL references, + * or 0 if not known. The value is the number of milliseconds since + * January 1, 1970 GMT. + * @see java.net.URLConnection#getHeaderField(java.lang.String) + */ + @Override + public long getExpiration() { + // not in cassette, go to real connection + return this.connection.getExpiration(); + } + + /** + * Returns the value of the named header field. + *

+ * If called on a connection that sets the same header multiple times + * with possibly different values, only the last value is returned. + * + * @param name the name of a header field. + * @return the value of the named header field, or {@code null} + * if there is no such field in the header. + */ + @Override + public String getHeaderField(String name) { + if (mode == Mode.Bypass) { + return this.connection.getHeaderField(name); + } + try { + buildCache(); + return getStringElementFromCache((interaction) -> interaction.getResponse().getHeaders().get(name).get(0), + null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns an unmodifiable Map of the header fields. + * The Map keys are Strings that represent the + * response-header field names. Each Map value is an + * unmodifiable List of Strings that represents + * the corresponding field values. + * + * @return a Map of header fields + * @since 1.4 + */ + @Override + public Map> getHeaderFields() { + if (mode == Mode.Bypass) { + return this.connection.getHeaderFields(); + } + try { + buildCache(); + if (cachedInteraction == null) { + return Collections.emptyMap(); + } + return cachedInteraction.getResponse().getHeaders(); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Retrieves the contents of this URL connection. + *

+ * This method first determines the content type of the object by + * calling the {@code getContentType} method. If this is + * the first time that the application has seen that specific content + * type, a content handler for that content type is created. + *

This is done as follows: + *

    + *
  1. If the application has set up a content handler factory instance + * using the {@code setContentHandlerFactory} method, the + * {@code createContentHandler} method of that instance is called + * with the content type as an argument; the result is a content + * handler for that content type. + *
  2. If no {@code ContentHandlerFactory} has yet been set up, + * or if the factory's {@code createContentHandler} method + * returns {@code null}, then the {@linkplain java.util.ServiceLoader + * ServiceLoader} mechanism is used to locate {@linkplain + * java.net.ContentHandlerFactory ContentHandlerFactory} + * implementations using the system class + * loader. The order that factories are located is implementation + * specific, and an implementation is free to cache the located + * factories. A {@linkplain java.util.ServiceConfigurationError + * ServiceConfigurationError}, {@code Error} or {@code RuntimeException} + * thrown from the {@code createContentHandler}, if encountered, will + * be propagated to the calling thread. The {@code + * createContentHandler} method of each factory, if instantiated, is + * invoked, with the content type, until a factory returns non-null, + * or all factories have been exhausted. + *
  3. Failing that, this method tries to load a content handler + * class as defined by {@link java.net.ContentHandler ContentHandler}. + * If the class does not exist, or is not a subclass of {@code + * ContentHandler}, then an {@code UnknownServiceException} is thrown. + *
+ * + * @return the object fetched. The {@code instanceof} operator + * should be used to determine the specific kind of object + * returned. + * @throws IOException if an I/O error occurs while + * getting the content. + * @throws UnknownServiceException if the protocol does not support + * the content type. + * @see java.net.ContentHandlerFactory#createContentHandler(java.lang.String) + * @see java.net.URLConnection#getContentType() + * @see java.net.URLConnection#setContentHandlerFactory(java.net.ContentHandlerFactory) + */ + @Override + public Object getContent() throws IOException { + if (mode == Mode.Bypass) { + return this.connection.getContent(); + } + try { + buildCache(); + return getObjectElementFromCache((interaction) -> interaction.getResponse().getBody(), null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns a {@code String} representation of this URL connection. + * + * @return a string representation of this {@code URLConnection}. + */ + @Override + public String toString() { + // use the built-in toString() method + return this.connection.toString(); + } + + /** + * Returns the value of this {@code URLConnection}'s + * {@code doInput} flag. + * + * @return the value of this {@code URLConnection}'s + * {@code doInput} flag. + * @see #setDoInput(boolean) + */ + @Override + public boolean getDoInput() { + // not in cassette, go to real connection + return this.connection.getDoInput(); + } + + /** + * Sets the value of the {@code doInput} field for this + * {@code URLConnection} to the specified value. + *

+ * A URL connection can be used for input and/or output. Set the doInput + * flag to true if you intend to use the URL connection for input, + * false if not. The default is true. + * + * @param doinput the new value. + * @throws IllegalStateException if already connected + * @see #getDoInput() + */ + @Override + public void setDoInput(boolean doinput) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setDoInput(doinput); + } + + /** + * Returns the value of this {@code URLConnection}'s + * {@code doOutput} flag. + * + * @return the value of this {@code URLConnection}'s + * {@code doOutput} flag. + * @see #setDoOutput(boolean) + */ + @Override + public boolean getDoOutput() { + // not in cassette, go to real connection + return this.connection.getDoOutput(); + } + + /** + * Sets the value of the {@code doOutput} field for this + * {@code URLConnection} to the specified value. + *

+ * A URL connection can be used for input and/or output. Set the doOutput + * flag to true if you intend to use the URL connection for output, + * false if not. The default is false. + * + * @param dooutput the new value. + * @throws IllegalStateException if already connected + * @see #getDoOutput() + */ + @Override + public void setDoOutput(boolean dooutput) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + if (this.connection.getDoOutput() != dooutput) { + this.connection.setDoOutput(dooutput); + } + } + + /** + * Returns the value of the {@code allowUserInteraction} field for + * this object. + * + * @return the value of the {@code allowUserInteraction} field for + * this object. + * @see #setAllowUserInteraction(boolean) + */ + @Override + public boolean getAllowUserInteraction() { + // not in cassette, go to real connection + return this.connection.getAllowUserInteraction(); + } + + /** + * Set the value of the {@code allowUserInteraction} field of + * this {@code URLConnection}. + * + * @param allowuserinteraction the new value. + * @throws IllegalStateException if already connected + * @see #getAllowUserInteraction() + */ + @Override + public void setAllowUserInteraction(boolean allowuserinteraction) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setAllowUserInteraction(allowuserinteraction); + } + + /** + * Returns the value of this {@code URLConnection}'s + * {@code useCaches} field. + * + * @return the value of this {@code URLConnection}'s + * {@code useCaches} field. + * @see #setUseCaches(boolean) + */ + @Override + public boolean getUseCaches() { + // not in cassette, go to real connection + return this.connection.getUseCaches(); + } + + /** + * Sets the value of the {@code useCaches} field of this + * {@code URLConnection} to the specified value. + *

+ * Some protocols do caching of documents. Occasionally, it is important + * to be able to "tunnel through" and ignore the caches (e.g., the + * "reload" button in a browser). If the UseCaches flag on a connection + * is true, the connection is allowed to use whatever caches it can. + * If false, caches are to be ignored. + * The default value comes from defaultUseCaches, which defaults to + * true. + * + * @param usecaches a {@code boolean} indicating whether + * or not to allow caching + * @throws IllegalStateException if already connected + * @see #getUseCaches() + */ + @Override + public void setUseCaches(boolean usecaches) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setUseCaches(usecaches); + } + + /** + * Returns the value of this object's {@code ifModifiedSince} field. + * + * @return the value of this object's {@code ifModifiedSince} field. + * @see #setIfModifiedSince(long) + */ + @Override + public long getIfModifiedSince() { + // not in cassette, go to real connection + return this.connection.getIfModifiedSince(); + } + + /** + * Sets the value of the {@code ifModifiedSince} field of + * this {@code URLConnection} to the specified value. + * + * @param ifmodifiedsince the new value. + * @throws IllegalStateException if already connected + * @see #getIfModifiedSince() + */ + @Override + public void setIfModifiedSince(long ifmodifiedsince) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setIfModifiedSince(ifmodifiedsince); + } + + /** + * Returns the default value of a {@code URLConnection}'s + * {@code useCaches} flag. + *

+ * This default is "sticky", being a part of the static state of all + * URLConnections. This flag applies to the next, and all following + * URLConnections that are created. + * + * @return the default value of a {@code URLConnection}'s + * {@code useCaches} flag. + * @see #setDefaultUseCaches(boolean) + */ + @Override + public boolean getDefaultUseCaches() { + // not in cassette, go to real connection + return this.connection.getDefaultUseCaches(); + } + + /** + * Sets the default value of the {@code useCaches} field to the + * specified value. + * + * @param defaultusecaches the new value. + * @see #getDefaultUseCaches() + */ + @Override + public void setDefaultUseCaches(boolean defaultusecaches) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setDefaultUseCaches(defaultusecaches); + } + + /** + * Sets the general request property. If a property with the key already + * exists, overwrite its value with the new value. + * + *

NOTE: HTTP requires all request properties which can + * legally have multiple instances with the same key + * to use a comma-separated list syntax which enables multiple + * properties to be appended into a single property. + * + * @param key the keyword by which the request is known + * (e.g., "{@code Accept}"). + * @param value the value associated with it. + * @throws IllegalStateException if already connected + * @throws NullPointerException if key is {@code null} + * @see #getRequestProperty(java.lang.String) + */ + @Override + public void setRequestProperty(String key, String value) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setRequestProperty(key, value); + } + + /** + * Adds a general request property specified by a + * key-value pair. This method will not overwrite + * existing values associated with the same key. + * + * @param key the keyword by which the request is known + * (e.g., "{@code Accept}"). + * @param value the value associated with it. + * @throws IllegalStateException if already connected + * @throws NullPointerException if key is null + * @see #getRequestProperties() + * @since 1.4 + */ + @Override + public void addRequestProperty(String key, String value) { + this.connection.addRequestProperty(key, value); + } + + /** + * Returns the value of the named general request property for this + * connection. + * + * @param key the keyword by which the request is known (e.g., "Accept"). + * @return the value of the named general request property for this + * connection. If key is null, then null is returned. + * @throws IllegalStateException if already connected + * @see #setRequestProperty(java.lang.String, java.lang.String) + */ + @Override + public String getRequestProperty(String key) { + if (mode == Mode.Bypass) { + return this.connection.getRequestProperty(key); + } + try { + buildCache(); + return getStringElementFromCache((interaction) -> interaction.getRequest().getHeaders().get(key).toString(), + null); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns an unmodifiable Map of general request + * properties for this connection. The Map keys + * are Strings that represent the request-header + * field names. Each Map value is a unmodifiable List + * of Strings that represents the corresponding + * field values. + * + * @return a Map of the general request properties for this connection. + * @throws IllegalStateException if already connected + * @since 1.4 + */ + @Override + public Map> getRequestProperties() { + // always return the real connection's request properties + return this.connection.getRequestProperties(); + } + + @Override + public InputStream getInputStream() throws IOException { + if (mode == Mode.Bypass) { + return this.connection.getInputStream(); + } + try { + buildCache(); + return createInputStream(this.cachedInteraction.getResponse().getBody()); + } catch (VCRException e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings ("deprecation") + @Override + //CHECKSTYLE.OFF: ParameterName + public long getHeaderFieldDate(String name, long Default) { + // not in cassette, go to real connection + return this.connection.getHeaderFieldDate(name, Default); + } + //CHECKSTYLE.ON: ParameterName + + /** + * Returns the value of the {@code content-length} header field. + *

+ * Note: {@link #getContentLengthLong() getContentLengthLong()} + * should be preferred over this method, since it returns a {@code long} + * instead and is therefore more portable.

+ * + * @return the content length of the resource that this connection's URL + * references, {@code -1} if the content length is not known, + * or if the content length is greater than Integer.MAX_VALUE. + */ + @Override + public int getContentLength() { + // not in cassette, go to real connection + return this.connection.getContentLength(); + } + + /** + * Returns the value of the {@code content-length} header field as a + * long. + * + * @return the content length of the resource that this connection's URL + * references, or {@code -1} if the content length is + * not known. + * @since 1.7 + */ + @Override + public long getContentLengthLong() { + // not in cassette, go to real connection + return this.connection.getContentLengthLong(); + } + + /** + * Returns the value of the {@code date} header field. + * + * @return the sending date of the resource that the URL references, + * or {@code 0} if not known. The value returned is the + * number of milliseconds since January 1, 1970 GMT. + * @see java.net.URLConnection#getHeaderField(java.lang.String) + */ + @Override + public long getDate() { + // not in cassette, go to real connection + return this.connection.getDate(); + } + + /** + * Returns the value of the {@code last-modified} header field. + * The result is the number of milliseconds since January 1, 1970 GMT. + * + * @return the date the resource referenced by this + * {@code URLConnection} was last modified, or 0 if not known. + * @see java.net.URLConnection#getHeaderField(java.lang.String) + */ + @Override + public long getLastModified() { + // not in cassette, go to real connection + return this.connection.getLastModified(); + } + + /** + * Returns the value of the named field parsed as a number. + *

+ * This form of {@code getHeaderField} exists because some + * connection types (e.g., {@code http-ng}) have pre-parsed + * headers. Classes for that connection type can override this method + * and short-circuit the parsing. + * + * @param name the name of the header field. + * @param Default the default value. + * @return the value of the named field, parsed as an integer. The + * {@code Default} value is returned if the field is + * missing or malformed. + */ + @Override + //CHECKSTYLE.OFF: ParameterName + public int getHeaderFieldInt(String name, int Default) { + // not in cassette, go to real connection + return this.connection.getHeaderFieldInt(name, Default); + } + //CHECKSTYLE.ON: ParameterName + + /** + * Returns the value of the named field parsed as a number. + *

+ * This form of {@code getHeaderField} exists because some + * connection types (e.g., {@code http-ng}) have pre-parsed + * headers. Classes for that connection type can override this method + * and short-circuit the parsing. + * + * @param name the name of the header field. + * @param Default the default value. + * @return the value of the named field, parsed as a long. The + * {@code Default} value is returned if the field is + * missing or malformed. + * @since 1.7 + */ + @Override + //CHECKSTYLE.OFF: ParameterName + public long getHeaderFieldLong(String name, long Default) { + // not in cassette, go to real connection + return this.connection.getHeaderFieldLong(name, Default); + } + //CHECKSTYLE.ON: ParameterName + + /** + * Retrieves the contents of this URL connection. + * + * @param classes the {@code Class} array + * indicating the requested types + * @return the object fetched that is the first match of the type + * specified in the classes array. null if none of + * the requested types are supported. + * The {@code instanceof} operator should be used to + * determine the specific kind of object returned. + * @throws IOException if an I/O error occurs while + * getting the content. + * @throws UnknownServiceException if the protocol does not support + * the content type. + * @see java.net.URLConnection#getContent() + * @see java.net.ContentHandlerFactory#createContentHandler(java.lang.String) + * @see java.net.URLConnection#getContent(java.lang.Class[]) + * @see java.net.URLConnection#setContentHandlerFactory(java.net.ContentHandlerFactory) + * @since 1.3 + */ + @Override + public Object getContent(Class[] classes) throws IOException { + // not in cassette, go to real connection + return this.connection.getContent(classes); + } + + /** + * Returns an output stream that writes to this connection. + * + * @return an output stream that writes to this connection. + * @throws IOException if an I/O error occurs while + * creating the output stream. + * @throws UnknownServiceException if the protocol does not support + * output. + */ + @Override + public OutputStream getOutputStream() throws IOException { + // use proxy requestBody to store inputted data + if (!this.getDoOutput()) { + throw new IOException("Cannot get output stream when doOutput is false"); + } + return this.requestBody; + } + + /// HTTPS methods /// + + @Override + public String getCipherSuite() { + // not in cassette, go to real connection + return this.connection.getCipherSuite(); + } + + @Override + public Certificate[] getLocalCertificates() { + // not in cassette, go to real connection + return this.connection.getLocalCertificates(); + } + + @Override + public Certificate[] getServerCertificates() throws SSLPeerUnverifiedException { + // not in cassette, go to real connection + return this.connection.getServerCertificates(); + } + + /** + * Returns the server's principal which was established as part of + * defining the session. + *

+ * Note: Subclasses should override this method. If not overridden, it + * will default to returning the X500Principal of the server's end-entity + * certificate for certificate-based ciphersuites, or throw an + * SSLPeerUnverifiedException for non-certificate based ciphersuites, + * such as Kerberos. + * + * @return the server's principal. Returns an X500Principal of the + * end-entity certiticate for X509-based cipher suites, and + * KerberosPrincipal for Kerberos cipher suites. + * @throws SSLPeerUnverifiedException if the peer was not verified + * @throws IllegalStateException if this method is called before + * the connection has been established. + * @see #getServerCertificates() + * @see #getLocalPrincipal() + * @since 1.5 + */ + @Override + public Principal getPeerPrincipal() throws SSLPeerUnverifiedException { + // not in cassette, go to real connection + return this.connection.getPeerPrincipal(); + } + + /** + * Returns the principal that was sent to the server during handshaking. + *

+ * Note: Subclasses should override this method. If not overridden, it + * will default to returning the X500Principal of the end-entity certificate + * that was sent to the server for certificate-based ciphersuites or, + * return null for non-certificate based ciphersuites, such as Kerberos. + * + * @return the principal sent to the server. Returns an X500Principal + * of the end-entity certificate for X509-based cipher suites, and + * KerberosPrincipal for Kerberos cipher suites. If no principal was + * sent, then null is returned. + * @throws IllegalStateException if this method is called before + * the connection has been established. + * @see #getLocalCertificates() + * @see #getPeerPrincipal() + * @since 1.5 + */ + @Override + public Principal getLocalPrincipal() { + // not in cassette, go to real connection + return this.connection.getLocalPrincipal(); + } + + /** + * gets the HostnameVerifier in place on this instance. + * + * @return the host name verifier + * @see #setHostnameVerifier(HostnameVerifier) + * @see #setDefaultHostnameVerifier(HostnameVerifier) + */ + @Override + public HostnameVerifier getHostnameVerifier() { + // not in cassette, go to real connection + return this.connection.getHostnameVerifier(); + } + + /** + * Sets the HostnameVerifier for this instance. + *

+ * New instances of this class inherit the default static hostname + * verifier set by {@link #setDefaultHostnameVerifier(HostnameVerifier) + * setDefaultHostnameVerifier}. Calls to this method replace + * this object's HostnameVerifier. + * + * @param v the host name verifier + * @throws IllegalArgumentException if the HostnameVerifier + * parameter is null. + * @see #getHostnameVerifier() + * @see #setDefaultHostnameVerifier(HostnameVerifier) + */ + @Override + public void setHostnameVerifier(HostnameVerifier v) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setHostnameVerifier(v); + } + + /** + * gets the SSL socket factory to be used when creating sockets + * for secure https URL connections. + * + * @return the SSLSocketFactory + * @see #setSSLSocketFactory(SSLSocketFactory) + */ + @Override + public SSLSocketFactory getSSLSocketFactory() { + // not in cassette, go to real connection + return this.connection.getSSLSocketFactory(); + } + + /** + * Sets the SSLSocketFactory to be used when this instance + * creates sockets for secure https URL connections. + *

+ * New instances of this class inherit the default static + * SSLSocketFactory set by + * {@link #setDefaultSSLSocketFactory(SSLSocketFactory) + * setDefaultSSLSocketFactory}. Calls to this method replace + * this object's SSLSocketFactory. + * + * @param sf the SSL socket factory + * @throws IllegalArgumentException if the SSLSocketFactory + * parameter is null. + * @throws SecurityException if a security manager exists and its + * checkSetFactory method does not allow + * a socket factory to be specified. + * @see #getSSLSocketFactory() + */ + @Override + public void setSSLSocketFactory(SSLSocketFactory sf) { + if (cachedInteraction != null) { + throw new IllegalStateException("Cannot set anything after interaction has been cached"); + } + this.connection.setSSLSocketFactory(sf); + } +} diff --git a/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableRequestBody.java b/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableRequestBody.java new file mode 100644 index 0000000..399f909 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableRequestBody.java @@ -0,0 +1,64 @@ +package com.easypost.easyvcr.clients.httpurlconnection; + +import java.io.IOException; +import java.io.OutputStream; + +public final class RecordableRequestBody extends OutputStream { + + private String data; + + /** + * Constructor. + */ + public RecordableRequestBody() { + this.data = ""; + } + + @Override + public void write(int b) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + data += new String(b, off, len); + } + + @Override + public void close() throws IOException { + // do nothing + } + + /** + * Returns the String data stored in this object. + * + * @return the String data stored in this object + */ + public String getData() { + return data; + } + + /** + * Check if this object is storing any data. + * + * @return true if this object is storing any data, false otherwise + */ + public boolean hasData() { + return !data.isEmpty(); + } + + /** + * Write the String data stored in this object to the given OutputStream. + * + * @param outputStream the OutputStream to write to + * @throws IOException if an I/O error occurs + */ + public void writeToOutputStream(OutputStream outputStream) throws IOException { + outputStream.write(data.getBytes()); + } +} diff --git a/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableURL.java b/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableURL.java new file mode 100644 index 0000000..12e6c57 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/clients/httpurlconnection/RecordableURL.java @@ -0,0 +1,300 @@ +package com.easypost.easyvcr.clients.httpurlconnection; + +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Mode; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.Proxy; +import java.net.URL; +import java.net.URLStreamHandler; + +/** + * A recordable URL, wrapper for RecordableHttpURLConnection and RecordableHttpsURLConnection. + */ +public final class RecordableURL { + /** + * The URL used by this recordable URL. + */ + private final URL url; + /** + * The cassette used by this recordable URL. + */ + private final Cassette cassette; + /** + * The VCR mode used by this recordable URL. + */ + private final Mode mode; + /** + * The advanced settings used by this recordable URL. + */ + private final AdvancedSettings advancedSettings; + + //CHECKSTYLE.OFF: ParameterNumber + + /** + * Constructs a new recordable URL. + * + * @param protocol The protocol of the URL. + * @param host The host of the URL. + * @param port The port of the URL. + * @param file The file of the URL. + * @param handler The URLStreamHandler of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @param advancedSettings The advanced settings used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(String protocol, String host, int port, String file, URLStreamHandler handler, + Cassette cassette, Mode mode, AdvancedSettings advancedSettings) throws MalformedURLException { + this.url = new URL(protocol, host, port, file, handler); + this.cassette = cassette; + this.mode = mode; + this.advancedSettings = advancedSettings; + } + //CHECKSTYLE.ON: ParameterNumber + + /** + * Constructs a new recordable URL. + * + * @param protocol The protocol of the URL. + * @param host The host of the URL. + * @param port The port of the URL. + * @param file The file of the URL. + * @param handler The URLStreamHandler of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(String protocol, String host, int port, String file, URLStreamHandler handler, + Cassette cassette, Mode mode) throws MalformedURLException { + this(protocol, host, port, file, handler, cassette, mode, new AdvancedSettings()); + } + + /** + * Constructs a new recordable URL. + * + * @param protocol The protocol of the URL. + * @param host The host of the URL. + * @param port The port of the URL. + * @param file The file of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @param advancedSettings The advanced settings used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(String protocol, String host, int port, String file, Cassette cassette, Mode mode, + AdvancedSettings advancedSettings) throws MalformedURLException { + this(protocol, host, port, file, null, cassette, mode, advancedSettings); + } + + /** + * Constructs a new recordable URL. + * + * @param protocol The protocol of the URL. + * @param host The host of the URL. + * @param port The port of the URL. + * @param file The file of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(String protocol, String host, int port, String file, Cassette cassette, Mode mode) + throws MalformedURLException { + this(protocol, host, port, file, cassette, mode, new AdvancedSettings()); + } + + /** + * Constructs a new recordable URL. + * + * @param protocol The protocol of the URL. + * @param host The host of the URL. + * @param file The file of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @param advancedSettings The advanced settings used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(String protocol, String host, String file, Cassette cassette, Mode mode, + AdvancedSettings advancedSettings) throws MalformedURLException { + this(protocol, host, -1, file, cassette, mode, advancedSettings); + } + + /** + * Constructs a new recordable URL. + * + * @param protocol The protocol of the URL. + * @param host The host of the URL. + * @param file The file of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(String protocol, String host, String file, Cassette cassette, Mode mode) + throws MalformedURLException { + this(protocol, host, file, cassette, mode, new AdvancedSettings()); + } + + /** + * Constructs a new recordable URL. + * + * @param context The URL of the URL. + * @param spec The spec of the URL. + * @param handler The URLStreamHandler of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @param advancedSettings The advanced settings used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(URL context, String spec, URLStreamHandler handler, Cassette cassette, Mode mode, + AdvancedSettings advancedSettings) throws MalformedURLException { + this.url = new URL(context, spec, handler); + this.cassette = cassette; + this.mode = mode; + this.advancedSettings = advancedSettings != null ? advancedSettings : new AdvancedSettings(); + if (this.cassette == null) { + throw new IllegalArgumentException("Cassette cannot be null"); + } + } + + /** + * Constructs a new recordable URL. + * + * @param context The URL of the URL. + * @param spec The spec of the URL. + * @param handler The URLStreamHandler of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(URL context, String spec, URLStreamHandler handler, Cassette cassette, Mode mode) + throws MalformedURLException { + this(context, spec, handler, cassette, mode, new AdvancedSettings()); + } + + /** + * Constructs a new recordable URL. + * + * @param spec The spec of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @param advancedSettings The advanced settings used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(String spec, Cassette cassette, Mode mode, AdvancedSettings advancedSettings) + throws MalformedURLException { + this(null, spec, cassette, mode, advancedSettings); + } + + /** + * Constructs a new recordable URL. + * + * @param spec The spec of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(String spec, Cassette cassette, Mode mode) throws MalformedURLException { + this(spec, cassette, mode, new AdvancedSettings()); + } + + /** + * Constructs a new recordable URL. + * + * @param context The URL of the URL. + * @param spec The spec of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @param advancedSettings The advanced settings used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(URL context, String spec, Cassette cassette, Mode mode, AdvancedSettings advancedSettings) + throws MalformedURLException { + this(context, spec, null, cassette, mode, advancedSettings); + } + + /** + * Constructs a new recordable URL. + * + * @param context The URL of the URL. + * @param spec The spec of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(URL context, String spec, Cassette cassette, Mode mode) throws MalformedURLException { + this(context, spec, cassette, mode, new AdvancedSettings()); + } + + /** + * Constructs a new recordable URL. + * + * @param context The URL of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @param advancedSettings The advanced settings used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(URL context, Cassette cassette, Mode mode, AdvancedSettings advancedSettings) + throws MalformedURLException { + this.url = context; + this.cassette = cassette; + this.mode = mode; + this.advancedSettings = advancedSettings != null ? advancedSettings : new AdvancedSettings(); + } + + /** + * Constructs a new recordable URL. + * + * @param context The URL of the URL. + * @param cassette The cassette used by this recordable URL. + * @param mode The VCR mode used by this recordable URL. + * @throws MalformedURLException If the URL is malformed. + */ + public RecordableURL(URL context, Cassette cassette, Mode mode) throws MalformedURLException { + this(context, cassette, mode, new AdvancedSettings()); + } + + /** + * Open an HTTP connection to the URL. + * + * @return a RecordableHttpURLConnection instance. + * @throws java.io.IOException if an I/O error occurs. + */ + public RecordableHttpURLConnection openConnection() throws java.io.IOException { + return new RecordableHttpURLConnection(this.url, this.cassette, this.mode, this.advancedSettings); + } + + /** + * Open an HTTP connection to the URL. + * + * @param proxy the proxy to use. + * @return a RecordableHttpURLConnection instance. + * @throws java.io.IOException if an I/O error occurs. + */ + public RecordableHttpURLConnection openConnection(Proxy proxy) throws java.io.IOException { + return new RecordableHttpURLConnection(this.url, proxy, this.cassette, this.mode, this.advancedSettings); + } + + /** + * Open an HTTPS connection to the URL. + * + * @return a RecordableHttpsURLConnection instance. + * @throws java.io.IOException if an I/O error occurs. + */ + public RecordableHttpsURLConnection openConnectionSecure() throws IOException { + return new RecordableHttpsURLConnection(this.url, this.cassette, this.mode, this.advancedSettings); + } + + /** + * Open an HTTPS connection to the URL. + * + * @param proxy the proxy to use. + * @return a RecordableHttpsURLConnection instance. + * @throws java.io.IOException if an I/O error occurs. + */ + public RecordableHttpsURLConnection openConnectionSecure(Proxy proxy) throws IOException { + return new RecordableHttpsURLConnection(this.url, proxy, this.cassette, this.mode, this.advancedSettings); + } +} diff --git a/src/main/java/com/easypost/easyvcr/interactionconverters/BaseInteractionConverter.java b/src/main/java/com/easypost/easyvcr/interactionconverters/BaseInteractionConverter.java new file mode 100644 index 0000000..bf85ad5 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/interactionconverters/BaseInteractionConverter.java @@ -0,0 +1,61 @@ +package com.easypost.easyvcr.interactionconverters; + +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.MatchRules; +import com.easypost.easyvcr.VCRException; +import com.easypost.easyvcr.requestelements.HttpInteraction; +import com.easypost.easyvcr.requestelements.Request; +import com.easypost.easyvcr.requestelements.Response; + +/** + * Base for custom interaction converters to convert requests/responses to/from EasyVCR requests/responses. + */ +public abstract class BaseInteractionConverter { + + /** + * Search for an existing interaction that matches the request. + * + * @param cassette The cassette to use to search for an existing interaction. + * @param request The request to search for. + * @param matchRules The match rules to use to determine if an interaction matches the request. + * @return The matching interaction, or null if no matching interaction was found. + * @throws VCRException If an error occurs while searching for an existing interaction. + */ + public HttpInteraction findMatchingInteraction(Cassette cassette, Request request, MatchRules matchRules) + throws VCRException { + for (HttpInteraction recordedInteraction : cassette.read()) { + if (matchRules.requestsMatch(request, recordedInteraction.getRequest())) { + return recordedInteraction; + } + } + return null; + } + + /** + * Create an HttpInteraction from a request and response. + * + * @param request The request to create an HttpInteraction from. + * @param response The response to create an HttpInteraction from. + * @param duration The duration of the interaction. + * @return The created HttpInteraction. + */ + protected HttpInteraction createInteraction(Request request, Response response, long duration) { + return new HttpInteraction(request, response, duration); + } + + public static class ResponseAndTime { + public final Response response; + public final long time; + + /** + * Constructor for ResponseAndTime. + * + * @param response Response + * @param time long + */ + public ResponseAndTime(Response response, long time) { + this.response = response; + this.time = time; + } + } +} diff --git a/src/main/java/com/easypost/easyvcr/interactionconverters/HttpUrlConnectionInteractionConverter.java b/src/main/java/com/easypost/easyvcr/interactionconverters/HttpUrlConnectionInteractionConverter.java new file mode 100644 index 0000000..fafc015 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/interactionconverters/HttpUrlConnectionInteractionConverter.java @@ -0,0 +1,127 @@ +package com.easypost.easyvcr.interactionconverters; + +import com.easypost.easyvcr.Censors; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableRequestBody; +import com.easypost.easyvcr.requestelements.HttpInteraction; +import com.easypost.easyvcr.requestelements.Request; +import com.easypost.easyvcr.requestelements.Response; +import com.easypost.easyvcr.requestelements.Status; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static com.easypost.easyvcr.internalutilities.Tools.readFromInputStream; + +/** + * The interaction converter to convert Http(s)UrlConnection requests/responses to/from EasyVCR requests/responses. + */ +public final class HttpUrlConnectionInteractionConverter extends BaseInteractionConverter { + /** + * Convert a HttpURLConnection request to an EasyVCR request. + * + * @param connection The HttpURLConnection request. + * @param requestBody The request body. + * @param censors The censors to apply to the request. + * @return The EasyVCR request. + */ + public Request createRecordedRequest(HttpURLConnection connection, RecordableRequestBody requestBody, + Censors censors) { + try { + // collect elements from the connection + String uriString = connection.getURL().toString(); + connection.disconnect(); + Map> headers = connection.getRequestProperties(); + String body = requestBody.getData(); + String method = connection.getRequestMethod(); + + // apply censors + uriString = censors.applyQueryParametersCensors(uriString); + headers = censors.applyHeadersCensors(headers); + body = censors.applyBodyParametersCensors(body); + + + // create the request + Request request = new Request(); + request.setMethod(method); + request.setUri(new URI(uriString)); + request.setHeaders(headers); + request.setBody(body); + + return request; + } catch (Exception ignored) { + return null; + } + } + + /** + * Convert a HttpURLConnection response to a ResponseAndTime object. + * + * @param connection The HttpURLConnection response. + * @param censors The censors to apply to the response. + * @return The ResponseAndTime object. + */ + public ResponseAndTime createRecordedResponse(HttpURLConnection connection, Censors censors) { + try { + // quickly time how long it takes to get the initial response + Instant start = Instant.now(); + int responseCode = connection.getResponseCode(); + Instant end = Instant.now(); + long milliseconds = Duration.between(start, end).toMillis(); + + // collect elements from the connection + String message = connection.getResponseMessage(); + String uriString = connection.getURL().toString(); + Map> headers = connection.getHeaderFields(); + String body = null; + String errors = null; + try { + body = readFromInputStream(connection.getInputStream()); + errors = readFromInputStream(connection.getErrorStream()); + } catch (NullPointerException | IOException ignored) { // nothing in body if bad status code from server + } + + // apply censors + uriString = censors.applyQueryParametersCensors(uriString); + headers = censors.applyHeadersCensors(headers); + // we don't censor the response body, only the request body + + // create the response + Response response = new Response(); + response.setStatus(new Status(responseCode, message)); + response.setUri(new URI(uriString)); + response.setHeaders(headers); + if (body != null) { + response.setBody(body); + } + if (errors != null) { + response.setErrors(errors); + } + + return new ResponseAndTime(response, milliseconds); + } catch (URISyntaxException | IOException ignored) { + return null; + } + } + + /** + * Convert a Http(s)URLConnection to an EasyVCR HttpInteraction. + * + * @param connection The Http(s)URLConnection. + * @param requestBody The request body. + * @param censors The censors to apply to the interaction. + * @return The EasyVCR HttpInteraction. + */ + public HttpInteraction createInteraction(HttpURLConnection connection, RecordableRequestBody requestBody, + Censors censors) { + Request request = createRecordedRequest(connection, requestBody, censors); + ResponseAndTime responseAndTime = createRecordedResponse(connection, censors); + connection.disconnect(); + return createInteraction(request, responseAndTime.response, responseAndTime.time); + } +} diff --git a/src/main/java/com/easypost/easyvcr/internalutilities/Files.java b/src/main/java/com/easypost/easyvcr/internalutilities/Files.java new file mode 100644 index 0000000..5485646 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/internalutilities/Files.java @@ -0,0 +1,78 @@ +package com.easypost.easyvcr.internalutilities; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class Files { + /** + * Creates a file if it doesn't exist. + * + * @param filePath The path to the file. + * @throws IOException If the file cannot be created. + */ + public static void createFileIfNotExists(String filePath) throws IOException { + try { + File file = new File(filePath); + File parentFolder = file.toPath().getParent().toFile(); + if (!parentFolder.exists()) { + parentFolder.mkdirs(); + } + file.createNewFile(); // if file already exists will do nothing + } catch (Exception ignored) { + throw new IOException("Could not create file"); + } + } + + /** + * Reads a file. + * + * @param file The file to read. + * @return The contents of the file. + */ + public static String readFile(File file) { + List data = new ArrayList<>(); + try { + data = java.nio.file.Files.readAllLines(file.toPath()); + } catch (IOException ignored) { + return null; + } + if (data.isEmpty()) { + return null; + } + StringBuilder contents = new StringBuilder(); + for (String line : data) { + contents.append(line); + } + return contents.toString(); + } + + /** + * Reads a file. + * + * @param filePath The path to the file. + * @return The contents of the file. + */ + public static String readFile(String filePath) { + File file = new File(filePath); + if (!file.exists()) { + return null; // file doesn't exist + } + return readFile(file); + } + + /** + * Writes a file. + * + * @param filePath The path to the file. + * @param string The contents to write. + */ + public static void writeFile(String filePath, String string) throws IOException { + createFileIfNotExists(filePath); + FileWriter myWriter = new FileWriter(filePath); + myWriter.write(string); + myWriter.close(); + } +} diff --git a/src/main/java/com/easypost/easyvcr/internalutilities/Tools.java b/src/main/java/com/easypost/easyvcr/internalutilities/Tools.java new file mode 100644 index 0000000..1a02259 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/internalutilities/Tools.java @@ -0,0 +1,197 @@ +package com.easypost.easyvcr.internalutilities; + +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.requestelements.HttpInteraction; +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URLEncodedUtils; +import org.apache.http.message.BasicNameValuePair; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Internal tools for EasyVCR. + */ +public class Tools { + /** + * Get a File object from a path. + * + * @param filePath The path to the file. + * @return The File object. + */ + public static File getFile(String filePath) { + if (filePath == null) { + return null; + } + return Paths.get(filePath).toFile(); + } + + /** + * Get a file path from a folder-file pair. + * + * @param folderPath The folder path. + * @param fileName The file name. + * @return The file path. + */ + public static String getFilePath(String folderPath, String fileName) { + return Paths.get(folderPath, fileName).toString(); + } + + /** + * Get the base64 representation of a string. + * + * @param input The string to encode. + * @return The base64 representation of the string. + */ + public static String toBase64String(String input) { + return Base64.getEncoder().encodeToString(input.getBytes()); + } + + /** + * Convert a URI's query parameters to a Map. + * + * @param uri The URI. + * @return The Map of query parameters. + */ + public static Map queryParametersToMap(URI uri) { + List receivedQueryDict = URLEncodedUtils.parse(uri, StandardCharsets.UTF_8); + if (receivedQueryDict == null || receivedQueryDict.size() == 0) { + return Collections.emptyMap(); + } + Map queryDict = new java.util.Hashtable<>(); + for (NameValuePair pair : receivedQueryDict) { + queryDict.put(pair.getName(), pair.getValue()); + } + return queryDict; + } + + /** + * Create an input stream from a string. + * + * @param string The string to create the input stream from. + * @return The input stream. + */ + public static InputStream createInputStream(String string) { + if (string == null) { + return new ByteArrayInputStream(new byte[] { }); + } + return new ByteArrayInputStream(string.getBytes()); + } + + /** + * Create an output stream from a string. + * + * @param string The string to create the output stream from. + * @return The output stream. + */ + public static OutputStream createOutputStream(String string) throws IOException { + if (string == null) { + return new ByteArrayOutputStream(); + } + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + outputStream.write(string.getBytes()); + return outputStream; + } + + /** + * Make a copy of an input stream (resetting the position to 0). + * + * @param stream The input stream to copy. + * @return A copy of the input stream. + */ + public static InputStream copyInputStream(InputStream stream) { + if (stream == null) { + return null; + } + try { + stream.reset(); + } catch (IOException ignored) { + } + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int len; + // TODO: Stream not resetting (len = -1) + while ((len = stream.read(buffer)) > -1) { + baos.write(buffer, 0, len); + } + baos.flush(); + return new ByteArrayInputStream(baos.toByteArray()); + } catch (IOException ignored) { + return new ByteArrayInputStream(new byte[] { }); + } + } + + /** + * Read the contents of an input stream into a string. + * + * @param stream The input stream to read. + * @return The contents of the input stream as a string. + */ + public static String readFromInputStream(InputStream stream) { + if (stream == null) { + return null; + } + InputStream copy = copyInputStream(stream); + String str = null; + try { + BufferedReader in = new BufferedReader(new InputStreamReader(copy)); + String inputLine; + StringBuilder content = new StringBuilder(); + while ((inputLine = in.readLine()) != null) { + content.append(inputLine); + } + in.close(); + str = content.toString(); + } catch (IOException ignored) { + } + return str; + } + + /** + * Convert a map to a query parameters string. + * + * @param map The map to convert. + * @return The query parameters string. + */ + public static List mapToQueryParameters(Map map) { + if (map == null || map.size() == 0) { + return Collections.emptyList(); + } + List nvpList = new ArrayList<>(map.size()); + for (Map.Entry entry : map.entrySet()) { + nvpList.add(new BasicNameValuePair(entry.getKey(), entry.getValue())); + } + return nvpList; + } + + /** + * Sleep the current thread for a specified number of milliseconds. + * + * @param interaction The interaction used to determine the number of milliseconds to sleep. + * @param advancedSettings The advanced settings used to determine the number of milliseconds to sleep. + * @throws InterruptedException If the thread is interrupted. + */ + public static void simulateDelay(HttpInteraction interaction, AdvancedSettings advancedSettings) + throws InterruptedException { + if (advancedSettings.simulateDelay) { + Thread.sleep(interaction.getDuration()); + } else { + Thread.sleep(advancedSettings.manualDelay); + } + } +} diff --git a/src/main/java/com/easypost/easyvcr/internalutilities/Utils.java b/src/main/java/com/easypost/easyvcr/internalutilities/Utils.java new file mode 100644 index 0000000..3026567 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/internalutilities/Utils.java @@ -0,0 +1,116 @@ +package com.easypost.easyvcr.internalutilities; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.BiPredicate; + +import static java.lang.String.format; + +public final class Utils { + private static final boolean[] T_CHAR = new boolean[256]; + private static final boolean[] FIELD_V_CHAR = new boolean[256]; + + private static final String HEADER_CONNECTION = "Connection"; + private static final String HEADER_UPGRADE = "Upgrade"; + + private static final Set DISALLOWED_HEADERS_SET = getDisallowedHeaders(); + + public static final BiPredicate ALLOWED_HEADERS = + (header, unused) -> !DISALLOWED_HEADERS_SET.contains(header); + + public static final BiPredicate VALIDATE_USER_HEADER = (name, value) -> { + assert name != null : "null header name"; + assert value != null : "null header value"; + if (!isValidName(name)) { + throw newIAE("invalid header name: \"%s\"", name); + } + if (!ALLOWED_HEADERS.test(name, null)) { + throw newIAE("restricted header name: \"%s\"", name); + } + if (!isValidValue(value)) { + throw newIAE("invalid header value for %s: \"%s\"", name, value); + } + return true; + }; + + private static Set getDisallowedHeaders() { + Set headers = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + // create a collection with all strings + Collection headerKeys = new ArrayList<>(); + headerKeys.add("connection"); + headerKeys.add("content-length"); + headerKeys.add("expect"); + headerKeys.add("host"); + headerKeys.add("upgrade"); + headers.addAll(headerKeys); + + String v = null; + if (v != null) { + // any headers found are removed from set. + String[] tokens = v.trim().split(","); + for (String token : tokens) { + headers.remove(token); + } + return Collections.unmodifiableSet(headers); + } else { + return Collections.unmodifiableSet(headers); + } + } + + /** + * Validates a RFC 7230 field-name. + * + * @param token the field-name to validate + * @return true if the field-name is valid + */ + public static boolean isValidName(String token) { + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (c > 255 || !T_CHAR[c]) { + return false; + } + } + return !token.isEmpty(); + } + + /** + * Validates a RFC 7230 field-value. + *

+ * "Obsolete line folding" rule + *

+ * obs-fold = CRLF 1*( SP / HTAB ) + *

+ * is not permitted! + * + * @param token the field-value to validate + * @return true if the field-value is valid + */ + public static boolean isValidValue(String token) { + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (c > 255) { + return false; + } + if (c == ' ' || c == '\t') { + continue; + } else if (!FIELD_V_CHAR[c]) { + return false; // forbidden byte + } + } + return true; + } + + /** + * Throw an IllegalArgumentException with a formatted message. + * + * @param message the message to use in the exception + * @param args the arguments to use in the formatted message + * @return IllegalArgumentException + */ + public static IllegalArgumentException newIAE(String message, Object... args) { + return new IllegalArgumentException(format(message, args)); + } +} diff --git a/src/main/java/com/easypost/easyvcr/internalutilities/json/Serialization.java b/src/main/java/com/easypost/easyvcr/internalutilities/json/Serialization.java new file mode 100644 index 0000000..9c59786 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/internalutilities/json/Serialization.java @@ -0,0 +1,47 @@ +package com.easypost.easyvcr.internalutilities.json; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; + +/** + * JSON de/serialization utilities. + */ +public final class Serialization { + /** + * Convert a JSON string to an object. + * + * @param json JSON string + * @param clazz Class of the object to convert to + * @param Type of the object to convert to + * @return Object of type clazz + */ + public static T convertJsonToObject(String json, Class clazz) { + Gson gson = new Gson(); + return gson.fromJson(json, clazz); + } + + /** + * Convert a JSON element to an object. + * + * @param json JSON element + * @param clazz Class of the object to convert to + * @param Type of the object to convert to + * @return Object of type clazz + */ + public static T convertJsonToObject(JsonElement json, Class clazz) { + Gson gson = new Gson(); + return gson.fromJson(json, clazz); + } + + /** + * Convert an object to a JSON string. + * + * @param object Object to convert + * @return JSON string + */ + public static String convertObjectToJson(Object object) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + return gson.toJson(object); + } +} diff --git a/src/main/java/com/easypost/easyvcr/requestelements/HttpElement.java b/src/main/java/com/easypost/easyvcr/requestelements/HttpElement.java new file mode 100644 index 0000000..1788ada --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/requestelements/HttpElement.java @@ -0,0 +1,17 @@ +package com.easypost.easyvcr.requestelements; + +import com.easypost.easyvcr.internalutilities.json.Serialization; + +/** + * Base class for all EasyVCR request/response objects. + */ +public abstract class HttpElement { + /** + * Serialize this object to a JSON string. + * + * @return JSON string representation of this HttpElement object. + */ + public String toJson() { + return Serialization.convertObjectToJson(this); + } +} diff --git a/src/main/java/com/easypost/easyvcr/requestelements/HttpInteraction.java b/src/main/java/com/easypost/easyvcr/requestelements/HttpInteraction.java new file mode 100644 index 0000000..4940500 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/requestelements/HttpInteraction.java @@ -0,0 +1,114 @@ +package com.easypost.easyvcr.requestelements; + +import java.time.Instant; + +/** + * Represents an HTTP request-response pair tracked by EasyVCR. + */ +public final class HttpInteraction extends HttpElement { + /** + * Timestamp of when the interaction was recorded. + */ + private long recordedAt; + + /** + * The HTTP request. + */ + private Request request; + + /** + * The HTTP response. + */ + private Response response; + + /** + * The duration of the request in milliseconds. + */ + private long duration = 0; + + /** + * Constructs a new HTTPInteraction object. + * + * @param request The HTTP request. + * @param response The HTTP response. + * @param duration The duration of the request in milliseconds. + */ + public HttpInteraction(Request request, Response response, long duration) { + this.request = request; + this.response = response; + this.recordedAt = Instant.now().getEpochSecond(); + this.duration = duration; + } + + /** + * Returns the timestamp of when the interaction was recorded. + * + * @return The timestamp of when the interaction was recorded. + */ + public long getRecordedAt() { + return this.recordedAt; + } + + /** + * Set the timestamp of when the interaction was recorded. + * + * @param recordedAt The timestamp of when the interaction was recorded. + */ + public void setRecordedAt(final long recordedAt) { + this.recordedAt = recordedAt; + } + + /** + * Returns the HTTP request. + * + * @return The HTTP request. + */ + public Request getRequest() { + return this.request; + } + + /** + * Sets the HTTP request. + * + * @param request The HTTP request. + */ + public void setRequest(final Request request) { + this.request = request; + } + + /** + * Returns the HTTP response. + * + * @return The HTTP response. + */ + public Response getResponse() { + return this.response; + } + + /** + * Sets the HTTP response. + * + * @param response The HTTP response. + */ + public void setResponse(final Response response) { + this.response = response; + } + + /** + * Returns the duration of the request in milliseconds. + * + * @return The duration of the request in milliseconds. + */ + public long getDuration() { + return this.duration; + } + + /** + * Sets the duration of the request in milliseconds. + * + * @param duration The duration of the request in milliseconds. + */ + public void setDuration(final int duration) { + this.duration = duration; + } +} diff --git a/src/main/java/com/easypost/easyvcr/requestelements/HttpVersion.java b/src/main/java/com/easypost/easyvcr/requestelements/HttpVersion.java new file mode 100644 index 0000000..20bb25d --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/requestelements/HttpVersion.java @@ -0,0 +1,65 @@ +package com.easypost.easyvcr.requestelements; + +import org.apache.http.ProtocolVersion; + +/** + * Represents an HTTP version. + */ +public final class HttpVersion { + /** + * HTTP protocol string. + */ + private final String protocol; + + /** + * HTTP minor version number. + */ + private int minor = 0; + + /** + * HTTP major version number. + */ + private int major = 0; + + /** + * Constructs a new HTTP version. + * + * @param version the HTTP version string + */ + public HttpVersion(String version) { + this.protocol = version; + } + + /** + * Constructs a new HTTP version. + * + * @param version the HTTP ProtocolVersion + */ + public HttpVersion(ProtocolVersion version) { + this.protocol = version.getProtocol(); + this.major = version.getMajor(); + this.minor = version.getMinor(); + } + + /** + * Returns the HTTP version as a ProtocolVersion. + * + * @return the HTTP version as a ProtocolVersion + */ + public ProtocolVersion asProtocolVersion() { + return new ProtocolVersion(this.protocol, this.major, this.minor); + } + + /** + * Returns the HTTP version as a string. + * + * @return the HTTP version as a string + */ + public String toString() { + String string = this.protocol; + if (this.major > 0) { + string += "/" + this.major + "." + this.minor; + } + return string; + } +} diff --git a/src/main/java/com/easypost/easyvcr/requestelements/Request.java b/src/main/java/com/easypost/easyvcr/requestelements/Request.java new file mode 100644 index 0000000..1902924 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/requestelements/Request.java @@ -0,0 +1,121 @@ +package com.easypost.easyvcr.requestelements; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +/** + * Represents an HTTP request tracked by EasyVCR. + */ +public final class Request extends HttpElement { + + /** + * The body of the request. + */ + private String body; + + /** + * The method of the request. + */ + private String method; + + /** + * The headers of the request. + */ + private Map> headers; + + /** + * The URI of the request. + */ + private URI uri; + + /** + * Returns the body of the request. + * + * @return the body of the request + */ + public String getBody() { + return body != null ? body : ""; + } + + /** + * Sets the body of the request. + * + * @param body the body of the request + */ + public void setBody(String body) { + this.body = body; + } + + /** + * Returns the method of the request. + * + * @return the method of the request + */ + public String getMethod() { + return method; + } + + /** + * Sets the method of the request. + * + * @param method the method of the request + */ + public void setMethod(String method) { + this.method = method; + } + + /** + * Returns the headers of the request. + * + * @return the headers of the request + */ + public Map> getHeaders() { + return headers; + } + + /** + * Sets the headers of the request. + * + * @param headers the headers of the request + */ + public void setHeaders(Map> headers) { + this.headers = headers; + } + + /** + * Returns the URI of the request. + * + * @return the URI of the request + */ + public URI getUri() { + return this.uri; + } + + /** + * Sets the URI of the request. + * + * @param uri the URI of the request + */ + public void setUri(URI uri) { + this.uri = uri; + } + + /** + * Returns the URI of the request as a string. + * + * @return the URI of the request as a string + */ + public String getUriString() { + return this.uri.toString(); + } + + /** + * Sets the URI of the request from a string. + * + * @param uriString the URI of the request as a string + */ + public void setUriString(String uriString) { + this.uri = URI.create(uriString); + } +} diff --git a/src/main/java/com/easypost/easyvcr/requestelements/Response.java b/src/main/java/com/easypost/easyvcr/requestelements/Response.java new file mode 100644 index 0000000..f615a4a --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/requestelements/Response.java @@ -0,0 +1,454 @@ +package com.easypost.easyvcr.requestelements; + +import com.easypost.easyvcr.Statics; +import org.apache.http.Header; +import org.apache.http.HeaderIterator; +import org.apache.http.HttpEntity; +import org.apache.http.ProtocolVersion; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.message.BasicHeader; +import org.apache.http.params.HttpParams; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static com.easypost.easyvcr.internalutilities.Tools.createInputStream; + +/** + * Represents an HTTP response tracked by EasyVCR. + */ +public final class Response extends HttpElement { + /** + * The body of the response. + */ + private String body; + + /** + * The HTTP version of the response. + */ + private HttpVersion httpVersion; + + /** + * The headers of the response. + */ + private Map> headers; + + /** + * The status of the response. + */ + private Status status; + + /** + * The errors of the response. + */ + private String errors; + + /** + * The URI of the response. + */ + private URI uri; + + /** + * Build a CloseableHttpResponse out of this Response. + * + * @return a CloseableHttpResponse representation of this Response. + */ + public CloseableHttpResponse toCloseableHttpResponse() { + + return new CloseableHttpResponse() { + @Override + public void close() throws IOException { + // not implemented + } + + @Override + public StatusLine getStatusLine() { + // not implemented + return new StatusLine() { + @Override + public ProtocolVersion getProtocolVersion() { + return Response.this.httpVersion.asProtocolVersion(); + } + + @Override + public int getStatusCode() { + return Response.this.status.getCode(); + } + + @Override + public String getReasonPhrase() { + return Response.this.status.getMessage(); + } + }; + } + + @Override + public void setStatusLine(StatusLine statusLine) { + // not implemented + } + + @Override + public void setStatusLine(ProtocolVersion protocolVersion, int i) { + // not implemented + } + + @Override + public void setStatusLine(ProtocolVersion protocolVersion, int i, String s) { + // not implemented + } + + @Override + public HttpEntity getEntity() { + return new HttpEntity() { + @Override + public boolean isRepeatable() { + return false; + } + + @Override + public boolean isChunked() { + return false; + } + + @Override + public long getContentLength() { + return Response.this.body.length(); + } + + @Override + public Header getContentType() { + // TODO: May be accidentally recursive + return Response.this.toCloseableHttpResponse().getFirstHeader("Content-Type"); + } + + @Override + public Header getContentEncoding() { + // TODO: May be accidentally recursive + return Response.this.toCloseableHttpResponse().getFirstHeader("Content-Encoding"); + } + + @Override + public InputStream getContent() throws IOException, UnsupportedOperationException { + return createInputStream(Response.this.body); + } + + @Override + public void writeTo(OutputStream outputStream) throws IOException { + if (Response.this.body != null) { + outputStream.write(Response.this.body.getBytes()); + } + } + + @Override + public boolean isStreaming() { + return false; + } + + @Override + public void consumeContent() throws IOException { + // not implemented + } + }; + } + + @Override + public void setEntity(HttpEntity httpEntity) { + // not implemented + } + + @Override + public Locale getLocale() { + // not implemented + return null; + } + + @Override + public void setLocale(Locale locale) { + // not implemented + } + + @Override + public void setStatusCode(int i) throws IllegalStateException { + if (Response.this.status != null) { + Response.this.status.setCode(i); + } + } + + @Override + public ProtocolVersion getProtocolVersion() { + return Response.this.getHttpVersion().asProtocolVersion(); + } + + @Override + public boolean containsHeader(String s) { + return Response.this.getHeaders().containsKey(s); + } + + @Override + public Header[] getHeaders(String s) { + List matchingHeaderValues = Response.this.getHeaders().get(s); + if (matchingHeaderValues == null) { + return null; + } + Header[] headers = new Header[matchingHeaderValues.size()]; + for (int i = 0; i < matchingHeaderValues.size(); i++) { + headers[i] = new BasicHeader(s, matchingHeaderValues.get(i)); + } + return headers; + } + + @Override + public Header getFirstHeader(String s) { + Header[] headers = getHeaders(s); + if (headers == null || headers.length == 0) { + return null; + } + return headers[0]; + } + + @Override + public void setReasonPhrase(String s) throws IllegalStateException { + if (Response.this.status != null) { + Response.this.status.setMessage(s); + } + } + + @Override + public Header getLastHeader(String s) { + Header[] headers = getHeaders(s); + if (headers == null || headers.length == 0) { + return null; + } + return headers[headers.length - 1]; + } + + @Override + public Header[] getAllHeaders() { + Map> headerMap = Response.this.getHeaders(); + if (headerMap == null) { + return null; + } + List

headers = new ArrayList<>(); + for (Map.Entry> entry : headerMap.entrySet()) { + for (String value : entry.getValue()) { + headers.add(new BasicHeader(entry.getKey(), value)); + } + } + + return headers.toArray(new Header[0]); + } + + @Override + public void addHeader(Header header) { + // not implemented + } + + @Override + public void addHeader(String s, String s1) { + // not implemented + } + + @Override + public void setHeader(Header header) { + // not implemented + } + + @Override + public void setHeader(String s, String s1) { + // not implemented + } + + @Override + public void setHeaders(Header[] headers) { + // not implemented + } + + @Override + public void removeHeader(Header header) { + // not implemented + } + + @Override + public void removeHeaders(String s) { + // not implemented + } + + @Override + public HeaderIterator headerIterator() { + // not implemented + return null; + } + + @Override + public HeaderIterator headerIterator(String s) { + // not implemented + return null; + } + + @Override + public HttpParams getParams() { + // not implemented + return null; + } + + @Override + public void setParams(HttpParams httpParams) { + // not implemented + } + }; + } + + /** + * Returns the body of the response. + * + * @return the body of the response + */ + public String getBody() { + return this.body; + } + + /** + * Sets the body of the response. + * + * @param body the body of the response + */ + public void setBody(String body) { + this.body = body; + } + + /** + * Returns the HttpVersion of the response. + * + * @return the HttpVersion of the response + */ + public HttpVersion getHttpVersion() { + return this.httpVersion; + } + + /** + * Sets the HTTP version of the response from a ProtocolVersion. + * + * @param version the HTTP version of the response as a ProtocolVersion + */ + public void setHttpVersion(ProtocolVersion version) { + this.httpVersion = new HttpVersion(version); + } + + /** + * Sets the HTTP version of the response from a String. + * + * @param version the HTTP version of the response as a String + */ + public void setHttpVersion(String version) { + this.httpVersion = new HttpVersion(version); + } + + /** + * Returns the headers of the response. + * + * @return the headers of the response + */ + public Map> getHeaders() { + return this.headers; + } + + /** + * Sets the headers of the response. + * + * @param headers the headers of the response + */ + public void setHeaders(Map> headers) { + this.headers = headers; + } + + /** + * Add the EasyVCR headers to the response. + */ + public void addReplayHeaders() { + // add default replay headers + Map replayHeaders = Statics.getReplayHeaders(); + for (Map.Entry entry : replayHeaders.entrySet()) { + this.headers.put(entry.getKey(), Collections.singletonList(entry.getValue())); + } + } + + /** + * Returns the status of the response. + * + * @return the status of the response + */ + public Status getStatus() { + return this.status; + } + + /** + * Sets the status of the response. + * + * @param status the status of the response + */ + public void setStatus(Status status) { + this.status = status; + } + + /** + * Returns the errors of the response. + * + * @return the errors of the response + */ + public String getErrors() { + return this.errors; + } + + /** + * Sets the errors of the response. + * + * @param errors the errors of the response + */ + public void setErrors(String errors) { + this.errors = errors; + } + + /** + * Returns the URI of the response. + * + * @return the URI of the response + */ + public URI getUri() { + return this.uri; + } + + /** + * Sets the URI of the response. + * + * @param uri the URI of the response + */ + public void setUri(URI uri) { + this.uri = uri; + } + + /** + * Returns the URI of the response as a string. + * + * @return the URI of the response as a string + */ + public String getUriString() { + return this.uri.toString(); + } + + /** + * Sets the URI of the response from a string. + * + * @param uriString the URI of the response as a string + */ + public void setUriString(String uriString) { + this.uri = URI.create(uriString); + } +} diff --git a/src/main/java/com/easypost/easyvcr/requestelements/Status.java b/src/main/java/com/easypost/easyvcr/requestelements/Status.java new file mode 100644 index 0000000..ed0c286 --- /dev/null +++ b/src/main/java/com/easypost/easyvcr/requestelements/Status.java @@ -0,0 +1,63 @@ +package com.easypost.easyvcr.requestelements; + +/** + * Represents a status of an HTTP request tracked by EasyVCR. + */ +public final class Status { + /** + * The status code of the HTTP request. + */ + private int code; + + /** + * The status description of the HTTP request. + */ + private String message; + + /** + * Constructs a new Status object. object. + * + * @param code The status code of the HTTP request. + * @param message The status description of the HTTP request. + */ + public Status(int code, String message) { + setCode(code); + setMessage(message); + } + + /** + * Returns the status code of the HTTP request. + * + * @return The status code of the HTTP request. + */ + public int getCode() { + return this.code; + } + + /** + * Sets the status code of the HTTP request. + * + * @param code The status code of the HTTP request. + */ + public void setCode(int code) { + this.code = code; + } + + /** + * Returns the status description of the HTTP request. + * + * @return The status description of the HTTP request. + */ + public String getMessage() { + return this.message; + } + + /** + * Sets the status description of the HTTP request. + * + * @param message The status description of the HTTP request. + */ + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/test/java/FakeDataService.java b/src/test/java/FakeDataService.java new file mode 100644 index 0000000..3a9c236 --- /dev/null +++ b/src/test/java/FakeDataService.java @@ -0,0 +1,104 @@ +import com.easypost.easyvcr.VCR; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import com.easypost.easyvcr.internalutilities.json.Serialization; + +import static com.easypost.easyvcr.internalutilities.Tools.readFromInputStream; + +public class FakeDataService { + + public final static String GET_POSTS_URL = "https://jsonplaceholder.typicode.com/posts"; + + private interface FakeDataServiceBaseInterface { + + Post[] getPosts() throws Exception; + + Object getPostsRawResponse() throws Exception; + } + + public static class Post { + public int userId; + public int id; + public String title; + public String body; + } + + public static class HttpUrlConnection extends FakeDataServiceBase implements FakeDataServiceBaseInterface { + protected RecordableHttpURLConnection client; + + public HttpUrlConnection(RecordableHttpURLConnection client) { + this.client = client; + } + + public HttpUrlConnection(VCR vcr) { + this.vcr = vcr; + } + + public RecordableHttpURLConnection getClient(String url) throws Exception { + if (client != null) { + return client; + } else if (vcr != null) { + return vcr.getHttpUrlConnection(url).openConnection(); + } + throw new Exception("No VCR or client has been set."); + } + + @Override + public Post[] getPosts() throws Exception { + RecordableHttpURLConnection client = (RecordableHttpURLConnection) getPostsRawResponse(); + String json = readFromInputStream(client.getInputStream()); + + return Serialization.convertJsonToObject(json, Post[].class); + } + + @Override + public Object getPostsRawResponse() throws Exception { + RecordableHttpURLConnection client = getClient(GET_POSTS_URL); + client.connect(); + return client; + } + } + + public static class HttpsUrlConnection extends FakeDataServiceBase implements FakeDataServiceBaseInterface { + protected RecordableHttpsURLConnection client; + + public HttpsUrlConnection(RecordableHttpsURLConnection client) { + this.client = client; + } + + public HttpsUrlConnection(VCR vcr) { + this.vcr = vcr; + } + + public RecordableHttpsURLConnection getClient(String url) throws Exception { + if (client != null) { + return client; + } else if (vcr != null) { + return vcr.getHttpUrlConnection(url).openConnectionSecure(); + } + throw new Exception("No VCR or client has been set."); + } + + @Override + public Post[] getPosts() throws Exception { + RecordableHttpsURLConnection client = (RecordableHttpsURLConnection) getPostsRawResponse(); + String json = readFromInputStream(client.getInputStream()); + + return Serialization.convertJsonToObject(json, Post[].class); + } + + @Override + public Object getPostsRawResponse() throws Exception { + RecordableHttpsURLConnection client = getClient(GET_POSTS_URL); + client.connect(); + return client; + } + } + + private static class FakeDataServiceBase { + protected VCR vcr; + + public FakeDataServiceBase() { + } + } +} diff --git a/src/test/java/HttpUrlConnectionTest.java b/src/test/java/HttpUrlConnectionTest.java new file mode 100644 index 0000000..9afe792 --- /dev/null +++ b/src/test/java/HttpUrlConnectionTest.java @@ -0,0 +1,245 @@ +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Censors; +import com.easypost.easyvcr.HttpClientType; +import com.easypost.easyvcr.HttpClients; +import com.easypost.easyvcr.MatchRules; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; + +import static com.easypost.easyvcr.internalutilities.Tools.readFromInputStream; + +public class HttpUrlConnectionTest { + + private static FakeDataService.Post[] GetFakePostsRequest(Cassette cassette, Mode mode) throws Exception { + RecordableHttpsURLConnection connection = TestUtils.getSimpleHttpsURLConnection(cassette.name, mode, null); + + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + + return fakeDataService.getPosts(); + } + + @Test + public void testPOSTRequest() throws Exception { + AdvancedSettings advancedSettings = new AdvancedSettings(); + advancedSettings.matchRules = new MatchRules().byMethod().byBody().byFullUrl(); + RecordableHttpsURLConnection connection = + TestUtils.getSimpleHttpsURLConnection("https://www.google.com", "test_post_request", Mode.Record, advancedSettings); + connection.setDoOutput(true); + String jsonInputString = "{'name': 'Upendra', 'job': 'Programmer'}"; + OutputStream output = null; + try { + output = connection.getOutputStream(); + output.write(jsonInputString.getBytes(StandardCharsets.UTF_8)); + } finally { + if (output != null) { + output.close(); + } + } + connection.connect(); + String json = readFromInputStream(connection.getInputStream()); + Assert.assertNotNull(json); + } + + @Test + public void testClient() throws IOException { + RecordableHttpsURLConnection connection = + TestUtils.getSimpleHttpsURLConnection("https://www.google.com", "test_client", Mode.Bypass, null); + + Assert.assertNotNull(connection); + } + + @Test + public void testFakeDataServiceClient() throws IOException { + RecordableHttpsURLConnection connection = + TestUtils.getSimpleHttpsURLConnection("https://www.google.com", "test_client", Mode.Bypass, null); + + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + + Assert.assertNotNull(fakeDataService); + } + + @Test + public void testErase() throws Exception { + Cassette cassette = TestUtils.getCassette("test_erase"); + + // record something to the cassette + FakeDataService.Post[] posts = GetFakePostsRequest(cassette, Mode.Record); + Assert.assertTrue(cassette.numInteractions() > 0); + + // erase the cassette + cassette.erase(); + Assert.assertEquals(0, cassette.numInteractions()); + } + + @Test + public void testEraseAndRecord() throws Exception { + Cassette cassette = TestUtils.getCassette("test_erase_and_record"); + cassette.erase(); // Erase cassette before recording + + FakeDataService.Post[] posts = GetFakePostsRequest(cassette, Mode.Record); + + Assert.assertNotNull(posts); + Assert.assertEquals(posts.length, 100); + Assert.assertTrue(cassette.numInteractions() > 0); // Make sure cassette is not empty + } + + @Test + public void testEraseAndPlayback() { + Cassette cassette = TestUtils.getCassette("test_erase_and_record"); + cassette.erase(); // Erase cassette before recording + + // cassette is empty, so replaying should throw an exception + Assert.assertThrows(Exception.class, () -> GetFakePostsRequest(cassette, Mode.Replay)); + } + + @Test + public void testAutoMode() throws Exception { + Cassette cassette = TestUtils.getCassette("test_auto_mode"); + cassette.erase(); // Erase cassette before recording + + // in replay mode, if cassette is empty, should throw an exception + Assert.assertThrows(Exception.class, () -> GetFakePostsRequest(cassette, Mode.Replay)); + Assert.assertEquals(cassette.numInteractions(), 0); // Make sure cassette is still empty + + // in auto mode, if cassette is empty, should make and record a real request + FakeDataService.Post[] posts = GetFakePostsRequest(cassette, Mode.Auto); + Assert.assertNotNull(posts); + Assert.assertTrue(cassette.numInteractions() > 0); // Make sure cassette is no longer empty + } + + @Test + public void testInteractionElements() throws Exception { + Cassette cassette = TestUtils.getCassette("test_interaction_elements"); + cassette.erase(); // Erase cassette before recording + + RecordableHttpsURLConnection connection = + (RecordableHttpsURLConnection) HttpClients.newClient(HttpClientType.HttpsUrlConnection, + FakeDataService.GET_POSTS_URL, cassette, Mode.Record); + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + + // Most elements of a VCR request are black-boxed, so we can't test them here. + // Instead, we can get the recreated HttpResponseMessage and check the details. + RecordableHttpsURLConnection response = (RecordableHttpsURLConnection) fakeDataService.getPostsRawResponse(); + Assert.assertNotNull(response); + } + + @Test + public void testCensors() throws Exception { + Cassette cassette = TestUtils.getCassette("test_censors"); + cassette.erase(); // Erase cassette before recording + + // set up advanced settings + String censorString = "censored-by-test"; + Censors censors = new Censors(censorString).hideHeader("Date"); + + AdvancedSettings advancedSettings = new AdvancedSettings(); + advancedSettings.censors = censors; + + // record cassette with advanced settings first + RecordableHttpsURLConnection connection = + (RecordableHttpsURLConnection) HttpClients.newClient(HttpClientType.HttpsUrlConnection, + FakeDataService.GET_POSTS_URL, cassette, Mode.Record, advancedSettings); + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + Object ignore = fakeDataService.getPostsRawResponse(); + + // now replay cassette + connection = (RecordableHttpsURLConnection) HttpClients.newClient(HttpClientType.HttpsUrlConnection, + FakeDataService.GET_POSTS_URL, cassette, Mode.Replay, advancedSettings); + fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + RecordableHttpsURLConnection response = (RecordableHttpsURLConnection) fakeDataService.getPostsRawResponse(); + + // check that the replayed response contains the censored header + Assert.assertNotNull(response); + Assert.assertNotNull(response.getHeaderField("Date")); + String censoredHeader = response.getHeaderField("Date"); + Assert.assertNotNull(censoredHeader); + Assert.assertEquals(censoredHeader, censorString); + } + + @Test + public void testMatchSettings() throws Exception { + Cassette cassette = TestUtils.getCassette("test_match_settings"); + cassette.erase(); // Erase cassette before recording + + // record cassette with advanced settings first + RecordableHttpsURLConnection connection = + (RecordableHttpsURLConnection) HttpClients.newClient(HttpClientType.HttpsUrlConnection, + FakeDataService.GET_POSTS_URL, cassette, Mode.Record); + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + Object ignore = fakeDataService.getPostsRawResponse(); + + // replay cassette with default match rules, should find a match + connection = (RecordableHttpsURLConnection) HttpClients.newClient(HttpClientType.HttpsUrlConnection, + FakeDataService.GET_POSTS_URL, cassette, Mode.Replay); + connection.setRequestProperty("X-Custom-Header", + "custom-value"); // add custom header to request, shouldn't matter when matching by default rules + fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + RecordableHttpsURLConnection response = (RecordableHttpsURLConnection) fakeDataService.getPostsRawResponse(); + Assert.assertNotNull(response); + + // replay cassette with custom match rules, should not find a match because request is different (throw exception) + MatchRules matchRules = new MatchRules().byEverything(); + AdvancedSettings advancedSettings = new AdvancedSettings(); + advancedSettings.matchRules = matchRules; + + connection = (RecordableHttpsURLConnection) HttpClients.newClient(HttpClientType.HttpsUrlConnection, + FakeDataService.GET_POSTS_URL, cassette, Mode.Replay, advancedSettings); + connection.setRequestProperty("X-Custom-Header", + "custom-value"); // add custom header to request, causing a match failure when matching by everything + fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + FakeDataService.HttpsUrlConnection finalFakeDataService = fakeDataService; + Assert.assertThrows(Exception.class, () -> finalFakeDataService.getPosts()); + } + + @Test + public void testDelay() throws Exception { + Cassette cassette = TestUtils.getCassette("test_delay"); + cassette.erase(); // Erase cassette before recording + + // record cassette first + RecordableHttpsURLConnection connection = + (RecordableHttpsURLConnection) HttpClients.newClient(HttpClientType.HttpsUrlConnection, + FakeDataService.GET_POSTS_URL, cassette, Mode.Record); + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + Object ignore = fakeDataService.getPosts(); + + // baseline - how much time does it take to replay the cassette? + connection = (RecordableHttpsURLConnection) HttpClients.newClient(HttpClientType.HttpsUrlConnection, + FakeDataService.GET_POSTS_URL, cassette, Mode.Replay); + fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + + Instant start = Instant.now(); + FakeDataService.Post[] posts = fakeDataService.getPosts(); + Instant end = Instant.now(); + + // confirm the normal replay worked, note time + Assert.assertNotNull(posts); + int normalReplayTime = (int) Duration.between(start, end).toMillis(); + + // set up advanced settings + int delay = normalReplayTime + 3000; // add 3 seconds to the normal replay time, for good measure + AdvancedSettings advancedSettings = new AdvancedSettings(); + advancedSettings.manualDelay = delay; + connection = (RecordableHttpsURLConnection) HttpClients.newClient(HttpClientType.HttpsUrlConnection, + FakeDataService.GET_POSTS_URL, cassette, Mode.Replay, advancedSettings); + fakeDataService = new FakeDataService.HttpsUrlConnection(connection); + + // time replay request + start = Instant.now(); + posts = fakeDataService.getPosts(); + end = Instant.now(); + + // check that the delay was respected + Assert.assertNotNull(posts); + Assert.assertTrue((int) Duration.between(start, end).toMillis() >= delay); + } +} diff --git a/src/test/java/TestUtils.java b/src/test/java/TestUtils.java new file mode 100644 index 0000000..6c36c81 --- /dev/null +++ b/src/test/java/TestUtils.java @@ -0,0 +1,64 @@ +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.VCR; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableURL; + +import java.io.IOException; +import java.net.URL; + +public class TestUtils { + + public static final String cassetteFolder = "cassettes"; + + public static Cassette getCassette(String cassetteName) { + return new Cassette(cassetteFolder, cassetteName); + } + + public static RecordableHttpURLConnection getSimpleHttpURLConnection(String url, String cassetteName, Mode mode, AdvancedSettings advancedSettings) + throws IOException { + Cassette cassette = getCassette(cassetteName); + return new RecordableURL(new URL(url), cassette, mode, advancedSettings).openConnection(); + } + + public static RecordableHttpURLConnection getSimpleHttpURLConnection(String cassetteName, Mode mode, AdvancedSettings advancedSettings) + throws IOException { + return getSimpleHttpURLConnection(FakeDataService.GET_POSTS_URL, cassetteName, mode, advancedSettings); + } + + public static RecordableHttpsURLConnection getSimpleHttpsURLConnection(String url, String cassetteName, Mode mode, AdvancedSettings advancedSettings) + throws IOException { + Cassette cassette = getCassette(cassetteName); + return new RecordableURL(new URL(url), cassette, mode, advancedSettings).openConnectionSecure(); + } + + public static RecordableHttpsURLConnection getSimpleHttpsURLConnection(String cassetteName, Mode mode, AdvancedSettings advancedSettings) + throws IOException { + return getSimpleHttpsURLConnection(FakeDataService.GET_POSTS_URL, cassetteName, mode, advancedSettings); + } + + public static VCR getSimpleVCR(Mode mode) { + VCR vcr = new VCR(); + + switch (mode) { + case Record: + vcr.record(); + break; + case Replay: + vcr.replay(); + break; + case Bypass: + vcr.pause(); + break; + case Auto: + vcr.recordIfNeeded(); + break; + default: + break; + } + + return vcr; + } +} diff --git a/src/test/java/VCRTest.java b/src/test/java/VCRTest.java new file mode 100644 index 0000000..11e0dcb --- /dev/null +++ b/src/test/java/VCRTest.java @@ -0,0 +1,227 @@ +import com.easypost.easyvcr.AdvancedSettings; +import com.easypost.easyvcr.Cassette; +import com.easypost.easyvcr.Censors; +import com.easypost.easyvcr.MatchRules; +import com.easypost.easyvcr.Mode; +import com.easypost.easyvcr.Utilities; +import com.easypost.easyvcr.VCR; +import com.easypost.easyvcr.VCRException; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableHttpsURLConnection; +import com.easypost.easyvcr.clients.httpurlconnection.RecordableURL; +import org.junit.Assert; +import org.junit.Test; + +import java.net.MalformedURLException; + +public class VCRTest { + + + @Test + public void testClient() throws MalformedURLException, VCRException { + Cassette cassette = TestUtils.getCassette("test_vcr_client"); + VCR vcr = TestUtils.getSimpleVCR(Mode.Bypass); + vcr.insert(cassette); + + Assert.assertNotNull(vcr.getHttpUrlConnection("https://google.com")); + } + + @Test + public void testClientHandoff() throws Exception { + Cassette cassette = TestUtils.getCassette("test_vcr_mode_hand_off"); + VCR vcr = TestUtils.getSimpleVCR(Mode.Bypass); + vcr.insert(cassette); + + // test that we can still control the VCR even after it's been handed off to the service using it + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(vcr); + // Client should come from VCR, which has a client because it has a cassette. + Assert.assertNotNull(fakeDataService.getClient("https://google.com")); + + vcr.eject(); + // Should throw an exception because the VCR's cassette has been ejected (can't make a client without a cassette) + Assert.assertThrows(VCRException.class, () -> fakeDataService.getClient("https://google.com")); + } + + @Test + public void testClientNoCassette() { + VCR vcr = TestUtils.getSimpleVCR(Mode.Bypass); + // Should throw an exception because the VCR has no cassette (can't make a client without a cassette) + Assert.assertThrows(Exception.class, () -> vcr.getHttpUrlConnection("https://google.com")); + } + + @Test + public void testInsertCassette() { + Cassette cassette = TestUtils.getCassette("test_vcr_insert_cassette"); + VCR vcr = TestUtils.getSimpleVCR(Mode.Bypass); + vcr.insert(cassette); + Assert.assertEquals(cassette.name, vcr.getCassetteName()); + } + + @Test + public void testEjectCassette() { + Cassette cassette = TestUtils.getCassette("test_vcr_eject_cassette"); + VCR vcr = TestUtils.getSimpleVCR(Mode.Bypass); + vcr.insert(cassette); + Assert.assertNotNull(vcr.getCassetteName()); + vcr.eject(); + Assert.assertNull(vcr.getCassetteName()); + } + + @Test + public void testErase() throws Exception { + Cassette cassette = TestUtils.getCassette("test_vcr_eject_cassette"); + cassette.erase(); // make sure the cassette is empty + VCR vcr = TestUtils.getSimpleVCR(Mode.Record); + vcr.insert(cassette); + + // record a request to a cassette + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(vcr); + FakeDataService.Post[] posts = fakeDataService.getPosts(); + Assert.assertNotNull(posts); + Assert.assertTrue(cassette.numInteractions() > 0); + + // erase the cassette + vcr.erase(); + Assert.assertEquals(0, cassette.numInteractions()); + } + + @Test + public void testMode() { + Cassette cassette = TestUtils.getCassette("test_vcr_mode"); + VCR vcr = TestUtils.getSimpleVCR(Mode.Bypass); + Assert.assertEquals(Mode.Bypass, vcr.getMode()); + vcr.record(); + Assert.assertEquals(Mode.Record, vcr.getMode()); + vcr.replay(); + Assert.assertEquals(Mode.Replay, vcr.getMode()); + vcr.pause(); + Assert.assertEquals(Mode.Bypass, vcr.getMode()); + vcr.recordIfNeeded(); + Assert.assertEquals(Mode.Auto, vcr.getMode()); + } + + @Test + public void testRequest() throws Exception { + Cassette cassette = TestUtils.getCassette("test_vcr_record"); + VCR vcr = TestUtils.getSimpleVCR(Mode.Bypass); + vcr.insert(cassette); + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(vcr); + + FakeDataService.Post[] posts = fakeDataService.getPosts(); + Assert.assertNotNull(posts); + Assert.assertEquals(100, posts.length); + } + + @Test + public void testRecord() throws Exception { + Cassette cassette = TestUtils.getCassette("test_vcr_record"); + VCR vcr = TestUtils.getSimpleVCR(Mode.Record); + vcr.insert(cassette); + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(vcr); + + FakeDataService.Post[] posts = fakeDataService.getPosts(); + Assert.assertNotNull(posts); + Assert.assertEquals(100, posts.length); + Assert.assertTrue(cassette.numInteractions() > 0); + } + + @Test + public void testReplay() throws Exception { + Cassette cassette = TestUtils.getCassette("test_vcr_replay"); + VCR vcr = TestUtils.getSimpleVCR(Mode.Record); + vcr.insert(cassette); + FakeDataService.HttpsUrlConnection fakeDataService = new FakeDataService.HttpsUrlConnection(vcr); + + // record first + RecordableHttpsURLConnection response = (RecordableHttpsURLConnection) fakeDataService.getPostsRawResponse(); + Assert.assertTrue(cassette.numInteractions() > 0); // make sure we recorded something + // check that the response did not come from a recorded cassette + Assert.assertFalse(Utilities.responseCameFromRecording(response)); + + // now replay + vcr.replay(); + response = (RecordableHttpsURLConnection) fakeDataService.getPostsRawResponse(); + Assert.assertNotNull(response); + // check that the response came from a recorded cassette + Assert.assertTrue(Utilities.responseCameFromRecording(response)); + + // double check by erasing the cassette and trying to replay + vcr.erase(); + // should throw an exception because there's no matching interaction now + Assert.assertThrows(Exception.class, fakeDataService::getPosts); + } + + @Test + public void testCassetteName() { + String cassetteName = "test_vcr_cassette_name"; + Cassette cassette = TestUtils.getCassette(cassetteName); + VCR vcr = TestUtils.getSimpleVCR(Mode.Bypass); + vcr.insert(cassette); + + // make sure the cassette name is set correctly + Assert.assertEquals(cassetteName, vcr.getCassetteName()); + } + + @Test + public void testAdvancedSettings() throws Exception { + // we can assume that, if one test of advanced settings works for the VCR, + // that the advanced settings are being properly passed to the cassette + // refer to ClientTest.cs for individual per-settings tests + + String censorString = "censored-by-test"; + + AdvancedSettings advancedSettings = new AdvancedSettings(); + advancedSettings.censors = new Censors(censorString).hideHeader("Date"); + advancedSettings.matchRules = new MatchRules().byMethod().byFullUrl().byBody(); + + VCR vcr = new VCR(advancedSettings); + + // test that the advanced settings are applied inside the VCR + Assert.assertEquals(advancedSettings, vcr.getAdvancedSettings()); + + // test that the advanced settings are passed to the cassette by checking if censor is applied + Cassette cassette = TestUtils.getCassette("test_vcr_advanced_settings"); + vcr.insert(cassette); + vcr.erase(); // erase before recording + + // record first + vcr.record(); + RecordableURL client = vcr.getHttpUrlConnection(FakeDataService.GET_POSTS_URL); + FakeDataService.HttpsUrlConnection fakeDataService = + new FakeDataService.HttpsUrlConnection(client.openConnectionSecure()); + FakeDataService.Post[] posts = fakeDataService.getPosts(); + + // now replay and confirm that the censor is applied + vcr.replay(); + // changing the VCR settings won't affect a client after it's been grabbed from the VCR + // so, we need to re-grab the VCR client and re-create the FakeDataService + client = vcr.getHttpUrlConnection(FakeDataService.GET_POSTS_URL); + fakeDataService = new FakeDataService.HttpsUrlConnection(client.openConnectionSecure()); + RecordableHttpsURLConnection response = (RecordableHttpsURLConnection) fakeDataService.getPostsRawResponse(); + + // check that the censor is applied + Assert.assertNotNull(response); + Assert.assertNotNull(response.getHeaderField("Date")); + String censoredHeader = response.getHeaderField("Date"); + Assert.assertNotNull(censoredHeader); + Assert.assertEquals(censoredHeader, censorString); + } + + @Test + public void testCassetteSwap() { + String cassette1Name = "test_vcr_cassette_swap_1"; + String cassette2Name = "test_vcr_cassette_swap_2"; + + VCR vcr = new VCR(); + + Cassette cassette = TestUtils.getCassette(cassette1Name); + vcr.insert(cassette); + Assert.assertEquals(cassette1Name, vcr.getCassetteName()); + + vcr.eject(); + Assert.assertNull(vcr.getCassetteName()); + + cassette = TestUtils.getCassette(cassette2Name); + vcr.insert(cassette); + Assert.assertEquals(cassette2Name, vcr.getCassetteName()); + } +} diff --git a/style_suppressions.xml b/style_suppressions.xml new file mode 100644 index 0000000..dad3232 --- /dev/null +++ b/style_suppressions.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + +