Skip to content

Commit

Permalink
- Rename some elements
Browse files Browse the repository at this point in the history
- Finish README documentation
- Improve imports
- Include unit tests in Dart published file (per Dart's rec: https://stackoverflow.com/a/69767697/13343799)
  • Loading branch information
nwithan8 committed Dec 12, 2022
1 parent 7eb6f2e commit b0db4db
Show file tree
Hide file tree
Showing 9 changed files with 267 additions and 127 deletions.
176 changes: 159 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,170 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
# DartVCR

For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/guides/libraries/writing-package-pages).
[![Pub](https://img.shields.io/pub/v/dartvcr)](https://pub.dev/packages/dartvcr)

For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-library-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/developing-packages).
-->
DartVCR is a library for recording and replaying HTTP interactions in your test suite. Port of [EasyVCR](https://github.com/EasyPost/easyvcr-csharp) for Dart.

A Dart package for recording and replaying HTTP interactions. Useful for testing, mocking, and more.
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.

Port of EasyVCR.
## How to use DartVCR

#### Step 1.

Run your test suite locally against a real HTTP endpoint in recording mode

```dart
import 'package:dartvcr/dartvcr.dart';
// Create a cassette to handle HTTP interactions
var cassette = Cassette("path/to/cassettes", "my_cassette");
// create an DartVCRClient using the cassette
DartVCRClient client = DartVCRClient(cassette, Mode.record);
// Use this DartVCRClient in any class making HTTP calls
// Note: DartVCRClient extends BaseClient from the 'http/http' package, so it can be used anywhere a BaseClient is expected
var response = await client.post(Uri.parse('https://api.example.com/v1/users'));
```

Real HTTP calls will be made and recorded to the cassette file.

#### Step 2.

Switch to replay mode:

```dart
import 'package:dartvcr/dartvcr.dart';
// Create a cassette to handle HTTP interactions
var cassette = Cassette("path/to/cassettes", "my_cassette");
// create an DartVCRClient using the cassette
DartVCRClient client = DartVCRClient(cassette, Mode.replay);
```

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 BaseClient).

## Features

To come.
`DartVCR` 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.

NOTE: This feature currently only works on JSON response bodies.

**Default**: *Disabled*

```dart
import 'package:dartvcr/dartvcr.dart';
var cassette = Cassette("path/to/cassettes", "my_cassette");
var censors = Censors().censorHeaderElementsByKeys(["authorization"]); // Hide the Authorization header
censors.censorBodyElements([CensorElement("table", caseSensitive: true)]); // Hide the table element (case sensitive) in the request and response body
var advancedOptions = AdvancedOptions(censors: censors);
var client = DartVCRClient(cassette, Mode.record, advancedOptions: advancedOptions);
```

### Delay

Simulate a delay when replaying a recorded request, either using a specified delay or the original request duration.

NOTE: Delays may suffer from a small margin of error. Do not rely on the delay being exact down to the millisecond.

**Default**: *No delay*

```dart
import 'package:dartvcr/dartvcr.dart';
var cassette = Cassette("path/to/cassettes", "my_cassette");
// Simulate a delay of the original request duration when replaying (overrides ManualDelay)
// Simulate a delay of 1000 milliseconds when replaying
var advancedOptions = AdvancedOptions(simulateDelay: true, manualDelay: 1000);
var client = DartVCRClient(cassette, Mode.replay, advancedOptions: advancedOptions);
```

### Expiration

Set expiration dates for recorded requests, and decide what to do with expired recordings.

**Default**: *No expiration*

```dart
import 'package:dartvcr/dartvcr.dart';
var cassette = Cassette("path/to/cassettes", "my_cassette");
// Any matching request is considered expired if it was recorded more than 30 days ago
// Throw exception if the recording is expired
var advancedOptions = AdvancedOptions(validTimeFrame: TimeFrame(days: 30), whenExpired: ExpirationAction.throwException);
var client = DartVCRClient(cassette, Mode.replay, advancedOptions: advancedOptions);
```

### Matching

Customize how a recorded request is determined to be a match to the current request.

**Default**: *Method and full URL must match*

```dart
import 'package:dartvcr/dartvcr.dart';
var cassette = Cassette("path/to/cassettes", "my_cassette");
// Match recorded requests by body and a specific header
var matchRules = MatchRules().byBody().byHeader("x-my-header");
var advancedOptions = AdvancedOptions(matchRules: matchRules);
var client = DartVCRClient(cassette, Mode.replay, advancedOptions: advancedOptions);
```

## VCR

In addition to individual recordable HttpClient instances, `DartVCR` 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 HttpClient.

```dart
import 'package:dartvcr/dartvcr.dart';
// hide the api_key query parameter
var advancedOptions = AdvancedOptions(censors: Censors().censorQueryElementsByKeys(["api_key"]));
// create a VCR with the advanced options applied
var vcr = VCR(advancedOptions: advancedOptions);
// create a cassette and add it to the VCR
var cassette = Cassette("path/to/cassettes", "my_cassette");
vcr.insert(cassette);
// set the VCR to record mode
vcr.record();
// get a client configured to use the VCR
var client = vcr.client;
## Getting started
// make a request
To come.
// remove the cassette from the VCR
vcr.eject();
```

## Usage
#### Credit

To come.
- [EasyVCR by EasyPost](https://github.com/easypost/easyvcr-csharp), which this library is based on.
15 changes: 12 additions & 3 deletions lib/dartvcr.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
/// Support for doing something awesome.
///
/// More dartdocs go here.
library dartvcr;

// TODO: Export any libraries intended for clients of this package.
export 'src/advanced_options.dart';
export 'src/cassette.dart';
export 'src/censor_element.dart';
export 'src/censors.dart';
export 'src/dartvcr_client.dart';
export 'src/expiration_actions.dart';
export 'src/match_rules.dart';
export 'src/mode.dart';
export 'src/time_frame.dart';
export 'src/utilities.dart';
export 'src/vcr.dart';
export 'src/vcr_exception.dart';
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import 'package:dartvcr/src/time_frame.dart';
import 'censors.dart';
import 'expiration_actions.dart';

class AdvancedSettings {
class AdvancedOptions {
final Censors censors;

final MatchRules matchRules;
Expand All @@ -17,7 +17,7 @@ class AdvancedSettings {

final ExpirationAction whenExpired;

AdvancedSettings(
AdvancedOptions(
{Censors? censors,
MatchRules? matchRules,
int? manualDelay,
Expand Down
38 changes: 19 additions & 19 deletions lib/src/easyvcr_client.dart → lib/src/dartvcr_client.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'package:dartvcr/src/advanced_settings.dart';
import 'package:dartvcr/src/advanced_options.dart';
import 'package:dartvcr/src/defaults.dart';
import 'package:dartvcr/src/expiration_actions.dart';
import 'package:dartvcr/src/request_elements/http_interaction.dart';
Expand All @@ -10,19 +10,19 @@ import 'package:http/http.dart' as http;
import 'cassette.dart';
import 'mode.dart';

class EasyVCRClient extends http.BaseClient {
class DartVCRClient extends http.BaseClient {
final http.Client _client;

final Cassette _cassette;

final Mode _mode;

final AdvancedSettings _advancedSettings;
final AdvancedOptions _advancedOptions;

EasyVCRClient(this._cassette, this._mode,
{AdvancedSettings? advancedSettings})
DartVCRClient(this._cassette, this._mode,
{AdvancedOptions? advancedOptions})
: _client = http.Client(),
_advancedSettings = advancedSettings ?? AdvancedSettings();
_advancedOptions = advancedOptions ?? AdvancedOptions();

@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
Expand All @@ -38,9 +38,9 @@ class EasyVCRClient extends http.BaseClient {
throw VCRException(
"No matching interaction found for request ${request.method} ${request.url}");
}
if (_advancedSettings.validTimeFrame
if (_advancedOptions.validTimeFrame
.hasLapsed(replayInteraction.recordedAt)) {
switch (_advancedSettings.whenExpired) {
switch (_advancedOptions.whenExpired) {
case ExpirationAction.warn:
// just throw a warning
// will still simulate delay below
Expand All @@ -59,17 +59,17 @@ class EasyVCRClient extends http.BaseClient {
// simulate delay if configured
await _simulateDelay(replayInteraction);
// return matching interaction's response
return replayInteraction.toStreamedResponse(_advancedSettings.censors);
return replayInteraction.toStreamedResponse(_advancedOptions.censors);

case Mode.auto:
// try to get recorded request, fallback to live request + record
HttpInteraction? replayInteraction = _findMatchingInteraction(request);
if (replayInteraction != null) {
// found a matching interaction
if (_advancedSettings.validTimeFrame
if (_advancedOptions.validTimeFrame
.hasLapsed(replayInteraction.recordedAt)) {
// interaction has expired
switch (_advancedSettings.whenExpired) {
switch (_advancedOptions.whenExpired) {
case ExpirationAction.warn:
// just throw a warning
// will still simulate delay below
Expand All @@ -88,7 +88,7 @@ class EasyVCRClient extends http.BaseClient {
await _simulateDelay(replayInteraction);
// return matching interaction's response
return replayInteraction
.toStreamedResponse(_advancedSettings.censors);
.toStreamedResponse(_advancedOptions.censors);
}

// no matching interaction found, make real request, record response
Expand All @@ -104,10 +104,10 @@ class EasyVCRClient extends http.BaseClient {
List<HttpInteraction> interactions = _cassette.read();

Request receivedRequest =
Request.fromHttpRequest(request, _advancedSettings.censors);
Request.fromHttpRequest(request, _advancedOptions.censors);

try {
return interactions.firstWhere((interaction) => _advancedSettings
return interactions.firstWhere((interaction) => _advancedOptions
.matchRules
.requestsMatch(receivedRequest, interaction.request));
} catch (e) {
Expand All @@ -122,27 +122,27 @@ class EasyVCRClient extends http.BaseClient {
http.StreamedResponse streamedResponse = await _client.send(request);
stopwatch.stop();
http.Response response =
await Response.fromStream(streamedResponse, _advancedSettings.censors);
await Response.fromStream(streamedResponse, _advancedOptions.censors);
HttpInteraction interaction =
HttpInteraction.fromHttpResponse(response, _advancedSettings.censors);
HttpInteraction.fromHttpResponse(response, _advancedOptions.censors);
interaction.duration = stopwatch
.elapsedMilliseconds; // add duration to interaction before saving
interaction.response.headers.addAll(
replayHeaders); // add replay headers to interaction before saving
_cassette.update(interaction);

// need to rebuild a new streamedResponse since this one has already been read
return Response.toStream(response, _advancedSettings.censors);
return Response.toStream(response, _advancedOptions.censors);
}

Future _simulateDelay(HttpInteraction interaction) async {
int delay = 0;
if (_advancedSettings.simulateDelay == true) {
if (_advancedOptions.simulateDelay == true) {
// original delay takes precedence
delay = interaction.duration;
} else {
// otherwise use manual delay
delay = _advancedSettings.manualDelay;
delay = _advancedOptions.manualDelay;
}
await Future.delayed(Duration(milliseconds: delay));
}
Expand Down
2 changes: 1 addition & 1 deletion lib/src/defaults.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const String viaRecordingHeaderKey = "X-Via-EasyVCR-Recording";
const String viaRecordingHeaderKey = "X-Via-DartVCR-Recording";

Map<String, String> get replayHeaders => {
viaRecordingHeaderKey: "true",
Expand Down
14 changes: 7 additions & 7 deletions lib/src/vcr.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'package:dartvcr/src/advanced_settings.dart';
import 'package:dartvcr/src/easyvcr_client.dart';
import 'package:dartvcr/src/advanced_options.dart';
import 'package:dartvcr/src/dartvcr_client.dart';

import 'cassette.dart';
import 'mode.dart';
Expand All @@ -9,17 +9,17 @@ class VCR {

Mode mode;

AdvancedSettings? advancedSettings;
AdvancedOptions? advancedOptions;

VCR({this.advancedSettings})
VCR({this.advancedOptions})
: mode = Mode.bypass;

String? get cassetteName => _currentCassette?.name;

EasyVCRClient get client => _currentCassette == null
DartVCRClient get client => _currentCassette == null
? throw Exception('No cassette is loaded')
: EasyVCRClient(_currentCassette!, mode,
advancedSettings: advancedSettings ?? AdvancedSettings());
: DartVCRClient(_currentCassette!, mode,
advancedOptions: advancedOptions ?? AdvancedOptions());

void eject() {
_currentCassette = null;
Expand Down
Loading

0 comments on commit b0db4db

Please sign in to comment.