diff --git a/lib/dartvcr.dart b/lib/dartvcr.dart index 93646b8..bbddca4 100644 --- a/lib/dartvcr.dart +++ b/lib/dartvcr.dart @@ -1,3 +1,4 @@ +/// DartVCR is a library that allows you to record and replay HTTP interactions library dartvcr; export 'src/advanced_options.dart'; diff --git a/lib/src/advanced_options.dart b/lib/src/advanced_options.dart index a1630d9..bd7ce59 100644 --- a/lib/src/advanced_options.dart +++ b/lib/src/advanced_options.dart @@ -19,6 +19,7 @@ class AdvancedOptions { 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; @@ -26,6 +27,16 @@ class AdvancedOptions { final ExpirationAction whenExpired; /// Creates a new [AdvancedOptions]. + /// + /// ```dart + /// final options = AdvancedOptions( + /// censors: Censors.defaultCensors, + /// matchRules: MatchRules.defaultMatchRules, + /// manualDelay: 0, + /// simulateDelay: false, + /// validTimeFrame: TimeFrame.forever, + /// whenExpired: ExpirationAction.warn, + /// ); AdvancedOptions( {Censors? censors, MatchRules? matchRules, diff --git a/lib/src/cassette.dart b/lib/src/cassette.dart index 5cdb77f..d9f44bc 100644 --- a/lib/src/cassette.dart +++ b/lib/src/cassette.dart @@ -15,6 +15,10 @@ class Cassette { bool _locked = false; /// Creates a new [Cassette] with the given [name] and [folderPath]. + /// + /// ```dart + /// Cassette cassette = Cassette('my_cassette', 'cassettes'); + /// ``` Cassette(folderPath, this.name) : _filePath = '$folderPath/$name.json'; /// Returns the number of interactions in the cassette. diff --git a/lib/src/censor_element.dart b/lib/src/censor_element.dart index 5048e43..ededdc2 100644 --- a/lib/src/censor_element.dart +++ b/lib/src/censor_element.dart @@ -7,9 +7,23 @@ class CensorElement { final bool caseSensitive; /// Creates a new [CensorElement] with the given [name] and [caseSensitive] flag. + /// + /// ```dart + /// CensorElement element = CensorElement('key', caseSensitive: true); + /// ``` CensorElement(this.name, {this.caseSensitive = false}); /// Returns true if the given [key] matches this element. + /// + /// ```dart + /// CensorElement element1 = CensorElement('key'); + /// element1.matches('key'); // true + /// element1.matches('KEY'); // true + /// + /// CensorElement element2 = CensorElement('key', caseSensitive: true); + /// element2.matches('key'); // true + /// element2.matches('KEY'); // false + /// ``` bool matches(String key) { if (caseSensitive) { return key == name; diff --git a/lib/src/censors.dart b/lib/src/censors.dart index 0a82b38..918643e 100644 --- a/lib/src/censors.dart +++ b/lib/src/censors.dart @@ -21,6 +21,10 @@ class Censors { final List _queryElementsToCensor; /// Creates a new [Censors] with the given [censorString]. + /// + /// ```dart + /// Censors censors = Censors(censorString: "censored"); + /// ``` Censors({String censorString = "******"}) : _censorString = censorString, _bodyElementsToCensor = [], @@ -34,6 +38,7 @@ class Censors { } /// 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}) { @@ -49,6 +54,7 @@ class Censors { } /// 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}) { @@ -64,6 +70,7 @@ class Censors { } /// 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}) { @@ -73,6 +80,8 @@ class Censors { } /// Applies the body parameter censors to the given [body] with the given [contentType]. + /// + /// Currently only supports JSON bodies. // TODO: Only works on JSON bodies String applyBodyParameterCensors(String body, ContentType contentType) { if (body.isEmpty) { diff --git a/lib/src/dartvcr_client.dart b/lib/src/dartvcr_client.dart index d41abaf..0e14bbe 100644 --- a/lib/src/dartvcr_client.dart +++ b/lib/src/dartvcr_client.dart @@ -25,11 +25,26 @@ class DartVCRClient extends http.BaseClient { final AdvancedOptions _advancedOptions; /// Creates a new [DartVCRClient] with the given [Cassette], [Mode] and [AdvancedOptions]. + /// + /// ```dart + /// final client = DartVCRClient( + /// cassette: Cassette(), + /// mode: Mode.auto, + /// advancedOptions: AdvancedOptions( + /// censors: Censors.defaultCensors, + /// matchRules: MatchRules.defaultMatchRules, + /// manualDelay: 0, + /// simulateDelay: false, + /// validTimeFrame: TimeFrame.forever, + /// whenExpired: ExpirationAction.warn, + /// ), + /// ); 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]. diff --git a/lib/src/expiration_actions.dart b/lib/src/expiration_actions.dart index 133a298..48e896a 100644 --- a/lib/src/expiration_actions.dart +++ b/lib/src/expiration_actions.dart @@ -14,6 +14,7 @@ enum ExpirationAction { recordAgain, } +/// An extension on [ExpirationAction] that provides additional functionality. extension ExpirationActionExtension on ExpirationAction { void checkCompatibleSettings(ExpirationAction action, Mode mode) { if (action == ExpirationAction.recordAgain && mode == Mode.replay) { diff --git a/lib/src/internal_utilities/content_type.dart b/lib/src/internal_utilities/content_type.dart index 70bde53..d21716b 100644 --- a/lib/src/internal_utilities/content_type.dart +++ b/lib/src/internal_utilities/content_type.dart @@ -1,10 +1,30 @@ +/// The various content types available. enum ContentType { + /// JSON content type. json, + + /// XML content type. xml, + + /// Text content type. text, + + /// HTML content type. html, } +/// Determines the content type from the given [content]. +/// +/// Returns the [ContentType] if the content type can be determined. +/// Returns null if the content type cannot be determined. +/// +/// ```dart +/// determineContentType("{\"key\": \"value\"}"); // ContentType.json +/// determineContentType(""); // ContentType.xml +/// determineContentType(""); // ContentType.html +/// determineContentType("text"); // ContentType.text +/// determineContentType(""); // null +/// ``` ContentType? determineContentType(String content) { if (content.isEmpty) { return null; @@ -21,6 +41,15 @@ ContentType? determineContentType(String content) { } } +/// Converts the given [contentType] string to a [ContentType]. +/// +/// ```dart +/// fromString("{\"key\": \"value\"}"); // ContentType.json +/// fromString(""); // ContentType.xml +/// fromString(""); // ContentType.html +/// fromString("text"); // ContentType.text +/// fromString(null); // null +/// ``` ContentType? fromString(String? contentType) { if (contentType == null) { return null; @@ -40,14 +69,38 @@ ContentType? fromString(String? contentType) { } } +/// Checks if the given [content] is JSON. +/// +/// Returns true if the content is JSON, false otherwise. +/// +/// ```dart +/// isJson("{\"key\": \"value\"}"); // true +/// isJson("text"); // false +/// ``` bool isJson(String content) { return content.startsWith("{") || content.startsWith("["); } +/// Checks if the given [content] is XML. +/// +/// Returns true if the content is XML, false otherwise. +/// +/// ```dart +/// isXml(""); // true +/// isXml("text"); // false +/// ``` bool isXml(String content) { return content.startsWith("<") && content.endsWith(">"); } +/// Checks if the given [content] is HTML. +/// +/// Returns true if the content is HTML, false otherwise. +/// +/// ```dart +/// isHtml(""); // true +/// isHtml("text"); // false +/// ``` bool isHtml(String content) { return content.startsWith("") && content.endsWith(""); } diff --git a/lib/src/match_rules.dart b/lib/src/match_rules.dart index ff1555d..15ea8b1 100644 --- a/lib/src/match_rules.dart +++ b/lib/src/match_rules.dart @@ -11,6 +11,10 @@ class MatchRules { final List _rules; /// Creates a new [MatchRules]. + /// + /// ```dart + /// MatchRules rules = MatchRules(); + /// ``` MatchRules() : _rules = []; /// A pre-configured set of rules that will match requests based on the full URL and method. @@ -38,6 +42,10 @@ class MatchRules { } /// Enforces that both requests have the base URL (host). + /// + /// ```dart + /// MatchRules rules = MatchRules().byBaseUrl(); + /// ``` MatchRules byBaseUrl() { _by((Request received, Request recorded) { return received.uri.host == recorded.uri.host; @@ -46,8 +54,13 @@ class MatchRules { } /// 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. + /// + /// ```dart + /// MatchRules rules = MatchRules().byFullUrl(); + /// ``` MatchRules byFullUrl({bool preserveQueryOrder = false}) { _by((Request received, Request recorded) { if (preserveQueryOrder) { @@ -76,6 +89,9 @@ class MatchRules { } /// Enforces that both requests have the same HTTP method. + /// + /// ```dart + /// MatchRules rules = MatchRules().byMethod(); MatchRules byMethod() { _by((Request received, Request recorded) { return received.method == recorded.method; @@ -84,7 +100,11 @@ class MatchRules { } /// Enforces that both requests have the same body. + /// /// Ignore specific [ignoreElements] in the body when comparing. + /// + /// ```dart + /// MatchRules rules = MatchRules().byBody(); MatchRules byBody({List ignoreElements = const []}) { _by((Request received, Request recorded) { if (received.body == null && recorded.body == null) { @@ -109,6 +129,10 @@ class MatchRules { } /// Enforces that both requests are the exact same (headers, body, etc.). + /// + /// ```dart + /// MatchRules rules = MatchRules().byEverything(); + /// ``` MatchRules byEverything() { _by((Request received, Request recorded) { String receivedRequest = jsonEncode(received); @@ -119,6 +143,10 @@ class MatchRules { } /// Enforces that both requests have the same header with the given [headerKey]. + /// + /// ```dart + /// MatchRules rules = MatchRules().byHeader("Content-Type"); + /// ``` MatchRules byHeader(String headerKey) { _by((Request received, Request recorded) { if (received.headers.containsKey(headerKey) && @@ -132,9 +160,14 @@ class MatchRules { } /// 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) { + /// + /// ```dart + /// MatchRules rules = MatchRules().byHeaders(); + /// ``` + MatchRules byHeaders({bool exact = false}) { 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. _by((Request received, Request recorded) { @@ -160,4 +193,5 @@ class MatchRules { } } +/// A function that determines if two requests match. typedef MatchRule = bool Function(Request received, Request recorded); diff --git a/lib/src/request_elements/http_element.dart b/lib/src/request_elements/http_element.dart index b1fd55f..779e080 100644 --- a/lib/src/request_elements/http_element.dart +++ b/lib/src/request_elements/http_element.dart @@ -2,12 +2,16 @@ import 'package:json_annotation/json_annotation.dart'; part 'http_element.g.dart'; +/// The base class for all request elements. @JsonSerializable(explicitToJson: true) class HttpElement { + /// Creates a new [HttpElement]. HttpElement(); + /// Creates a new [HttpElement] from a JSON map. factory HttpElement.fromJson(Map input) => _$HttpElementFromJson(input); + /// Converts this [HttpElement] to a JSON map. Map toJson() => _$HttpElementToJson(this); } diff --git a/lib/src/request_elements/http_interaction.dart b/lib/src/request_elements/http_interaction.dart index 20dcbf5..d7e3beb 100644 --- a/lib/src/request_elements/http_interaction.dart +++ b/lib/src/request_elements/http_interaction.dart @@ -5,35 +5,44 @@ import 'package:dartvcr/src/request_elements/http_element.dart'; import 'package:dartvcr/src/request_elements/request.dart'; import 'package:dartvcr/src/request_elements/response.dart'; import 'package:dartvcr/src/request_elements/status.dart'; -import 'package:json_annotation/json_annotation.dart'; import 'package:http/http.dart' as http; +import 'package:json_annotation/json_annotation.dart'; import '../internal_utilities/content_type.dart'; part 'http_interaction.g.dart'; +/// A class that represents a single HTTP interaction. @JsonSerializable(explicitToJson: true) class HttpInteraction extends HttpElement { + /// The duration of the request in milliseconds. @JsonKey(name: 'duration') int duration; + /// The time at which the request was recorded. @JsonKey(name: 'recorded_at') final DateTime recordedAt; + /// The [Request] that was made. @JsonKey(name: 'request') final Request request; + /// The [Response] that was received. @JsonKey(name: 'response') final Response response; + /// Creates a new [HttpInteraction] with the given [duration], [recordedAt], [request], and [response]. HttpInteraction(this.duration, this.recordedAt, this.request, this.response); + /// Creates a new [HttpInteraction] from a JSON map. factory HttpInteraction.fromJson(Map input) => _$HttpInteractionFromJson(input); + /// Converts this [HttpInteraction] to a JSON map. @override Map toJson() => _$HttpInteractionToJson(this); + /// Creates a new [http.StreamedResponse] from this [HttpInteraction]. http.StreamedResponse toStreamedResponse(Censors censors) { final streamedResponse = http.StreamedResponse( http.ByteStream.fromBytes(utf8.encode(response.body ?? '')), @@ -46,6 +55,7 @@ class HttpInteraction extends HttpElement { return streamedResponse; } + /// Creates a new [HttpInteraction] from a [http.Response] and [Censors] rules. factory HttpInteraction.fromHttpResponse( http.Response response, Censors censors) { final requestBody = ((response.request!) as http.Request).body; diff --git a/lib/src/request_elements/request.dart b/lib/src/request_elements/request.dart index 873cd5b..980fc5c 100644 --- a/lib/src/request_elements/request.dart +++ b/lib/src/request_elements/request.dart @@ -7,28 +7,37 @@ import 'http_element.dart'; part 'request.g.dart'; +/// A class that represents a single HTTP request. @JsonSerializable(explicitToJson: true) class Request extends HttpElement { + /// The body of the request. @JsonKey(name: 'body') final String? body; + /// The headers of the request. @JsonKey(name: 'headers') final Map headers; + /// The HTTP method of the request. @JsonKey(name: 'method') final String method; + /// The URI of the request. @JsonKey(name: 'uri') final Uri uri; + /// Creates a new [Request] with the given [body], [headers], [method], and [uri]. Request(this.body, this.headers, this.method, this.uri) : super(); + /// Creates a new [Request] from a JSON map. factory Request.fromJson(Map input) => _$RequestFromJson(input); + /// Converts this [Request] to a JSON map. @override Map toJson() => _$RequestToJson(this); + /// Creates a new [Request] from a [http.BaseRequest] and [Censors] rules. factory Request.fromHttpRequest(http.BaseRequest request, Censors censors) { String body = ""; try { diff --git a/lib/src/request_elements/response.dart b/lib/src/request_elements/response.dart index 95ef6ed..73127e4 100644 --- a/lib/src/request_elements/response.dart +++ b/lib/src/request_elements/response.dart @@ -7,25 +7,33 @@ import 'http_element.dart'; part 'response.g.dart'; +/// A class that represents an HTTP response. @JsonSerializable(explicitToJson: true) class Response extends HttpElement { + /// The body of the response. @JsonKey(name: 'body') final String? body; + /// The headers of the response. @JsonKey(name: 'headers') final Map headers; + /// The HTTP status of the response. @JsonKey(name: 'status') final Status status; + /// Creates a new [Response] with the given [body], [headers], and [status]. Response(this.body, this.headers, this.status) : super(); + /// Creates a new [Response] from a JSON map. factory Response.fromJson(Map input) => _$ResponseFromJson(input); + /// Converts this [Response] to a JSON map. @override Map toJson() => _$ResponseToJson(this); + /// Creates a new [http.StreamedResponse] from a [http.Response] and [Censors] rules. static Future toStream( http.Response response, Censors censors) async { Map headers = response.headers; @@ -42,6 +50,7 @@ class Response extends HttpElement { ); } + /// Creates a new [http.Response] from a [http.StreamedResponse] and [Censors] rules. static Future fromStream( http.StreamedResponse response, Censors censors) async { final body = await response.stream.toBytes(); diff --git a/lib/src/request_elements/status.dart b/lib/src/request_elements/status.dart index 1322b19..1d1ab0c 100644 --- a/lib/src/request_elements/status.dart +++ b/lib/src/request_elements/status.dart @@ -4,19 +4,25 @@ import 'http_element.dart'; part 'status.g.dart'; +/// A class that represents an HTTP status. @JsonSerializable(explicitToJson: true) class Status extends HttpElement { + /// The code of the status. @JsonKey(name: 'code') final int? code; + /// The message of the status. @JsonKey(name: 'message') final String? message; + /// Creates a new [Status] with the given [code] and [message]. Status(this.code, this.message) : super(); + /// Creates a new [Status] from a JSON map. factory Status.fromJson(Map input) => _$StatusFromJson(input); + /// Converts this [Status] to a JSON map. @override Map toJson() => _$StatusToJson(this); } diff --git a/lib/src/time_frame.dart b/lib/src/time_frame.dart index c64f3f8..20d9655 100644 --- a/lib/src/time_frame.dart +++ b/lib/src/time_frame.dart @@ -1,3 +1,4 @@ +/// The various common [TimeFrame]s available. enum CommonTimeFrame { never, forever } /// A class representing a time frame. @@ -18,6 +19,11 @@ class TimeFrame { final CommonTimeFrame? _commonTimeFrame; /// Creates a new [TimeFrame] with the given [days], [hours], [minutes] and [seconds], or [commonTimeFrame]. + /// + /// ```dart + /// TimeFrame frame1 = TimeFrame(days: 1, hours: 2, minutes: 3, seconds: 4); + /// TimeFrame frame2 = TimeFrame(commonTimeFrame: CommonTimeFrame.forever); + /// ``` TimeFrame({ this.days = 0, this.hours = 0, diff --git a/lib/src/utilities.dart b/lib/src/utilities.dart index 024ddad..614511b 100644 --- a/lib/src/utilities.dart +++ b/lib/src/utilities.dart @@ -1,18 +1,29 @@ import 'package:dartvcr/src/defaults.dart'; - import 'package:http/http.dart' as http; /// Returns true if the given [content] is a JSON map. +/// +/// ```dart +/// isJsonMap({}); // true +/// isJsonMap([]); // false +/// isJsonMap(''); // false +/// ``` bool isJsonMap(dynamic content) { return content is Map; } /// Returns true if the given [content] is a JSON list. +/// +/// ```dart +/// isJsonList([]); // true +/// isJsonList({}); // false +/// isJsonList(''); // false +/// ``` bool isJsonList(dynamic content) { return content is List; } -/// Returns true if the given [response] is a JSON string. +/// Returns true if the given [response] came from a recording. bool responseCameFromRecording(http.Response response) { return response.headers.containsKey(viaRecordingHeaderKey); } diff --git a/lib/src/vcr.dart b/lib/src/vcr.dart index deebe04..de5273a 100644 --- a/lib/src/vcr.dart +++ b/lib/src/vcr.dart @@ -13,10 +13,15 @@ class VCR { Mode mode; /// The [AdvancedOptions] the VCR is currently using during requests. + /// /// These options will be passed to the [DartVCRClient] when making requests. AdvancedOptions? advancedOptions; /// Creates a new [VCR] with the given [AdvancedOptions]. The [mode] defaults to [Mode.bypass]. + /// + /// ```dart + /// final vcr = VCR(AdvancedOptions()); + /// ``` VCR({this.advancedOptions}) : mode = Mode.bypass; /// Get the name of the current [Cassette], or null if there is no cassette. @@ -29,36 +34,73 @@ class VCR { advancedOptions: advancedOptions ?? AdvancedOptions()); /// Unload the current [Cassette]. + /// + /// ```dart + /// final vcr = VCR(); + /// vcr.insert(Cassette()); + /// vcr.eject(); + /// ``` void eject() { _currentCassette = null; } /// Load a [Cassette]. + /// + /// ```dart + /// final vcr = VCR(); + /// vcr.insert(Cassette()); + /// ``` void insert(Cassette cassette) { _currentCassette = cassette; } /// Delete all recorded interactions from the current [Cassette]. + /// + /// ```dart + /// final vcr = VCR(); + /// vcr.insert(Cassette()); + /// vcr.erase(); + /// ``` void erase() { _currentCassette?.erase(); } /// Set the [Mode] of the VCR to [Mode.bypass]. + /// + /// ```dart + /// final vcr = VCR(); + /// vcr.pause(); + /// ``` void pause() { mode = Mode.bypass; } /// Set the [Mode] of the VCR to [Mode.record]. + /// + /// ```dart + /// final vcr = VCR(); + /// vcr.record(); + /// ``` void record() { mode = Mode.record; } /// Set the [Mode] of the VCR to [Mode.replay]. + /// + /// ```dart + /// final vcr = VCR(); + /// vcr.replay(); + /// ``` void replay() { mode = Mode.replay; } /// Set the [Mode] of the VCR to [Mode.auto]. + /// + /// ```dart + /// final vcr = VCR(); + /// vcr.recordIfNeeded(); + /// ``` void recordIfNeeded() { mode = Mode.auto; }