Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: whenMatch - for more advanced matching needs #23

Merged
merged 4 commits into from
Apr 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 0.3.0

- Add `whenMatch` for more complex matching scenarios
- Remove `CharlatanHttpRequest#pathParameters`

## 0.2.0

- Upgrade `dio` to `5.0.0`
Expand Down
48 changes: 41 additions & 7 deletions example/main.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:charlatan/charlatan.dart';
import 'package:dio/dio.dart';
import 'package:test/test.dart';
import 'package:uri/uri.dart';

void main() {
late Dio client;
Expand All @@ -18,13 +19,19 @@ void main() {
expect(plain.data, {'name': 'frodo'});
});

test('Use a URI template and use the path parameters in the response',
() async {
test('Use a URI template', () async {
final pathWithTemplate = '/users/{id}';
charlatan.whenGet(
'/user/{id}',
(request) => {
'id': request.pathParameters['id'],
'name': 'frodo',
pathWithTemplate,
(request) {
final uri = Uri.parse(request.path);
final template = UriTemplate(pathWithTemplate);
final parser = UriParser(template);
final pathParameters = parser.parse(uri);
return {
'id': pathParameters['id'],
'name': 'frodo',
};
},
);

Expand All @@ -40,7 +47,34 @@ void main() {
);

final emptyBody = await client.get<Object?>('/posts');
expect(emptyBody.data, '');
expect(emptyBody.data, null);
expect(emptyBody.statusCode, 204);
});

test('Use a custom request matcher', () async {
charlatan.whenMatch(
(request) => request.method == 'GET' && request.path == '/posts',
(_) => null,
statusCode: 204,
);

final emptyBody = await client.get<Object?>('/posts');
expect(emptyBody.data, null);
expect(emptyBody.statusCode, 204);
});

test('Use a custom request matcher with helpers', () async {
charlatan.whenMatch(
requestMatchesAll([
requestMatchesHttpMethod('GET'),
requestMatchesPathOrTemplate('/posts'),
samandmoore marked this conversation as resolved.
Show resolved Hide resolved
]),
(_) => null,
statusCode: 204,
);

final emptyBody = await client.get<Object?>('/posts');
expect(emptyBody.data, null);
expect(emptyBody.statusCode, 204);
});

Expand Down
5 changes: 3 additions & 2 deletions example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ description: An example of charlatan usage
publish_to: none

environment:
sdk: ">=2.14-0-0 <3.0.0"
sdk: ">=2.15.0 <3.0.0"

dev_dependencies:
charlatan:
path: ../
dio: ^4.0.0
dio: ^5.0.0
lints: ^1.0.1
test: ^1.16.0
uri: ^1.0.0
7 changes: 6 additions & 1 deletion lib/charlatan.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import 'src/charlatan_http_client_adapter.dart';

export 'src/charlatan.dart';
export 'src/charlatan_http_client_adapter.dart';
export 'src/charlatan_http_response_definition.dart' show CharlatanHttpResponse;
export 'src/charlatan_response_definition.dart'
show
CharlatanHttpResponse,
requestMatchesAll,
requestMatchesHttpMethod,
requestMatchesPathOrTemplate;

/// Utilities to make it easier to work with [Charlatan].
extension CharlatanExtensions on Charlatan {
Expand Down
145 changes: 79 additions & 66 deletions lib/src/charlatan.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:charlatan/src/charlatan_http_response_definition.dart';
import 'package:charlatan/src/charlatan_response_definition.dart';
import 'package:collection/collection.dart';

/// {@template charlatan}
/// A class for building a collection of fake http responses to power a fake
Expand All @@ -11,14 +12,37 @@ import 'package:charlatan/src/charlatan_http_response_definition.dart';
/// ```
/// {@endtemplate}
class Charlatan {
final Map<String, List<CharlatanHttpResponseDefinition>> _mapping = {};

/// {@nodoc}
bool shouldLogErrors = true;

/// {@nodoc}
void silenceErrors() => shouldLogErrors = false;

final List<CharlatanResponseDefinition> _matchers = [];

/// Adds a fake response definition for a request that matches the provided
/// [requestMatcher]. If the response is not a [CharlatanHttpResponse] then
/// the [statusCode] will be used.
///
/// [description] is used to describe the response for debugging; it defaults
/// to 'Custom Matcher' but should be provided for clarity, e.g. 'GET /users/123?q=foo'
void whenMatch(
CharlatanRequestMatcher requestMatcher,
CharlatanResponseBuilder responseBuilder, {
int statusCode = 200,
String? description,
}) {
_matchers.insert(
0,
CharlatanResponseDefinition(
description: description ?? 'Custom Matcher',
requestMatcher: requestMatcher,
responseBuilder: responseBuilder,
defaultStatusCode: statusCode,
),
);
}

Copy link
Member Author

@samandmoore samandmoore Apr 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for some more understanding of where this is valuable...

now we can support matching against graphql API calls like this:

c.whenMatch(
  (r) => r.path == '/api/graphql' && r.body.asJson['queryName'] == 'GetUserQuery',
  (r) => {'name': 'bilbo'}, // but obviously in the shape of GQL responses
);

which we can then wrap in some helpers to look more like this:

c.whenMatch(
  requestMatchesGqlQuery('GetUserQuery'),
  requestGqlResponse({'name': 'bilbo'}),
);

/// Adds a fake response definition for a GET request to the provided
/// [pathOrTemplate]. If the response is not a [CharlatanHttpResponse] then
/// the [statusCode] will be used.
Expand All @@ -27,11 +51,17 @@ class Charlatan {
CharlatanResponseBuilder responseBuilder, {
int statusCode = 200,
}) {
_addDefintionForHttpMethod(
httpMethod: 'get',
pathOrTemplate: pathOrTemplate,
statusCode: statusCode,
responseBuilder: responseBuilder,
_matchers.insert(
0,
CharlatanResponseDefinition(
description: 'GET $pathOrTemplate',
requestMatcher: requestMatchesAll([
requestMatchesHttpMethod('get'),
requestMatchesPathOrTemplate(pathOrTemplate),
]),
responseBuilder: responseBuilder,
defaultStatusCode: statusCode,
),
);
}

Expand All @@ -43,11 +73,17 @@ class Charlatan {
CharlatanResponseBuilder responseBuilder, {
int statusCode = 200,
}) {
_addDefintionForHttpMethod(
httpMethod: 'post',
pathOrTemplate: pathOrTemplate,
statusCode: statusCode,
responseBuilder: responseBuilder,
_matchers.insert(
0,
CharlatanResponseDefinition(
description: 'POST $pathOrTemplate',
requestMatcher: requestMatchesAll([
requestMatchesHttpMethod('post'),
requestMatchesPathOrTemplate(pathOrTemplate),
]),
responseBuilder: responseBuilder,
defaultStatusCode: statusCode,
),
);
}

Expand All @@ -59,11 +95,17 @@ class Charlatan {
CharlatanResponseBuilder responseBuilder, {
int statusCode = 200,
}) {
_addDefintionForHttpMethod(
httpMethod: 'put',
pathOrTemplate: pathOrTemplate,
statusCode: statusCode,
responseBuilder: responseBuilder,
_matchers.insert(
0,
CharlatanResponseDefinition(
description: 'PUT $pathOrTemplate',
requestMatcher: requestMatchesAll([
requestMatchesHttpMethod('put'),
requestMatchesPathOrTemplate(pathOrTemplate),
]),
responseBuilder: responseBuilder,
defaultStatusCode: statusCode,
),
);
}

Expand All @@ -75,62 +117,33 @@ class Charlatan {
CharlatanResponseBuilder responseBuilder, {
int statusCode = 200,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could make a breaking change to make things more typesafe

whenDelete(
  '/users',
  charlatanResponseWith({'id': 123}, status: 200),
);

where charlatanResponseWith returns a CharlatanResponseBuilder that produces a json response with a 200 status code. then if you wanna do something fancier, you drop down to defining your own builder but we change it to require the builder to return a CharlatanHttpResponse instead of Object?. as a reminder, the reason it expects Object? right now is to support returning just a Map from the builder in the normal case where you want a 200 with a json body.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is definitely not a do it in this PR thing, but it might be a nice thing to consider as a better solution to the default status code and short-hand response builder definition.

}) {
_addDefintionForHttpMethod(
httpMethod: 'delete',
pathOrTemplate: pathOrTemplate,
statusCode: statusCode,
responseBuilder: responseBuilder,
_matchers.insert(
0,
CharlatanResponseDefinition(
description: 'DELETE $pathOrTemplate',
requestMatcher: requestMatchesAll([
requestMatchesHttpMethod('delete'),
requestMatchesPathOrTemplate(pathOrTemplate),
]),
responseBuilder: responseBuilder,
defaultStatusCode: statusCode,
),
);
}

void _addDefintionForHttpMethod({
required String httpMethod,
required String pathOrTemplate,
required int statusCode,
required CharlatanResponseBuilder responseBuilder,
}) {
final definition = CharlatanHttpResponseDefinition(
statusCode: statusCode,
httpMethod: httpMethod,
pathOrTemplate: pathOrTemplate,
responseBuilder: responseBuilder,
);

getDefinitionsForHttpMethod(httpMethod)
// we're removing exact matches for house-keeping purposes. technically,
// it's totally fine to just insert at the beginning and not remove an
// existing exact match, but it feels weird to have a data structure that
// contains responses with duplicate pathOrTemplate knowing that only one
// of them can ever be matched.
..removeWhere((possibleDefinition) =>
possibleDefinition.pathOrTemplate == pathOrTemplate)
// this is the important part. we want to always insert new entries at the
// beginning of the list because that matches the expecations of the user
// in terms of how overriding a fake response would work.
..insert(0, definition);
}

/// Returns all the matching [CharlatanHttpResponseDefinition]s for the provided
/// [httpMethod] or an empty list.
List<CharlatanHttpResponseDefinition> getDefinitionsForHttpMethod(
String httpMethod,
) {
return _mapping.putIfAbsent(httpMethod, () => []);
/// Returns the first fake response definition that matches the provided [request].
CharlatanResponseDefinition? findMatch(CharlatanHttpRequest request) {
return _matchers.firstWhereOrNull((matcher) => matcher.matches(request));
}

/// Prints a human-readable list of all the registered fake responses.
String toPrettyPrintedString() {
if (_mapping.entries.isEmpty) {
return 'No responses defined.';
if (_matchers.isEmpty) {
return 'No definitions.';
}

return _mapping.entries
.expand<String>(
(entry) => entry.value.map(
(definition) =>
'${definition.httpMethod.toUpperCase()} ${definition.pathOrTemplate}',
),
)
.join('\n');
// the reversed here is because we insert new matchers at the front of the
// list, but we want to print them in the order they were added.
return _matchers.reversed.map((def) => def.description).join('\n');
}
}
22 changes: 8 additions & 14 deletions lib/src/charlatan_http_client_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';

import 'package:charlatan/src/charlatan.dart';
import 'package:charlatan/src/charlatan_http_response_definition.dart';
import 'package:collection/collection.dart';
import 'package:charlatan/src/charlatan_response_definition.dart';
import 'package:dio/dio.dart';

/// {@template charlatan_http_client_adapter}
Expand Down Expand Up @@ -32,18 +31,13 @@ class CharlatanHttpClientAdapter implements HttpClientAdapter {
) async {
final path = options.path;
final method = options.method.toLowerCase();
final possibleDefinitions = charlatan.getDefinitionsForHttpMethod(method);
final match = possibleDefinitions
.map((d) => d.computeMatch(path))
.firstWhereOrNull((m) => m != null);
final request = CharlatanHttpRequest(
requestOptions: options,
);
final match = charlatan.findMatch(request);

if (match != null) {
final definition = match.definition;
final request = CharlatanHttpRequest(
pathParameters: match.pathParameters,
requestOptions: options,
);
return _buildResponse(definition, request);
return _buildResponse(request, match);
}

final errorMessage = '''
Expand Down Expand Up @@ -72,8 +66,8 @@ ${charlatan.toPrettyPrintedString()}
}

Future<ResponseBody> _buildResponse(
CharlatanHttpResponseDefinition definition,
CharlatanHttpRequest request,
CharlatanResponseDefinition definition,
) async {
final response = await definition.buildResponse(request);
final responseType = request.requestOptions.responseType;
Expand All @@ -93,7 +87,7 @@ Future<ResponseBody> _buildResponse(
} else if (responseType == ResponseType.bytes) {
return ResponseBody.fromBytes(
response.body as Uint8List,
definition.statusCode,
response.statusCode,
headers: {
for (final header in response.headers.entries)
header.key: [header.value],
Expand Down
Loading