diff --git a/lib/src/advanced_options.dart b/lib/src/advanced_options.dart index bff3c0c..a1630d9 100644 --- a/lib/src/advanced_options.dart +++ b/lib/src/advanced_options.dart @@ -4,19 +4,28 @@ import 'package:dartvcr/src/time_frame.dart'; import 'censors.dart'; import 'expiration_actions.dart'; +/// A collection of configuration options that can be used when recording and replaying requests. class AdvancedOptions { + /// A collection of censor rules that will be applied to the request and response bodies. final Censors censors; + /// The rules that will be used to match requests to recorded requests. final MatchRules matchRules; + /// The number of milliseconds to delay before returning a response. final int manualDelay; + /// If true, a replayed request will be delayed by the same amount of time it took to make the original live request. final bool simulateDelay; + /// The time frame during which a request can be replayed. + /// If the request is replayed outside of this time frame, the [whenExpired] action will be taken. final TimeFrame validTimeFrame; + /// The action to take when a request is replayed outside of the [validTimeFrame]. final ExpirationAction whenExpired; + /// Creates a new [AdvancedOptions]. AdvancedOptions( {Censors? censors, MatchRules? matchRules, diff --git a/lib/src/cassette.dart b/lib/src/cassette.dart index 7f839e5..5cdb77f 100644 --- a/lib/src/cassette.dart +++ b/lib/src/cassette.dart @@ -3,17 +3,24 @@ import 'dart:io'; import 'request_elements/http_interaction.dart'; +/// A class representing a cassette that contains a list of [HttpInteraction]s. class Cassette { + /// The path to the cassette file. final String _filePath; + + /// The name of the cassette. final String name; + /// Whether or not the cassette is locked (i.e. being written to). bool _locked = false; - Cassette(folderPath, this.name) - : _filePath = '$folderPath/$name.json'; + /// Creates a new [Cassette] with the given [name] and [folderPath]. + Cassette(folderPath, this.name) : _filePath = '$folderPath/$name.json'; + /// Returns the number of interactions in the cassette. int get numberOfInteractions => read().length; + /// Returns a list of all [HttpInteraction]s in the cassette. List read() { List interactions = []; @@ -33,6 +40,7 @@ class Cassette { return interactions; } + /// Adds or overwrites the given [interaction] to the cassette. void update(HttpInteraction interaction) { File file = File(_filePath); if (!file.existsSync()) { @@ -48,6 +56,7 @@ class Cassette { File(_filePath).writeAsStringSync(jsonEncode(interactions)); } + /// Deletes the cassette file. void erase() { File file = File(_filePath); if (file.existsSync()) { @@ -55,19 +64,23 @@ class Cassette { } } + /// Returns true if the cassette file exists. bool _exists() { File file = File(_filePath); return file.existsSync(); } + /// Locks the cassette so that it cannot be written to. void lock() { _locked = true; } + /// Unlocks the cassette so that it can be written to. void unlock() { _locked = false; } + /// Check if the cassette can be written to. void _preWriteCheck() { if (_locked) { throw Exception('Cassette $name is locked'); diff --git a/lib/src/censor_element.dart b/lib/src/censor_element.dart index d161359..5048e43 100644 --- a/lib/src/censor_element.dart +++ b/lib/src/censor_element.dart @@ -1,10 +1,15 @@ +/// A censor element is a single element to censor from requests and responses. class CensorElement { + /// The name or key of the element to censor. final String name; + /// Whether or not the element should be censored only when matching case exactly. final bool caseSensitive; + /// Creates a new [CensorElement] with the given [name] and [caseSensitive] flag. CensorElement(this.name, {this.caseSensitive = false}); + /// Returns true if the given [key] matches this element. bool matches(String key) { if (caseSensitive) { return key == name; diff --git a/lib/src/censors.dart b/lib/src/censors.dart index 2b52139..0a82b38 100644 --- a/lib/src/censors.dart +++ b/lib/src/censors.dart @@ -6,57 +6,73 @@ import 'package:dartvcr/src/vcr_exception.dart'; import 'censor_element.dart'; import 'internal_utilities/content_type.dart'; +/// A class representing a set of rules to censor elements from requests and responses. class Censors { + /// The string to replace censored elements with. final String _censorString; + /// The list of elements to censor from request bodies. final List _bodyElementsToCensor; + /// The list of elements to censor from request headers. final List _headerElementsToCensor; + /// The list of elements to censor from request query parameters. final List _queryElementsToCensor; + /// Creates a new [Censors] with the given [censorString]. Censors({String censorString = "******"}) : _censorString = censorString, _bodyElementsToCensor = [], _headerElementsToCensor = [], _queryElementsToCensor = []; + /// Add the given [element] to the list of elements to censor from request bodies. Censors censorBodyElements(List elements) { _bodyElementsToCensor.addAll(elements); return this; } + /// Add the given [keys] to the list of elements to censor from request bodies. + /// If [caseSensitive] is true, the keys will be censored only when matching case exactly. Censors censorBodyElementsByKeys(List keys, {bool caseSensitive = false}) { - _bodyElementsToCensor - .addAll(keys.map((key) => CensorElement(key, caseSensitive: caseSensitive))); + _bodyElementsToCensor.addAll( + keys.map((key) => CensorElement(key, caseSensitive: caseSensitive))); return this; } + /// Add the given [element] to the list of elements to censor from request headers. Censors censorHeaderElements(List elements) { _headerElementsToCensor.addAll(elements); return this; } + /// Add the given [keys] to the list of elements to censor from request headers. + /// If [caseSensitive] is true, the keys will be censored only when matching case exactly. Censors censorHeaderElementsByKeys(List keys, {bool caseSensitive = false}) { - _headerElementsToCensor - .addAll(keys.map((key) => CensorElement(key, caseSensitive: caseSensitive))); + _headerElementsToCensor.addAll( + keys.map((key) => CensorElement(key, caseSensitive: caseSensitive))); return this; } + /// Add the given [element] to the list of elements to censor from request query parameters. Censors censorQueryElements(List elements) { _queryElementsToCensor.addAll(elements); return this; } + /// Add the given [keys] to the list of elements to censor from request query parameters. + /// If [caseSensitive] is true, the keys will be censored only when matching case exactly. Censors censorQueryElementsByKeys(List keys, {bool caseSensitive = false}) { - _queryElementsToCensor - .addAll(keys.map((key) => CensorElement(key, caseSensitive: caseSensitive))); + _queryElementsToCensor.addAll( + keys.map((key) => CensorElement(key, caseSensitive: caseSensitive))); return this; } + /// Applies the body parameter censors to the given [body] with the given [contentType]. // TODO: Only works on JSON bodies String applyBodyParameterCensors(String body, ContentType contentType) { if (body.isEmpty) { @@ -87,6 +103,7 @@ class Censors { } } + /// Removes all null values from the given [map]. Map _removeNullValuesFromMap(Map map) { Map result = {}; map.forEach((key, value) { @@ -97,6 +114,7 @@ class Censors { return result; } + /// Applies the header censors to the given [headers]. Map applyHeaderCensors(Map? headers) { if (headers == null || headers.isEmpty) { // short circuit if headers is empty @@ -112,7 +130,6 @@ class Censors { headers.forEach((key, value) { if (value == null) { - } else if (elementShouldBeCensored(key, _headerElementsToCensor)) { newHeaders[key] = _censorString; } else { @@ -123,6 +140,7 @@ class Censors { return newHeaders; } + /// Applies the query parameter censors to the given [url]. String applyQueryCensors(String url) { if (_queryElementsToCensor.isEmpty) { // short circuit if there are no censors to apply @@ -151,6 +169,7 @@ class Censors { ).toString(); } + /// Censors the given [elementsToCensor] in the given [body] with the given [censorString]. static String censorJsonData( String body, String censorString, List elementsToCensor) { try { @@ -172,8 +191,9 @@ class Censors { } } - static Map censorMap(Map map, String censorString, - List elementsToCensor) { + /// Censors the given [elementsToCensor] in the given [map] with the given [censorString]. + static Map censorMap(Map map, + String censorString, List elementsToCensor) { if (elementsToCensor.isEmpty) { // short circuit if there are no censors to apply return map; @@ -216,6 +236,7 @@ class Censors { return censoredMap; } + /// Censors the given [elementsToCensor] in the given [list] with the given [censorString]. static List censorList(List list, String censorString, List elementsToCensor) { if (elementsToCensor.isEmpty) { @@ -241,12 +262,14 @@ class Censors { return censoredList; } + /// Returns true if the given [foundKey] exists in the given [elementsToCensor] list. static bool elementShouldBeCensored( String foundKey, List elementsToCensor) { return elementsToCensor.isNotEmpty && elementsToCensor.any((element) => element.matches(foundKey)); } + /// A pre-configured instance of [Censors] that censors nothing. static Censors get defaultCensors { return Censors(); } diff --git a/lib/src/dartvcr_client.dart b/lib/src/dartvcr_client.dart index 04ab4ce..d41abaf 100644 --- a/lib/src/dartvcr_client.dart +++ b/lib/src/dartvcr_client.dart @@ -10,20 +10,30 @@ import 'package:http/http.dart' as http; import 'cassette.dart'; import 'mode.dart'; +/// A [http.BaseClient] that records and replays HTTP interactions. class DartVCRClient extends http.BaseClient { + /// The internal [http.Client] that will be used to make requests. final http.Client _client; + /// The [Cassette] the client will use during requests. final Cassette _cassette; + /// The [Mode] the client will use during requests. final Mode _mode; + /// The [AdvancedOptions] the client will use during requests. final AdvancedOptions _advancedOptions; - DartVCRClient(this._cassette, this._mode, - {AdvancedOptions? advancedOptions}) + /// Creates a new [DartVCRClient] with the given [Cassette], [Mode] and [AdvancedOptions]. + DartVCRClient(this._cassette, this._mode, {AdvancedOptions? advancedOptions}) : _client = http.Client(), _advancedOptions = advancedOptions ?? AdvancedOptions(); + /// Simulates an HTTP request and response. + /// Makes a real request and records the response if the [Mode] is [Mode.record]. + /// Drops the request and returns a recorded response if the [Mode] is [Mode.replay]. + /// Makes a real request and returns the real response if the [Mode] is [Mode.bypass]. + /// Either makes a real request and records the response, or drops the request and returns a recorded response if the [Mode] is [Mode.auto]. @override Future send(http.BaseRequest request) async { switch (_mode) { @@ -87,8 +97,7 @@ class DartVCRClient extends http.BaseClient { // simulate delay if configured await _simulateDelay(replayInteraction); // return matching interaction's response - return replayInteraction - .toStreamedResponse(_advancedOptions.censors); + return replayInteraction.toStreamedResponse(_advancedOptions.censors); } // no matching interaction found, make real request, record response @@ -100,6 +109,7 @@ class DartVCRClient extends http.BaseClient { } } + /// Finds a matching recorded [HttpInteraction] for the given [http.BaseRequest]. HttpInteraction? _findMatchingInteraction(http.BaseRequest request) { List interactions = _cassette.read(); @@ -115,6 +125,7 @@ class DartVCRClient extends http.BaseClient { } } + /// Makes a real request and records the response. Future _recordRequestAndResponse( http.BaseRequest request) async { Stopwatch stopwatch = Stopwatch(); @@ -135,6 +146,7 @@ class DartVCRClient extends http.BaseClient { return Response.toStream(response, _advancedOptions.censors); } + /// Simulates an HTTP delay if configured to do so. Future _simulateDelay(HttpInteraction interaction) async { int delay = 0; if (_advancedOptions.simulateDelay == true) { diff --git a/lib/src/defaults.dart b/lib/src/defaults.dart index 7243cb5..fc3ebb0 100644 --- a/lib/src/defaults.dart +++ b/lib/src/defaults.dart @@ -1,14 +1,17 @@ const String viaRecordingHeaderKey = "X-Via-DartVCR-Recording"; +/// A set of default headers that will be added to all requests replayed by the VCR. Map get replayHeaders => { viaRecordingHeaderKey: "true", }; +/// A set of common credential headers that will be hidden from recordings. List get credentialHeadersToHide => [ "authorization", "cookie", ]; +/// A set of common credential parameters that will be hidden from recordings. List get credentialParametersToHide => [ "access_token", "client_id", diff --git a/lib/src/expiration_actions.dart b/lib/src/expiration_actions.dart index 86f147d..34bd708 100644 --- a/lib/src/expiration_actions.dart +++ b/lib/src/expiration_actions.dart @@ -2,9 +2,13 @@ import 'package:dartvcr/src/vcr_exception.dart'; import 'mode.dart'; +/// The various actions that can be taken when an interaction expires. enum ExpirationAction { + /// warn: The VCR will log a warning to the console. warn, + /// throwException: The VCR will throw an exception. throwException, + /// recordAgain: The VCR will silently re-record the interaction. recordAgain, } diff --git a/lib/src/match_rules.dart b/lib/src/match_rules.dart index 4ba3316..ff1555d 100644 --- a/lib/src/match_rules.dart +++ b/lib/src/match_rules.dart @@ -5,20 +5,25 @@ import 'package:dartvcr/src/censors.dart'; import 'censor_element.dart'; +/// A class representing a set of rules to determine if two requests match. class MatchRules { - // store list of functions + /// The list of rules that will be used to match requests. final List _rules; + /// Creates a new [MatchRules]. MatchRules() : _rules = []; + /// A pre-configured set of rules that will match requests based on the full URL and method. static MatchRules get defaultMatchRules { return MatchRules().byMethod().byFullUrl(preserveQueryOrder: false); } + /// A pre-configured set of rules that will match requests based on the full URL, method, and body. static MatchRules get defaultStrictMatchRules { return MatchRules().byMethod().byFullUrl(preserveQueryOrder: true).byBody(); } + /// Returns true if the given [received] request matches the given [recorded] request based on the configured rules. bool requestsMatch(Request received, Request recorded) { if (_rules.isEmpty) { return true; @@ -32,6 +37,7 @@ class MatchRules { return true; } + /// Enforces that both requests have the base URL (host). MatchRules byBaseUrl() { _by((Request received, Request recorded) { return received.uri.host == recorded.uri.host; @@ -39,6 +45,9 @@ class MatchRules { return this; } + /// Enforces that both requests have the same full URL. + /// If [preserveQueryOrder] is true, the order of the query parameters must match as well. + /// If [preserveQueryOrder] is false, the order of the query parameters is ignored. MatchRules byFullUrl({bool preserveQueryOrder = false}) { _by((Request received, Request recorded) { if (preserveQueryOrder) { @@ -66,6 +75,7 @@ class MatchRules { return this; } + /// Enforces that both requests have the same HTTP method. MatchRules byMethod() { _by((Request received, Request recorded) { return received.method == recorded.method; @@ -73,6 +83,8 @@ class MatchRules { return this; } + /// Enforces that both requests have the same body. + /// Ignore specific [ignoreElements] in the body when comparing. MatchRules byBody({List ignoreElements = const []}) { _by((Request received, Request recorded) { if (received.body == null && recorded.body == null) { @@ -96,6 +108,7 @@ class MatchRules { return this; } + /// Enforces that both requests are the exact same (headers, body, etc.). MatchRules byEverything() { _by((Request received, Request recorded) { String receivedRequest = jsonEncode(received); @@ -105,6 +118,7 @@ class MatchRules { return this; } + /// Enforces that both requests have the same header with the given [headerKey]. MatchRules byHeader(String headerKey) { _by((Request received, Request recorded) { if (received.headers.containsKey(headerKey) && @@ -117,8 +131,9 @@ class MatchRules { return this; } - // 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. + /// Enforces that both requests have the same headers. + /// If [exact] is true, then both requests must have the exact same headers. + /// If [exact] is false, then as long as the evaluated request has all the headers of the matching request (and potentially more), the match is considered valid. MatchRules byHeaders(bool exact) { if (exact) { // first, we'll check that there are the same number of headers in both requests. If they're are, then the second check is guaranteed to compare all headers. @@ -139,6 +154,7 @@ class MatchRules { return this; } + /// Enforces that both requests match the given [rule]. void _by(MatchRule rule) { _rules.add(rule); } diff --git a/lib/src/mode.dart b/lib/src/mode.dart index b599ab9..bec428d 100644 --- a/lib/src/mode.dart +++ b/lib/src/mode.dart @@ -1,6 +1,14 @@ +/// The various modes that the VCR can be in. enum Mode { + /// The VCR will automatically play back a cassette if it exists, otherwise it will record a new cassette. auto, + + /// The VCR will record a new cassette. If a cassette already exists, it will be overwritten. record, + + /// The VCR will play back a cassette. If a cassette does not exist, an exception will be thrown. replay, + + /// The VCR will not record or play back a cassette. It will make a live request to the server. bypass } diff --git a/lib/src/time_frame.dart b/lib/src/time_frame.dart index baeb96f..c64f3f8 100644 --- a/lib/src/time_frame.dart +++ b/lib/src/time_frame.dart @@ -1,15 +1,23 @@ -enum CommonTimeFrame { - never, - forever -} +enum CommonTimeFrame { never, forever } +/// A class representing a time frame. class TimeFrame { + /// The number of days in the time frame. final int days; + + /// The number of hours in the time frame. final int hours; + + /// The number of minutes in the time frame. final int minutes; + + /// The number of seconds in the time frame. final int seconds; + + /// A common time frame. final CommonTimeFrame? _commonTimeFrame; + /// Creates a new [TimeFrame] with the given [days], [hours], [minutes] and [seconds], or [commonTimeFrame]. TimeFrame({ this.days = 0, this.hours = 0, @@ -18,11 +26,13 @@ class TimeFrame { CommonTimeFrame? commonTimeFrame, }) : _commonTimeFrame = commonTimeFrame; + /// Returns true if the given [fromTime] is before the time frame (has lapsed). bool hasLapsed(DateTime fromTime) { DateTime startTimePlusFrame = _timePlusFrame(fromTime); return startTimePlusFrame.isBefore(DateTime.now()); } + /// Calculate the [DateTime] that is the given [fromTime] plus the time frame. DateTime _timePlusFrame(DateTime fromTime) { if (_commonTimeFrame == CommonTimeFrame.forever) { return DateTime(9999); // will always be in the future @@ -37,18 +47,26 @@ class TimeFrame { )); } - static TimeFrame get never => TimeFrame(commonTimeFrame: CommonTimeFrame.never); + /// A common time frame that will always be in the past. + static TimeFrame get never => + TimeFrame(commonTimeFrame: CommonTimeFrame.never); - static TimeFrame get forever => TimeFrame(commonTimeFrame: CommonTimeFrame.forever); + /// A common time frame that will always be in the future. + static TimeFrame get forever => + TimeFrame(commonTimeFrame: CommonTimeFrame.forever); + /// A common time frame that is 1 calendar month (30 days). static TimeFrame get months1 => TimeFrame(days: 30); + /// A common time frame that is 2 calendar months (61 days). static TimeFrame get months2 => TimeFrame(days: 61); + /// A common time frame that is 3 calendar months (91 days). static TimeFrame get months3 => TimeFrame(days: 91); + /// A common time frame that is 6 calendar months (182 days). static TimeFrame get months6 => TimeFrame(days: 182); + /// A common time frame that is 12 calendar months (365 days). static TimeFrame get months12 => TimeFrame(days: 365); } - diff --git a/lib/src/utilities.dart b/lib/src/utilities.dart index a8e3204..024ddad 100644 --- a/lib/src/utilities.dart +++ b/lib/src/utilities.dart @@ -2,14 +2,17 @@ import 'package:dartvcr/src/defaults.dart'; import 'package:http/http.dart' as http; +/// Returns true if the given [content] is a JSON map. bool isJsonMap(dynamic content) { return content is Map; } +/// Returns true if the given [content] is a JSON list. bool isJsonList(dynamic content) { return content is List; } +/// Returns true if the given [response] is a JSON string. bool responseCameFromRecording(http.Response response) { return response.headers.containsKey(viaRecordingHeaderKey); } diff --git a/lib/src/vcr.dart b/lib/src/vcr.dart index d7af6c6..5c78cf8 100644 --- a/lib/src/vcr.dart +++ b/lib/src/vcr.dart @@ -4,7 +4,7 @@ import 'package:dartvcr/src/dartvcr_client.dart'; import 'cassette.dart'; import 'mode.dart'; -/// A VCR can store consistent settings and control a cassette. +/// A [VCR] can store consistent settings and control a cassette. class VCR { /// The cassette that is currently being used. Cassette? _currentCassette;