diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..73a0248 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,80 @@ +import 'package:http/http.dart' as http; +import 'package:pact_dart/pact_dart.dart'; +import 'package:test/test.dart'; + +void main() { + group('HTTP consumer example', () { + late PactMockService pact; + + setUp(() { + pact = PactMockService('HTTP consumer', 'Some API'); + }); + + tearDown(() { + return pact.reset(); + }); + + test('get users', () async { + pact + .newInteraction() + .given("a user exists", + params: {'first_name': 'Betsy', 'last_name': 'Tester'}) + .uponReceiving('a request for all users') + .withRequest('GET', '/users') + .willRespondWith(200, body: { + // Matchers are used here as we care about the types and structure of the response and not the exact values. + 'page': PactMatchers.SomethingLike(1), + 'per_page': PactMatchers.SomethingLike(20), + 'total': PactMatchers.IntegerLike(20), + 'total_pages': PactMatchers.SomethingLike(3), + 'data': PactMatchers.EachLike([ + { + 'id': PactMatchers.uuid('f3a9cf4a-92d7-4aae-a945-63a6440b528b'), + 'first_name': PactMatchers.SomethingLike('Betsy'), + 'last_name': PactMatchers.SomethingLike('Tester'), + 'salary': PactMatchers.DecimalLike(125000.00) + } + ]) + }); + + pact.run(secure: false); + + await http.get(Uri.parse('http://${pact.host}:${pact.port}/users')); + + pact.writePactFile(); + }); + }); + + group('message consumer example', () { + test('payment rejected', () async { + final pact = MessagesPact('message consumer', 'message provider'); + await pact + .newMessage() + .given("a user exists", params: {'id': 'user_id'}) + .andGiven('payment is rejected') + .expectsToReceive('payment rejected message') + .withContent({ + 'type': 'payment rejected', + 'payment_id': + PactMatchers.uuid('f3a9cf4a-92d7-4aae-a945-63a6440b528b'), + }) + .withMetadata({'foo': 'bar'}) + .verify((message, metadata) { + expect( + message, + equals({ + 'type': 'payment rejected', + 'payment_id': 'f3a9cf4a-92d7-4aae-a945-63a6440b528b' + })); + expect( + metadata, + equals({ + 'contentType': 'application/json', + 'foo': 'bar', + })); + }); + + pact.writePactFile(); + }); + }); +} diff --git a/example/pubspec.lock b/example/pubspec.lock new file mode 100644 index 0000000..1af36a4 --- /dev/null +++ b/example/pubspec.lock @@ -0,0 +1,341 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "40.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.9.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.4" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.12" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + pact_dart: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.5.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.10" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + test: + dependency: "direct main" + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.21.3" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.11" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.15" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "9.0.0" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" +sdks: + dart: ">=2.16.0 <3.0.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..aaf208d --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,37 @@ +name: pact_dart_example +description: Demonstrates how to use the pact_dart package. + +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ">=2.12.0 <3.0.0" + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + pact_dart: + # When depending on this package from a real application you should use: + # pact_dart: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example is bundled with the package so we use a path dependency on + # the parent directory to use the current version. + path: ../ + test: ^1.21.3 diff --git a/lib/pact_dart.dart b/lib/pact_dart.dart index 1399aa2..e925bbf 100644 --- a/lib/pact_dart.dart +++ b/lib/pact_dart.dart @@ -1,2 +1,3 @@ export 'src/pact_mock_service.dart'; +export 'src/messages_pact.dart'; export 'src/matchers.dart'; diff --git a/lib/src/bindings/bindings.dart b/lib/src/bindings/bindings.dart index ecfb20a..d6514bf 100644 --- a/lib/src/bindings/bindings.dart +++ b/lib/src/bindings/bindings.dart @@ -60,6 +60,35 @@ class PactFFIBindings { late int Function(int mock_server_port) pactffi_cleanup_mock_server; + late MessagePactHandle Function( + Pointer consumer_name, Pointer provider_name) + pactffi_new_message_pact; + + late MessageHandle Function(MessagePactHandle pact, Pointer description) + pactffi_new_message; + + late void Function(MessageHandle message, Pointer description) + pactffi_message_given; + + late void Function(MessageHandle message, Pointer description, + Pointer name, Pointer value) pactffi_message_given_with_param; + + late void Function(MessageHandle message, Pointer description) + pactffi_message_expects_to_receive; + + late void Function(MessageHandle message, Pointer content_type, + Pointer body, int size) pactffi_message_with_contents; + + late void Function( + MessageHandle message, Pointer key, Pointer value) + pactffi_message_with_metadata; + + late int Function( + MessagePactHandle pact, Pointer directory, int overwrite) + pactffi_write_message_pact_file; + + late Pointer Function(MessageHandle message) pactffi_message_reify; + PactFFIBindings() { pactffi = openLibrary(); @@ -147,6 +176,51 @@ class PactFFIBindings { .lookup>( 'pactffi_cleanup_mock_server') .asFunction(); + + pactffi_new_message_pact = pactffi + .lookup>( + 'pactffi_new_message_pact') + .asFunction(); + + pactffi_new_message = pactffi + .lookup>( + 'pactffi_new_message') + .asFunction(); + + pactffi_message_given = pactffi + .lookup>( + 'pactffi_message_given') + .asFunction(); + + pactffi_message_given_with_param = pactffi + .lookup>( + 'pactffi_message_given_with_param') + .asFunction(); + + pactffi_message_expects_to_receive = pactffi + .lookup>( + 'pactffi_message_expects_to_receive') + .asFunction(); + + pactffi_message_with_contents = pactffi + .lookup>( + 'pactffi_message_with_contents') + .asFunction(); + + pactffi_message_with_metadata = pactffi + .lookup>( + 'pactffi_message_with_metadata') + .asFunction(); + + pactffi_write_message_pact_file = pactffi + .lookup>( + 'pactffi_write_message_pact_file') + .asFunction(); + + pactffi_message_reify = pactffi + .lookup>( + 'pactffi_message_reify') + .asFunction(); } } diff --git a/lib/src/bindings/signatures.dart b/lib/src/bindings/signatures.dart index d9c0b33..7781d99 100644 --- a/lib/src/bindings/signatures.dart +++ b/lib/src/bindings/signatures.dart @@ -65,7 +65,10 @@ typedef pactffi_message_reify_native = Pointer Function( MessageHandle message); typedef pactffi_message_with_contents_native = Void Function( - MessageHandle message, Pointer content_type, Uint8 body, IntPtr size); + MessageHandle message, + Pointer content_type, + Pointer body, // TODO: body: *const u8, + IntPtr size); typedef pactffi_message_with_metadata_native = Void Function( MessageHandle message, Pointer key, Pointer value); diff --git a/lib/src/message.dart b/lib/src/message.dart new file mode 100644 index 0000000..0668a95 --- /dev/null +++ b/lib/src/message.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:ffi/ffi.dart'; + +import 'package:pact_dart/src/bindings/bindings.dart'; +import 'package:pact_dart/src/bindings/types.dart'; +import 'package:pact_dart/src/errors.dart'; + +class Message { + late MessageHandle message; + + Message(MessagePactHandle handle, String description) { + final cDescription = description.toNativeUtf8(); + message = bindings.pactffi_new_message(handle, cDescription); + } + + Message given(String providerState, {Map? params}) { + if (providerState.isEmpty) { + throw EmptyParameterError('providerState'); + } + + final cProviderState = providerState.toNativeUtf8(); + if (params != null && params.isNotEmpty) { + params.forEach((key, value) { + final cProviderState = providerState.toNativeUtf8(); + final cKey = key.toNativeUtf8(); + final cValue = value.toNativeUtf8(); + + bindings.pactffi_message_given_with_param( + message, cProviderState, cKey, cValue); + }); + } else { + bindings.pactffi_message_given(message, cProviderState); + } + + return this; + } + + Message andGiven(String providerState, {Map? params}) { + return given(providerState, params: params); + } + + Message expectsToReceive(String description) { + if (description.isEmpty) { + throw EmptyParameterError('description'); + } + + final cDescription = description.toNativeUtf8(); + bindings.pactffi_message_expects_to_receive(message, cDescription); + + return this; + } + + /// Note: The given `body` is converted to JSON via [jsonEncode]. + Message withContent(dynamic body) { + final cContentType = 'application/json'.toNativeUtf8(); + final cBody = jsonEncode(body).toNativeUtf8(); + bindings.pactffi_message_with_contents(message, cContentType, cBody, -1); + + return this; + } + + Message withMetadata(Map metadata) { + metadata.forEach((key, value) { + final cKey = key.toNativeUtf8(); + final cValue = value.toNativeUtf8(); + bindings.pactffi_message_with_metadata(message, cKey, cValue); + }); + + return this; + } + + /// You should call this method in your test to ensure, that your code + /// can indeed handle the message specified by the message pact. + /// + /// Note: The message given to the `tryToHandleMethod` callback is a copy + /// of the message given to [withContent] with all matchers stripped away. + FutureOr verify( + FutureOr Function(dynamic message, Map metadata) + tryToHandleMethod) { + var json = bindings.pactffi_message_reify(message).toDartString(); + if (json.isEmpty) { + throw StateError( + 'message has not been properly constructed yet, you need to call withContent first'); + } + final reifiedMessage = jsonDecode(json) as Map; + final body = reifiedMessage['contents']; + final metadata = reifiedMessage['metadata'] as Map; + return tryToHandleMethod(body, metadata.cast()); + } +} diff --git a/lib/src/messages_pact.dart b/lib/src/messages_pact.dart new file mode 100644 index 0000000..f2f1c16 --- /dev/null +++ b/lib/src/messages_pact.dart @@ -0,0 +1,36 @@ +import 'package:ffi/ffi.dart'; + +import 'package:pact_dart/src/bindings/bindings.dart'; +import 'package:pact_dart/src/bindings/constants.dart'; +import 'package:pact_dart/src/bindings/types.dart'; +import 'package:pact_dart/src/errors.dart'; +import 'package:pact_dart/src/ffi/extensions.dart'; +import 'package:pact_dart/src/message.dart'; + +class MessagesPact { + late MessagePactHandle handle; + late Message currentMessage; + + List messages = []; + + MessagesPact(String consumer, String provider) { + handle = bindings.pactffi_new_message_pact( + consumer.toNativeUtf8(), provider.toNativeUtf8()); + } + + Message newMessage({String description = ''}) { + currentMessage = Message(handle, description); + messages.add(currentMessage); + + return currentMessage; + } + + void writePactFile({String directory = 'contracts', bool overwrite = false}) { + final result = bindings.pactffi_write_message_pact_file( + handle, directory.toNativeUtf8(), overwrite.toInt()); + + if (result != PactWriteStatusCodes.OK) { + throw PactWriteError(result); + } + } +} diff --git a/lib/src/pact_mock_service.dart b/lib/src/pact_mock_service.dart index 7d76281..5fde55b 100644 --- a/lib/src/pact_mock_service.dart +++ b/lib/src/pact_mock_service.dart @@ -67,7 +67,7 @@ class PactMockService { throw NoInteractionsError(); } - log.info('Starting mock server on', addr); + log.info('Starting mock server on $addr'); final portOrStatus = bindings.pactffi_create_mock_server_for_pact( handle, addr.toNativeUtf8(), secure.toInt());