From 246e86f8f41a2228ec656425447e28c6e4227433 Mon Sep 17 00:00:00 2001 From: Matt Fellows Date: Sun, 30 Oct 2022 20:59:21 +1100 Subject: [PATCH] feat: add additional FFI methods to support plugins Adds the following FFI methods to support custom (plugin) mock servers and provider transports: - pactffiVerifierAddProviderTransport - pactffiCreateMockServerForTransport - pactffiWritePactFileByPort Also, for general verification: - pactffiVerifierSetNoPactsIsError --- native/addon.cc | 5 + native/consumer.cc | 381 ++++++++++++++++++++++- native/consumer.h | 2 + native/provider.cc | 100 +++++- native/provider.h | 2 + package-lock.json | 168 ++++++++++ package.json | 3 + src/consumer/index.ts | 170 +++++++++- src/consumer/types.ts | 77 ++++- src/ffi/index.ts | 2 +- src/ffi/types.ts | 27 +- src/verifier/argumentMapper/arguments.ts | 21 ++ src/verifier/types.ts | 8 + src/verifier/validateOptions.ts | 1 + test/consumer.integration.spec.ts | 2 +- test/integration/grpc/grpc.json | 153 +++++++++ test/integration/grpc/grpc.json.bak | 127 ++++++++ test/integration/grpc/route_guide.proto | 111 +++++++ test/matt.consumer.integration.spec.ts | 159 ++++++++++ test/matt.provider.integration.spec.ts | 96 ++++++ test/message.integration.spec.ts | 217 ++++++++++--- test/plugin-verifier.integration.spec.ts | 139 +++++++++ 22 files changed, 1890 insertions(+), 81 deletions(-) create mode 100644 test/integration/grpc/grpc.json create mode 100644 test/integration/grpc/grpc.json.bak create mode 100644 test/integration/grpc/route_guide.proto create mode 100644 test/matt.consumer.integration.spec.ts create mode 100644 test/matt.provider.integration.spec.ts create mode 100644 test/plugin-verifier.integration.spec.ts diff --git a/native/addon.cc b/native/addon.cc index 635244ca..8701cba0 100644 --- a/native/addon.cc +++ b/native/addon.cc @@ -13,8 +13,10 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set(Napi::String::New(env, "pactffiMockServerMatched"), Napi::Function::New(env, PactffiMockServerMatched)); exports.Set(Napi::String::New(env, "pactffiMockServerMismatches"), Napi::Function::New(env, PactffiMockServerMismatches)); exports.Set(Napi::String::New(env, "pactffiCreateMockServerForPact"), Napi::Function::New(env, PactffiCreateMockServerForPact)); + exports.Set(Napi::String::New(env, "pactffiCreateMockServerForTransport"), Napi::Function::New(env, PactffiCreateMockServerForTransport)); exports.Set(Napi::String::New(env, "pactffiCleanupMockServer"), Napi::Function::New(env, PactffiCleanupMockServer)); exports.Set(Napi::String::New(env, "pactffiWritePactFile"), Napi::Function::New(env, PactffiWritePactFile)); + exports.Set(Napi::String::New(env, "pactffiWritePactFileByPort"), Napi::Function::New(env, PactffiWritePactFileByPort)); exports.Set(Napi::String::New(env, "pactffiNewPact"), Napi::Function::New(env, PactffiNewPact)); exports.Set(Napi::String::New(env, "pactffiNewInteraction"), Napi::Function::New(env, PactffiNewInteraction)); exports.Set(Napi::String::New(env, "pactffiUponReceiving"), Napi::Function::New(env, PactffiUponReceiving)); @@ -38,6 +40,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { // exports.Set(Napi::String::New(env, "pactffiWithMessagePactMetadata"), Napi::Function::New(env, PactffiWithMessagePactMetadata)); exports.Set(Napi::String::New(env, "pactffiNewAsyncMessage"), Napi::Function::New(env, PactffiNewAsyncMessage)); exports.Set(Napi::String::New(env, "pactffiNewSyncMessage"), Napi::Function::New(env, PactffiNewSyncMessage)); + // exports.Set(Napi::String::New(env, "pactffiSyncMessageSetDescription"), Napi::Function::New(env, PactffiSyncMessageSetDescription)); // exports.Set(Napi::String::New(env, "pactffiNewMessage"), Napi::Function::New(env, PactffiNewMessage)); exports.Set(Napi::String::New(env, "pactffiMessageReify"), Napi::Function::New(env, PactffiMessageReify)); exports.Set(Napi::String::New(env, "pactffiMessageGiven"), Napi::Function::New(env, PactffiMessageGiven)); @@ -63,6 +66,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set(Napi::String::New(env, "pactffiVerifierAddDirectorySource"), Napi::Function::New(env, PactffiVerifierAddDirectorySource)); exports.Set(Napi::String::New(env, "pactffiVerifierUrlSource"), Napi::Function::New(env, PactffiVerifierUrlSource)); exports.Set(Napi::String::New(env, "pactffiVerifierBrokerSourceWithSelectors"), Napi::Function::New(env, PactffiVerifierBrokerSourceWithSelectors)); + exports.Set(Napi::String::New(env, "pactffiVerifierAddProviderTransport"), Napi::Function::New(env, PactffiVerifierAddProviderTransport)); + exports.Set(Napi::String::New(env, "pactffiVerifierSetNoPactsIsError"), Napi::Function::New(env, PactffiVerifierSetNoPactsIsError)); return exports; } diff --git a/native/consumer.cc b/native/consumer.cc index 1372e64d..de4cd4f8 100644 --- a/native/consumer.cc +++ b/native/consumer.cc @@ -237,6 +237,83 @@ Napi::Value PactffiCreateMockServerForPact(const Napi::CallbackInfo& info) { return Number::New(env, result); } +/** + * Create a mock server for the provided Pact handle and transport. If the transport is not + * provided (it is a NULL pointer or an empty string), will default to an HTTP transport. The + * address is the interface bind to, and will default to the loopback adapter if not specified. + * Specifying a value of zero for the port will result in the operating system allocating the port. + * + * Parameters: + * * `pact` - Handle to a Pact model created with created with `pactffi_new_pact`. + * * `addr` - Address to bind to (i.e. `127.0.0.1` or `[::1]`). Must be a valid UTF-8 NULL-terminated string, or NULL or empty, in which case the loopback adapter is used. + * * `port` - Port number to bind to. A value of zero will result in the operating system allocating an available port. + * * `transport` - The transport to use (i.e. http, https, grpc). Must be a valid UTF-8 NULL-terminated string, or NULL or empty, in which case http will be used. + * * `transport_config` - (OPTIONAL) Configuration for the transport as a valid JSON string. Set to NULL or empty if not required. + * + * The port of the mock server is returned. + * + * # Safety + * NULL pointers or empty strings can be passed in for the address, transport and transport_config, + * in which case a default value will be used. Passing in an invalid pointer will result in undefined behaviour. + * + * # Errors + * + * Errors are returned as negative values. + * + * | Error | Description | + * |-------|-------------| + * | -1 | An invalid handle was received. Handles should be created with `pactffi_new_pact` | + * | -2 | transport_config is not valid JSON | + * | -3 | The mock server could not be started | + * | -4 | The method panicked | + * | -5 | The address is not valid | + * + * C interface: + * + * int32_t pactffi_create_mock_server_for_transport(PactHandle pact, + * const char *addr, + * uint16_t port, + * const char *transport, + * const char *transport_config); + */ +Napi::Value PactffiCreateMockServerForTransport(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + if (info.Length() < 5) { + throw Napi::Error::New(env, "PactffiCreateMockServerForTransport received < 5 arguments"); + } + + if (!info[0].IsNumber()) { + throw Napi::Error::New(env, "PactffiCreateMockServerForTransport(arg 0) expected a PactHandle (uint16_t)"); + } + + if (!info[1].IsString()) { + throw Napi::Error::New(env, "PactffiCreateMockServerForTransport(arg 1) expected a string"); + } + + if (!info[2].IsNumber()) { + throw Napi::Error::New(env, "PactffiCleanupMockServer(arg 2) expected a number"); + } + + if (!info[3].IsString()) { + throw Napi::Error::New(env, "PactffiCreateMockServerForTransport(arg 3) expected a string"); + } + + if (!info[4].IsString()) { + throw Napi::Error::New(env, "PactffiCreateMockServerForTransport(arg 4) expected a string"); + } + + PactHandle pact = info[0].As().Int32Value(); + std::string addr = info[1].As().Utf8Value(); + uint32_t port = info[2].As().Int32Value(); + std::string transport = info[3].As().Utf8Value(); + std::string config = info[4].As().Utf8Value(); + + uint16_t result = pactffi_create_mock_server_for_transport(pact, addr.c_str(), port, transport.c_str(), config.c_str()); + + return Number::New(env, result); +} + /** * External interface to cleanup a mock server. This function will try terminate the mock server * with the given port number and cleanup any memory allocated for it. Returns true, unless a @@ -321,6 +398,59 @@ Napi::Value PactffiWritePactFile(const Napi::CallbackInfo& info) { return Number::New(env, res); } +/** + * External interface to trigger a mock server to write out its pact file. This function should + * be called if all the consumer tests have passed. The directory to write the file to is passed + * as the second parameter. If a NULL pointer is passed, the current working directory is used. + * + * If overwrite is true, the file will be overwritten with the contents of the current pact. + * Otherwise, it will be merged with any existing pact file. + * + * Returns 0 if the pact file was successfully written. Returns a positive code if the file can + * not be written, or there is no mock server running on that port or the function panics. + * + * # Errors + * + * Errors are returned as positive values. + * + * | Error | Description | + * |-------|-------------| + * | 1 | A general panic was caught | + * | 2 | The pact file was not able to be written | + * | 3 | A mock server with the provided port was not found | + * + * C Interface: + * + * int32_t pactffi_write_pact_file(int32_t mock_server_port, const char *directory, bool overwrite); + */ +Napi::Value PactffiWritePactFileByPort(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + if (info.Length() < 3) { + throw Napi::Error::New(env, "PactffiWritePactFileByPort received < 3 arguments"); + } + + if (!info[0].IsNumber()) { + throw Napi::Error::New(env, "PactffiWritePactFileByPort(arg 0) expected a number"); + } + + if (!info[1].IsString()) { + throw Napi::Error::New(env, "PactffiWritePactFileByPort(arg 1) expected a string"); + } + + if (!info[2].IsBoolean()) { + throw Napi::Error::New(env, "PactffiWritePactFileByPort(arg 2) expected a boolean"); + } + + int32_t port = info[0].As().Int32Value(); + std::string dir = info[1].As().Utf8Value(); + bool overwrite = info[2].As().Value(); + + int32_t res = pactffi_write_pact_file(port, dir.c_str(), overwrite); + + return Number::New(env, res); +} + /** * Creates a new Pact model and returns a handle to it. * @@ -457,6 +587,49 @@ Napi::Value PactffiGiven(const Napi::CallbackInfo& info) { return Napi::Boolean::New(env, res); } +/** + * Write the `description` field on the `SynchronousMessage`. + * + * # Safety + * + * `description` must contain valid UTF-8. Invalid UTF-8 + * will be replaced with U+FFFD REPLACEMENT CHARACTER. + * + * This function will only reallocate if the new string + * does not fit in the existing buffer. + * + * # Error Handling + * + * Errors will be reported with a non-zero return value. + * + * C interface: + * + * int pactffi_sync_message_set_description(struct SynchronousMessage *message, + * const char *description); + */ +// Napi::Value PactffiSyncMessageSetDescription(const Napi::CallbackInfo& info) { +// Napi::Env env = info.Env(); + +// if (info.Length() < 2) { +// throw Napi::Error::New(env, "PactffiSyncMessageSetDescription received < 2 arguments"); +// } + +// if (!info[0].IsNumber()) { +// throw Napi::Error::New(env, "PactffiSyncMessageSetDescription(arg 0) expected a InteractionHandle (uint32_t)"); +// } + +// if (!info[1].IsString()) { +// throw Napi::Error::New(env, "PactffiSyncMessageSetDescription(arg 1) expected a string"); +// } + +// SynchronousMessage interaction = info[0].As().Uint32Value(); +// std::string description = info[1].As().Utf8Value(); + +// int res = pactffi_sync_message_set_description(interaction, description.c_str()); + +// return Number::New(env, res); +// } + /** * Adds a provider state to the Interaction with a parameter key and value. Returns false if the interaction or Pact can't be * modified (i.e. the mock server for it has already started) @@ -1612,4 +1785,210 @@ Napi::Value PactffiPluginInteractionContents(const Napi::CallbackInfo& info) { bool res = pactffi_interaction_contents(interaction, part, contentType.c_str(), contents.c_str()); return Napi::Boolean::New(env, res); -} \ No newline at end of file +} + +/* +// GetMessageContents retreives the binary contents of the request for a given message +// any matchers are stripped away if given +// if the contents is from a plugin, the byte[] representation of the parsed +// plugin data is returned, again, with any matchers etc. removed +func (m *Message) GetMessageRequestContents() ([]byte, error) { + log.Println("[DEBUG] GetMessageRequestContents") + if m.messageType == MESSAGE_TYPE_ASYNC { + iter := C.pactffi_pact_handle_get_message_iter(m.pact.handle) + log.Println("[DEBUG] pactffi_pact_handle_get_message_iter") + if iter == nil { + return nil, errors.New("unable to get a message iterator") + } + log.Println("[DEBUG] pactffi_pact_handle_get_message_iter - OK") + + /////// + // TODO: some debugging in here to see what's exploding....... + /////// + + log.Println("[DEBUG] pactffi_pact_handle_get_message_iter - len", len(m.server.messages)) + + for i := 0; i < len(m.server.messages); i++ { + log.Println("[DEBUG] pactffi_pact_handle_get_message_iter - index", i) + message := C.pactffi_pact_message_iter_next(iter) + log.Println("[DEBUG] pactffi_pact_message_iter_next - message", message) + + if i == m.index { + log.Println("[DEBUG] pactffi_pact_message_iter_next - index match", message) + + if message == nil { + return nil, errors.New("retreived a null message pointer") + } + + len := C.pactffi_message_get_contents_length(message) + log.Println("[DEBUG] pactffi_message_get_contents_length - len", len) + if len == 0 { + return nil, errors.New("retreived an empty message") + } + data := C.pactffi_message_get_contents_bin(message) + log.Println("[DEBUG] pactffi_message_get_contents_bin - data", data) + if data == nil { + return nil, errors.New("retreived an empty pointer to the message contents") + } + ptr := unsafe.Pointer(data) + bytes := C.GoBytes(ptr, C.int(len)) + + return bytes, nil + } + } + + } else { + iter := C.pactffi_pact_handle_get_sync_message_iter(m.pact.handle) + if iter == nil { + return nil, errors.New("unable to get a message iterator") + } + + for i := 0; i < len(m.server.messages); i++ { + message := C.pactffi_pact_sync_message_iter_next(iter) + + if i == m.index { + if message == nil { + return nil, errors.New("retreived a null message pointer") + } + + len := C.pactffi_sync_message_get_request_contents_length(message) + if len == 0 { + return nil, errors.New("retreived an empty message") + } + data := C.pactffi_sync_message_get_request_contents_bin(message) + if data == nil { + return nil, errors.New("retreived an empty pointer to the message contents") + } + ptr := unsafe.Pointer(data) + bytes := C.GoBytes(ptr, C.int(len)) + + return bytes, nil + } + } + } + + return nil, errors.New("unable to find the message") +} + +// GetMessageResponseContents retreives the binary contents of the response for a given message +// any matchers are stripped away if given +// if the contents is from a plugin, the byte[] representation of the parsed +// plugin data is returned, again, with any matchers etc. removed +func (m *Message) GetMessageResponseContents() ([][]byte, error) { + + responses := make([][]byte, len(m.server.messages)) + if m.messageType == MESSAGE_TYPE_ASYNC { + return nil, errors.New("invalid request: asynchronous messages do not have response") + } + iter := C.pactffi_pact_handle_get_sync_message_iter(m.pact.handle) + if iter == nil { + return nil, errors.New("unable to get a message iterator") + } + + for i := 0; i < len(m.server.messages); i++ { + message := C.pactffi_pact_sync_message_iter_next(iter) + + if message == nil { + return nil, errors.New("retreived a null message pointer") + } + + // Get Response body + len := C.pactffi_sync_message_get_response_contents_length(message, C.ulong(i)) + if len == 0 { + return nil, errors.New("retreived an empty message") + } + data := C.pactffi_sync_message_get_response_contents_bin(message, C.ulong(i)) + if data == nil { + return nil, errors.New("retreived an empty pointer to the message contents") + } + ptr := unsafe.Pointer(data) + bytes := C.GoBytes(ptr, C.int(len)) + + responses[i] = bytes + } + + return responses, nil +} + +// StartTransport starts up a mock server on the given address:port for the given transport +// https://docs.rs/pact_ffi/latest/pact_ffi/mock_server/fn.pactffi_create_mock_server_for_transport.html +func (m *MessageServer) StartTransport(transport string, address string, port int, config map[string][]interface{}) (int, error) { + if len(m.messages) == 0 { + return 0, ErrNoInteractions + } + + log.Println("[DEBUG] mock server starting on address:", address, port) + cAddress := C.CString(address) + defer free(cAddress) + + cTransport := C.CString(transport) + defer free(cTransport) + + configJson := stringFromInterface(config) + cConfig := C.CString(configJson) + defer free(cConfig) + + p := C.pactffi_create_mock_server_for_transport(m.messagePact.handle, cAddress, C.int(port), cTransport, cConfig) + + // | Error | Description + // |-------|------------- + // | -1 | An invalid handle was received. Handles should be created with pactffi_new_pact + // | -2 | transport_config is not valid JSON + // | -3 | The mock server could not be started + // | -4 | The method panicked + // | -5 | The address is not valid + msPort := int(p) + switch msPort { + case -1: + return 0, ErrInvalidMockServerConfig + case -2: + return 0, ErrInvalidMockServerConfig + case -3: + return 0, ErrMockServerUnableToStart + case -4: + return 0, ErrMockServerPanic + case -5: + return 0, ErrInvalidAddress + default: + if msPort > 0 { + log.Println("[DEBUG] mock server running on port:", msPort) + return msPort, nil + } + return msPort, fmt.Errorf("an unknown error (code: %v) occurred when starting a mock server for the test", msPort) + } +} + +// Get the length of the request contents of a `SynchronousMessage`. +size_t pactffi_sync_message_get_request_contents_length(SynchronousMessage *message); +struct PactSyncMessageIterator *pactffi_pact_handle_get_sync_message_iter(PactHandle pact); +struct SynchronousMessage *pactffi_pact_sync_message_iter_next(struct PactSyncMessageIterator *iter); + +// Async +// Get the length of the contents of a `Message`. +size_t pactffi_message_get_contents_length(Message *message); + +// Get the contents of a `Message` as a pointer to an array of bytes. +const unsigned char *pactffi_message_get_contents_bin(const Message *message); +struct PactMessageIterator *pactffi_pact_handle_get_message_iter(PactHandle pact); +struct Message *pactffi_pact_message_iter_next(struct PactMessageIterator *iter); + +// Need the index of the body to get +const unsigned char *pactffi_sync_message_get_response_contents_bin(const struct SynchronousMessage *message, size_t index); +size_t pactffi_sync_message_get_response_contents_length(const struct SynchronousMessage *message, size_t index); + +// Sync +// Get the request contents of a `SynchronousMessage` as a pointer to an array of bytes. +// The number of bytes in the buffer will be returned by `pactffi_sync_message_get_request_contents_length`. +const unsigned char *pactffi_sync_message_get_request_contents_bin(SynchronousMessage *message); +// Set Sync message request body - non binary +void pactffi_sync_message_set_request_contents(InteractionHandle *message, const char *contents, const char *content_type); + +// Set Sync message request body - binary +void pactffi_sync_message_set_request_contents_bin(InteractionHandle *message, const unsigned char *contents, size_t len, const char *content_type); + +// Set sync message response contents - non binary +void pactffi_sync_message_set_response_contents(InteractionHandle *message, size_t index, const char *contents, const char *content_type); + +// Set sync message response contents - binary +void pactffi_sync_message_set_response_contents_bin(InteractionHandle *message, size_t index, const unsigned char *contents, size_t len, const char *content_type); +*/ \ No newline at end of file diff --git a/native/consumer.h b/native/consumer.h index 0c478ad2..bd5d7c93 100644 --- a/native/consumer.h +++ b/native/consumer.h @@ -11,6 +11,7 @@ Napi::Value PactffiWithQueryParameter(const Napi::CallbackInfo& info); Napi::Value PactffiWithRequest(const Napi::CallbackInfo& info); Napi::Value PactffiWithSpecification(const Napi::CallbackInfo& info); Napi::Value PactffiWritePactFile(const Napi::CallbackInfo& info); +Napi::Value PactffiWritePactFileByPort(const Napi::CallbackInfo& info); Napi::Value PactffiCleanupMockServer(const Napi::CallbackInfo& info); Napi::Value PactffiCreateMockServerForPact(const Napi::CallbackInfo& info); Napi::Value PactffiCreateMockServer(const Napi::CallbackInfo& info); @@ -123,3 +124,4 @@ Napi::Value PactffiSyncMessageGetResponseContents(const Napi::CallbackInfo& info Napi::Value PactffiSyncMessageGetResponseContentsBin(const Napi::CallbackInfo& info); Napi::Value PactffiSyncMessageGetResponseContentsLength(const Napi::CallbackInfo& info); Napi::Value PactffiSyncMessageSetDescription(const Napi::CallbackInfo& info); +Napi::Value PactffiCreateMockServerForTransport(const Napi::CallbackInfo& info); diff --git a/native/provider.cc b/native/provider.cc index 7d6f4547..117b8dd0 100644 --- a/native/provider.cc +++ b/native/provider.cc @@ -152,7 +152,6 @@ Napi::Value PactffiVerifierShutdown(const Napi::CallbackInfo& info) { return info.Env().Undefined(); } - /** * Set the provider details for the Pact verifier. Passing a NULL for any field will * use the default value for that field. @@ -214,6 +213,105 @@ Napi::Value PactffiVerifierSetProviderInfo(const Napi::CallbackInfo& info) { return info.Env().Undefined(); } + +/** + * Adds a new transport for the given provider. Passing a NULL for any field will + * use the default value for that field. + * + * For non-plugin based message interactions, set protocol to "message" and set scheme + * to an empty string or "https" if secure HTTP is required. Communication to the calling + * application will be over HTTP to the default provider hostname. + * + * # Safety + * + * All string fields must contain valid UTF-8. Invalid UTF-8 + * will be replaced with U+FFFD REPLACEMENT CHARACTER. + * + * C interface: + * + * + * void pactffi_verifier_add_provider_transport(VerifierHandle *handle, + * const char *protocol, + * unsigned short port, + * const char *path + * const char *scheme); + * + * */ +Napi::Value PactffiVerifierAddProviderTransport(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 5) { + throw Napi::Error::New(env, "PactffiVerifierAddProviderTransport received < 6 arguments"); + } + + if (!info[0].IsNumber()) { + throw Napi::Error::New(env, "PactffiVerifierAddProviderTransport(arg 0) expected a VerifierHandle"); + } + + if (!info[1].IsString()) { + throw Napi::Error::New(env, "PactffiVerifierAddProviderTransport(arg 1) expected a string"); + } + + if (!info[2].IsNumber()) { + throw Napi::Error::New(env, "PactffiVerifierAddProviderTransport(arg 2) expected a number"); + } + + if (!info[3].IsString()) { + throw Napi::Error::New(env, "PactffiVerifierAddProviderTransport(arg 3) expected a string"); + } + + if (!info[4].IsString()) { + throw Napi::Error::New(env, "PactffiVerifierAddProviderTransport(arg 4) expected a string"); + } + + uint32_t handleId = info[0].As().Uint32Value(); + std::string protocol = info[1].As().Utf8Value(); + uint32_t port = info[2].As().Uint32Value(); + std::string path = info[3].As().Utf8Value(); + std::string scheme = info[4].As().Utf8Value(); + + pactffi_verifier_add_provider_transport(handles[handleId], protocol.c_str(), port, path.c_str(), scheme.c_str()); + + return info.Env().Undefined(); +} + +/** + * Enables or disables if no pacts are found to verify results in an error. + * + * `is_error` is a boolean value. Set it to greater than zero to enable an error when no pacts + * are found to verify, and set it to zero to disable this. + * + * # Safety + * + * This function is safe as long as the handle pointer points to a valid handle. + * + * C interface: + * + * + * int pactffi_verifier_set_no_pacts_is_error(struct VerifierHandle *handle, unsigned char is_error); + * + * */ +Napi::Value PactffiVerifierSetNoPactsIsError(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2) { + throw Napi::Error::New(env, "PactffiVerifierSetProviderInfo received < 2 arguments"); + } + + if (!info[0].IsNumber()) { + throw Napi::Error::New(env, "pactffiVerifierSetProviderInfo(arg 0) expected a VerifierHandle"); + } + + if (!info[1].IsBoolean()) { + throw Napi::Error::New(env, "pactffiVerifierSetProviderInfo(arg 1 expected a boolean"); + } + + uint32_t handleId = info[0].As().Uint32Value(); + bool isError = info[1].As().Value(); + + pactffi_verifier_set_no_pacts_is_error(handles[handleId], isError); + + return info.Env().Undefined(); +} + /** * Set the filters for the Pact verifier. * diff --git a/native/provider.h b/native/provider.h index 01020fec..39502505 100644 --- a/native/provider.h +++ b/native/provider.h @@ -13,6 +13,8 @@ Napi::Value PactffiVerifierAddCustomHeader(const Napi::CallbackInfo& info); Napi::Value PactffiVerifierAddFileSource(const Napi::CallbackInfo& info); Napi::Value PactffiVerifierAddDirectorySource(const Napi::CallbackInfo& info); Napi::Value PactffiVerifierUrlSource(const Napi::CallbackInfo& info); +Napi::Value PactffiVerifierAddProviderTransport(const Napi::CallbackInfo& info); +Napi::Value PactffiVerifierSetNoPactsIsError(const Napi::CallbackInfo& info); // Napi::Value PactffiVerifierBrokerSource(const Napi::CallbackInfo& info); Napi::Value PactffiVerifierBrokerSourceWithSelectors(const Napi::CallbackInfo& info); Napi::Value PactffiVerifierSetFailIfNoPactsFound(const Napi::CallbackInfo& info); diff --git a/package-lock.json b/package-lock.json index 555ea65d..f6be7114 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,8 @@ "pactflow": "bin/pactflow.js" }, "devDependencies": { + "@grpc/grpc-js": "^1.7.1", + "@grpc/proto-loader": "^0.7.3", "@pact-foundation/pact-js-prettier-config": "^1.0.0", "@tsconfig/node14": "^1.0.3", "@types/basic-auth": "^1.1.2", @@ -81,6 +83,7 @@ "eslint-config-prettier": "^8.3.0", "express": "4.16.2", "form-data": "^4.0.0", + "grpc-promise": "^1.4.0", "mocha": "^9.1.3", "nodemon": "^2.0.4", "prettier": "^2.3.0", @@ -835,6 +838,80 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/@grpc/grpc-js": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.7.1.tgz", + "integrity": "sha512-GVtMU4oh/TeKkWGzXUEsyZtyvSUIT1z49RtGH1UnEGeL+sLuxKl8QH3KZTlSB329R1sWJmesm5hQ5CxXdYH9dg==", + "dev": true, + "dependencies": { + "@grpc/proto-loader": "^0.7.0", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@types/node": { + "version": "18.8.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.4.tgz", + "integrity": "sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow==", + "dev": true + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.3.tgz", + "integrity": "sha512-5dAvoZwna2Py3Ef96Ux9jIkp3iZ62TUsV00p3wVBPNX5K178UbNi8Q7gQVqwXT1Yq9RejIGG9G2IPEo93T6RcA==", + "dev": true, + "dependencies": { + "@types/long": "^4.0.1", + "lodash.camelcase": "^4.3.0", + "long": "^4.0.0", + "protobufjs": "^7.0.0", + "yargs": "^16.2.0" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/@types/node": { + "version": "18.8.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.4.tgz", + "integrity": "sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow==", + "dev": true + }, + "node_modules/@grpc/proto-loader/node_modules/protobufjs": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.1.2.tgz", + "integrity": "sha512-4ZPTPkXCdel3+L81yw3dG6+Kq3umdWKh7Dc7GW/CpNk4SX3hK58iPCWeCyhVTDrbkNeKrYNZ7EojM5WDaEWTLQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@grpc/proto-loader/node_modules/protobufjs/node_modules/long": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.0.tgz", + "integrity": "sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w==", + "dev": true + }, "node_modules/@hapi/bourne": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.0.0.tgz", @@ -5235,6 +5312,12 @@ "node": ">=4.x" } }, + "node_modules/grpc-promise": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/grpc-promise/-/grpc-promise-1.4.0.tgz", + "integrity": "sha512-4BBXHXb5OjjBh7luylu8vFqL6H6aPn/LeqpQaSBeRzO/Xv95wHW/WkU9TJRqaCTMZ5wq9jTSvlJWp0vRJy1pVA==", + "dev": true + }, "node_modules/handlebars": { "version": "4.7.7", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", @@ -6872,6 +6955,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -10810,6 +10899,73 @@ } } }, + "@grpc/grpc-js": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.7.1.tgz", + "integrity": "sha512-GVtMU4oh/TeKkWGzXUEsyZtyvSUIT1z49RtGH1UnEGeL+sLuxKl8QH3KZTlSB329R1sWJmesm5hQ5CxXdYH9dg==", + "dev": true, + "requires": { + "@grpc/proto-loader": "^0.7.0", + "@types/node": ">=12.12.47" + }, + "dependencies": { + "@types/node": { + "version": "18.8.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.4.tgz", + "integrity": "sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow==", + "dev": true + } + } + }, + "@grpc/proto-loader": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.3.tgz", + "integrity": "sha512-5dAvoZwna2Py3Ef96Ux9jIkp3iZ62TUsV00p3wVBPNX5K178UbNi8Q7gQVqwXT1Yq9RejIGG9G2IPEo93T6RcA==", + "dev": true, + "requires": { + "@types/long": "^4.0.1", + "lodash.camelcase": "^4.3.0", + "long": "^4.0.0", + "protobufjs": "^7.0.0", + "yargs": "^16.2.0" + }, + "dependencies": { + "@types/node": { + "version": "18.8.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.4.tgz", + "integrity": "sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow==", + "dev": true + }, + "protobufjs": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.1.2.tgz", + "integrity": "sha512-4ZPTPkXCdel3+L81yw3dG6+Kq3umdWKh7Dc7GW/CpNk4SX3hK58iPCWeCyhVTDrbkNeKrYNZ7EojM5WDaEWTLQ==", + "dev": true, + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "dependencies": { + "long": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.0.tgz", + "integrity": "sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w==", + "dev": true + } + } + } + } + }, "@hapi/bourne": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.0.0.tgz", @@ -14337,6 +14493,12 @@ "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true }, + "grpc-promise": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/grpc-promise/-/grpc-promise-1.4.0.tgz", + "integrity": "sha512-4BBXHXb5OjjBh7luylu8vFqL6H6aPn/LeqpQaSBeRzO/Xv95wHW/WkU9TJRqaCTMZ5wq9jTSvlJWp0vRJy1pVA==", + "dev": true + }, "handlebars": { "version": "4.7.7", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", @@ -15602,6 +15764,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true + }, "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", diff --git a/package.json b/package.json index b07b91bd..ca616f15 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "unixify": "1.0.0" }, "devDependencies": { + "@grpc/grpc-js": "^1.7.1", + "@grpc/proto-loader": "^0.7.3", "@pact-foundation/pact-js-prettier-config": "^1.0.0", "@tsconfig/node14": "^1.0.3", "@types/basic-auth": "^1.1.2", @@ -104,6 +106,7 @@ "eslint-config-prettier": "^8.3.0", "express": "4.16.2", "form-data": "^4.0.0", + "grpc-promise": "^1.4.0", "mocha": "^9.1.3", "nodemon": "^2.0.4", "prettier": "^2.3.0", diff --git a/src/consumer/index.ts b/src/consumer/index.ts index daef79f2..46ae55fe 100644 --- a/src/consumer/index.ts +++ b/src/consumer/index.ts @@ -16,12 +16,13 @@ import { import { wrapAllWithCheck, wrapWithCheck } from './checkErrors'; import { + AsynchronousMessage, ConsumerInteraction, - ConsumerMessage, ConsumerMessagePact, ConsumerPact, MatchingResult, Mismatch, + SynchronousMessage, } from './types'; import { getFfiLib } from '../ffi'; @@ -237,6 +238,19 @@ export const makeConsumerPact = ( return true; }, + withPluginRequestResponseInteractionContents: ( + contentType: string, + contents: string + ) => { + ffi.pactffiPluginInteractionContents( + interactionPtr, + INTERACTION_PART_REQUEST, + contentType, + contents + ); + + return true; + }, withPluginResponseInteractionContents: ( contentType: string, contents: string @@ -254,7 +268,7 @@ export const makeConsumerPact = ( }; }; -export const makeConsumerAsyncMessagePact = ( +export const makeConsumerMessagePact = ( consumer: string, provider: string, version: FfiSpecificationVersion = 4, @@ -279,12 +293,20 @@ export const makeConsumerAsyncMessagePact = ( cleanupPlugins: () => { ffi.pactffiCleanupPlugins(pactPtr); }, + cleanupMockServer: (port: number): boolean => { + return wrapWithCheck<(port: number) => boolean>( + (port: number): boolean => ffi.pactffiCleanupMockServer(port), + 'cleanupMockServer' + )(port); + }, writePactFile: (dir: string, merge = true) => writePact(ffi, pactPtr, dir, merge), + writePactFileForPluginServer: (port: number, dir: string, merge = true) => + writePact(ffi, pactPtr, dir, merge, port), addMetadata: (namespace: string, name: string, value: string): boolean => { return ffi.pactffiWithPactMetadata(pactPtr, namespace, name, value); }, - newMessage: (description: string): ConsumerMessage => { + newAsynchronousMessage: (description: string): AsynchronousMessage => { const interactionPtr = ffi.pactffiNewAsyncMessage(pactPtr, description); return { @@ -300,18 +322,6 @@ export const makeConsumerAsyncMessagePact = ( ); return true; }, - withPluginResponseInteractionContents: ( - contentType: string, - contents: string - ) => { - ffi.pactffiPluginInteractionContents( - interactionPtr, - INTERACTION_PART_RESPONSE, - contentType, - contents - ); - return true; - }, expectsToReceive: (description: string) => { return ffi.pactffiMessageExpectsToReceive( interactionPtr, @@ -352,6 +362,124 @@ export const makeConsumerAsyncMessagePact = ( }, }; }, + newSynchronousMessage: (description: string): SynchronousMessage => { + // TODO: will this automatically set the correct spec version? + const interactionPtr = ffi.pactffiNewSyncMessage(pactPtr, description); + + return { + withPluginRequestInteractionContents: ( + contentType: string, + contents: string + ) => { + ffi.pactffiPluginInteractionContents( + interactionPtr, + INTERACTION_PART_REQUEST, + contentType, + contents + ); + return true; + }, + withPluginResponseInteractionContents: ( + contentType: string, + contents: string + ) => { + ffi.pactffiPluginInteractionContents( + interactionPtr, + INTERACTION_PART_RESPONSE, + contentType, + contents + ); + return true; + }, + withPluginRequestResponseInteractionContents: ( + contentType: string, + contents: string + ) => { + ffi.pactffiPluginInteractionContents( + interactionPtr, + INTERACTION_PART_REQUEST, + contentType, + contents + ); + return true; + }, + given: (state: string) => { + return ffi.pactffiGiven(interactionPtr, state); + }, + givenWithParam: (state: string, name: string, value: string) => { + return ffi.pactffiGivenWithParam(interactionPtr, state, name, value); + }, + withRequestContents: (body: string, contentType: string) => { + return ffi.pactffiWithBody( + interactionPtr, + INTERACTION_PART_REQUEST, + contentType, + body + ); + }, + withResponseContents: (body: string, contentType: string) => { + return ffi.pactffiWithBody( + interactionPtr, + INTERACTION_PART_RESPONSE, + contentType, + body + ); + }, + withRequestBinaryContents: (body: Buffer, contentType: string) => { + return ffi.pactffiWithBinaryFile( + interactionPtr, + INTERACTION_PART_REQUEST, + contentType, + body, + body.length + ); + }, + withResponseBinaryContents: (body: Buffer, contentType: string) => { + return ffi.pactffiWithBinaryFile( + interactionPtr, + INTERACTION_PART_RESPONSE, + contentType, + body, + body.length + ); + }, + withMetadata: (name: string, value: string) => { + return ffi.pactffiMessageWithMetadata(interactionPtr, name, value); + }, + }; + }, + pactffiCreateMockServerForTransport( + address: string, + transport: string, + config: string, + port?: number + ) { + return ffi.pactffiCreateMockServerForTransport( + pactPtr, + address, + port || 0, + transport, + config + ); + }, + mockServerMatchedSuccessfully: (port: number) => { + return ffi.pactffiMockServerMatched(port); + }, + mockServerMismatches: (port: number): MatchingResult[] => { + const results: MatchingResult[] = JSON.parse( + ffi.pactffiMockServerMismatches(port) + ); + return results.map((result: MatchingResult) => ({ + ...result, + ...('mismatches' in result + ? { + mismatches: result.mismatches.map((m: string | Mismatch) => + typeof m === 'string' ? JSON.parse(m) : m + ), + } + : {}), + })); + }, }; }; @@ -359,9 +487,17 @@ const writePact = ( ffi: Ffi, pactPtr: FfiPactHandle, dir: string, - merge = true + merge = true, + port = 0 ) => { - const result = ffi.pactffiWritePactFile(pactPtr, dir, !merge); + let result: FfiWritePactResponse; + + if (port != 0) { + result = ffi.pactffiWritePactFileByPort(port, dir, !merge); + } else { + result = ffi.pactffiWritePactFile(pactPtr, dir, !merge); + } + switch (result) { case FfiWritePactResponse.SUCCESS: return; diff --git a/src/consumer/types.ts b/src/consumer/types.ts index 61df4efb..9f8b219e 100644 --- a/src/consumer/types.ts +++ b/src/consumer/types.ts @@ -110,20 +110,35 @@ export type RequestMismatch = { body?: string; }; -export type PluginInteraction = { +export type PluginInteraction = RequestPluginInteraction & + ResponsePluginInteraction & + RequestResponsePluginInteraction; + +export type RequestPluginInteraction = { withPluginRequestInteractionContents: ( contentType: string, contents: string ) => boolean; +}; + +export type ResponsePluginInteraction = { withPluginResponseInteractionContents: ( contentType: string, contents: string ) => boolean; }; +export type RequestResponsePluginInteraction = { + withPluginRequestResponseInteractionContents: ( + contentType: string, + contents: string + ) => boolean; +}; + export type PluginPact = { addPlugin: (plugin: string, version: string) => void; cleanupPlugins: () => void; + cleanupMockServer: (port: number) => boolean; }; export type ConsumerInteraction = PluginInteraction & { @@ -149,14 +164,6 @@ export type ConsumerInteraction = PluginInteraction & { filename: string, mimePartName: string ) => boolean; - withPluginRequestInteractionContents: ( - contentType: string, - contents: string - ) => void; - withPluginResponseInteractionContents: ( - contentType: string, - contents: string - ) => void; }; export type ConsumerPact = PluginPact & { @@ -185,7 +192,7 @@ export type ConsumerPact = PluginPact & { addMetadata: (namespace: string, name: string, value: string) => boolean; }; -export type ConsumerMessage = PluginInteraction & { +export type AsynchronousMessage = RequestPluginInteraction & { given: (state: string) => void; givenWithParam: (state: string, name: string, value: string) => void; expectsToReceive: (description: string) => void; @@ -195,15 +202,59 @@ export type ConsumerMessage = PluginInteraction & { reifyMessage: () => string; }; +export type SynchronousMessage = PluginInteraction & { + given: (state: string) => void; + givenWithParam: (state: string, name: string, value: string) => void; + withMetadata: (name: string, value: string) => void; + withRequestContents: (body: string, contentType: string) => void; + withResponseContents: (body: string, contentType: string) => void; + withRequestBinaryContents: (body: Buffer, contentType: string) => void; + withResponseBinaryContents: (body: Buffer, contentType: string) => void; + // TODO: need a type to return the contents back to the test + // reify could be the way to do it + // reifyMessage: () => string; +}; + export type ConsumerMessagePact = PluginPact & { - newMessage: (description: string) => ConsumerMessage; + newAsynchronousMessage: (description: string) => AsynchronousMessage; + newSynchronousMessage: (description: string) => SynchronousMessage; + pactffiCreateMockServerForTransport: ( + address: string, + transport: string, + config: string, + port?: number + ) => number; /** * This function writes the pact file, regardless of whether or not the test was successful. * Do not call it without checking that the tests were successful, unless you want to write the wrong pact contents. * * @param dir the directory to write the pact file to - * @param overwrite whether or not to overwrite the pact file contents (default true) + * @param merge whether or not to merge the pact file contents with previous test runs (default true) */ - writePactFile: (dir: string, overwrite?: boolean) => void; + writePactFile: (dir: string, merge?: boolean) => void; + /** + * This function writes the pact file, using the given plugin transport port. + * If you are using plugins in your test, you must use this method + * + * @param port The port that identifies the custom mock server + * @param dir The directory to write the pact file to + * @param merge whether or not to merge the pact file contents with previous test runs (default true) + * @returns + */ + writePactFileForPluginServer: ( + port: number, + dir: string, + merge?: boolean + ) => void; addMetadata: (namespace: string, name: string, value: string) => boolean; + mockServerMismatches: (port: number) => MatchingResult[]; + /** + * Check if a mock server has matched all its requests. + * + * @param port the port number the mock server is running on. + * @returns {boolean} true if all requests have been matched. False if there + * is no mock server on the given port, or if any request has not been successfully matched, or + * the method panics. + */ + mockServerMatchedSuccessfully: (port: number) => boolean; }; diff --git a/src/ffi/index.ts b/src/ffi/index.ts index e750d269..e39ba025 100644 --- a/src/ffi/index.ts +++ b/src/ffi/index.ts @@ -5,7 +5,7 @@ import bindings = require('bindings'); const ffiLib: Ffi = bindings('pact.node'); -export const PACT_FFI_VERSION = '0.3.12'; +export const PACT_FFI_VERSION = '0.3.13'; let ffi: typeof ffiLib; let ffiLogLevel: LogLevel; diff --git a/src/ffi/types.ts b/src/ffi/types.ts index 8a1aa7f8..bafabab9 100644 --- a/src/ffi/types.ts +++ b/src/ffi/types.ts @@ -128,6 +128,13 @@ export type FfiConsumerFunctions = { address: string, tls: boolean ): number; + pactffiCreateMockServerForTransport( + handle: FfiPactHandle, + address: string, + port: number, + transport: string, + config: string + ): number; pactffiNewPact(consumer: string, provider: string): FfiPactHandle; pactffiWithSpecification( handle: FfiPactHandle, @@ -198,6 +205,11 @@ export type FfiConsumerFunctions = { dir: string, overwrite: boolean ): FfiWritePactResponse; + pactffiWritePactFileByPort( + port: number, + dir: string, + overwrite: boolean + ): FfiWritePactResponse; pactffiCleanupMockServer(port: number): boolean; pactffiMockServerMatched(port: number): boolean; pactffiMockServerMismatches(port: number): string; @@ -224,8 +236,12 @@ export type FfiConsumerFunctions = { handle: FfiPactHandle, description: string ): FfiMessageHandle; - // TODO: not sure how to use this given the return type, commenting out for now - // pactffiNewSyncMessage( + pactffiNewSyncMessage( + handle: FfiPactHandle, + description: string + ): FfiInteractionHandle; + // TODO: need to look at how we return and handle a synchronous message + // pactffiSyncMessageSetDescription( // handle: FfiPactHandle, // description: string // ): FfiInteractionHandle; @@ -359,4 +375,11 @@ export type FfiVerificationFunctions = { callback: (e: Error, res: number) => void ): number; pactffiVerifierShutdown(handle: FfiVerifierHandle): void; + pactffiVerifierAddProviderTransport( + handle: FfiVerifierHandle, + protocol: string, + port: number, + path: string, + scheme: string + ): void; }; diff --git a/src/verifier/argumentMapper/arguments.ts b/src/verifier/argumentMapper/arguments.ts index 48211ae7..4f77b232 100644 --- a/src/verifier/argumentMapper/arguments.ts +++ b/src/verifier/argumentMapper/arguments.ts @@ -45,6 +45,7 @@ export const orderOfExecution: OrderedExecution = { pactffiVerifierAddCustomHeader: 8, pactffiVerifierAddDirectorySource: 9, pactffiVerifierBrokerSourceWithSelectors: 10, + pactffiVerifierAddProviderTransport: 11, }; export const ffiFnMapping: FnMapping< @@ -313,4 +314,24 @@ export const ffiFnMapping: FnMapping< }; }, }, + pactffiVerifierAddProviderTransport: { + validateAndExecute(ffi, handle, options) { + if (Array.isArray(options.transports)) { + options.transports.forEach((transport) => { + ffi.pactffiVerifierAddProviderTransport( + handle, + transport.protocol, + transport.port, + transport.path || '', + transport.scheme || '' + ); + }); + return { status: FnValidationStatus.SUCCESS }; + } + return { + status: FnValidationStatus.IGNORE, + messages: ['No additional provider transports provided'], + }; + }, + }, }; diff --git a/src/verifier/types.ts b/src/verifier/types.ts index 7789d914..6e3769a9 100644 --- a/src/verifier/types.ts +++ b/src/verifier/types.ts @@ -18,6 +18,13 @@ export type CustomHeaders = { [header: string]: string; }; +export interface Transport { + protocol: string; + port: number; + scheme?: string; + path?: string; +} + export interface VerifierOptions { providerBaseUrl: string; provider?: string; @@ -43,6 +50,7 @@ export interface VerifierOptions { buildUrl?: string; customProviderHeaders?: CustomHeaders | string[]; consumerFilters?: string[]; + transports?: Transport[]; /** * @deprecated use providerVersionBranch instead */ diff --git a/src/verifier/validateOptions.ts b/src/verifier/validateOptions.ts index 48538fa3..98a442ab 100644 --- a/src/verifier/validateOptions.ts +++ b/src/verifier/validateOptions.ts @@ -250,6 +250,7 @@ export const validationRules: ArgumentValidationRules { diff --git a/test/consumer.integration.spec.ts b/test/consumer.integration.spec.ts index b9d589fd..3b145e13 100644 --- a/test/consumer.integration.spec.ts +++ b/test/consumer.integration.spec.ts @@ -262,7 +262,7 @@ describe('FFI integration test for the HTTP Consumer API', () => { 'bar-provider', FfiSpecificationVersion.SPECIFICATION_VERSION_V3 ); - pact.addPlugin('protobuf', '0.0.3'); + pact.addPlugin('protobuf', '0.1.14'); const interaction = pact.newInteraction('some description'); const protobufContents = { diff --git a/test/integration/grpc/grpc.json b/test/integration/grpc/grpc.json new file mode 100644 index 00000000..74850ce0 --- /dev/null +++ b/test/integration/grpc/grpc.json @@ -0,0 +1,153 @@ +{ + "consumer": { + "name": "grpcconsumer" + }, + "interactions": [ + { + "description": "A request to do a foo", + "key": "539a26be10e0124e", + "pending": false, + "request": { + "body": { + "content": { + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Authorization": [ + "Bearer 1234" + ], + "Content-Type": [ + "application/json" + ] + }, + "method": "POST", + "path": "/foobar" + }, + "response": { + "body": { + "content": {}, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "status": 200 + }, + "transport": "http", + "type": "Synchronous/HTTP" + }, + { + "description": "Route guide - GetFeature", + "interactionMarkup": { + "markup": "```protobuf\nmessage Feature {\n string name = 1;\n message .routeguide.Point location = 2;\n}\n```\n", + "markupType": "COMMON_MARK" + }, + "key": "d81a62841ce862db", + "pending": false, + "pluginConfiguration": { + "protobuf": { + "descriptorKey": "32f7898819c9f3ece72c5f9de784d705", + "service": "RouteGuide/GetFeature" + } + }, + "request": { + "contents": { + "content": "CLQBEMgB", + "contentType": "application/protobuf;message=Point", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.latitude": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.longitude": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + } + }, + "response": [ + { + "contents": { + "content": "CghCaWcgVHJlZRIGCLQBEMgB", + "contentType": "application/protobuf;message=Feature", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.location.latitude": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.location.longitude": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.name": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + } + } + ], + "transport": "grpc", + "type": "Synchronous/Messages" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.3.9", + "mockserver": "0.9.4", + "models": "0.4.4" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": { + "32f7898819c9f3ece72c5f9de784d705": { + "protoDescriptors": "CukGChFyb3V0ZV9ndWlkZS5wcm90bxIKcm91dGVndWlkZSJBCgVQb2ludBIaCghsYXRpdHVkZRgBIAEoBVIIbGF0aXR1ZGUSHAoJbG9uZ2l0dWRlGAIgASgFUglsb25naXR1ZGUiUQoJUmVjdGFuZ2xlEiEKAmxvGAEgASgLMhEucm91dGVndWlkZS5Qb2ludFICbG8SIQoCaGkYAiABKAsyES5yb3V0ZWd1aWRlLlBvaW50UgJoaSJMCgdGZWF0dXJlEhIKBG5hbWUYASABKAlSBG5hbWUSLQoIbG9jYXRpb24YAiABKAsyES5yb3V0ZWd1aWRlLlBvaW50Ughsb2NhdGlvbiJUCglSb3V0ZU5vdGUSLQoIbG9jYXRpb24YASABKAsyES5yb3V0ZWd1aWRlLlBvaW50Ughsb2NhdGlvbhIYCgdtZXNzYWdlGAIgASgJUgdtZXNzYWdlIpMBCgxSb3V0ZVN1bW1hcnkSHwoLcG9pbnRfY291bnQYASABKAVSCnBvaW50Q291bnQSIwoNZmVhdHVyZV9jb3VudBgCIAEoBVIMZmVhdHVyZUNvdW50EhoKCGRpc3RhbmNlGAMgASgFUghkaXN0YW5jZRIhCgxlbGFwc2VkX3RpbWUYBCABKAVSC2VsYXBzZWRUaW1lMoUCCgpSb3V0ZUd1aWRlEjYKCkdldEZlYXR1cmUSES5yb3V0ZWd1aWRlLlBvaW50GhMucm91dGVndWlkZS5GZWF0dXJlIgASPgoMTGlzdEZlYXR1cmVzEhUucm91dGVndWlkZS5SZWN0YW5nbGUaEy5yb3V0ZWd1aWRlLkZlYXR1cmUiADABEj4KC1JlY29yZFJvdXRlEhEucm91dGVndWlkZS5Qb2ludBoYLnJvdXRlZ3VpZGUuUm91dGVTdW1tYXJ5IgAoARI/CglSb3V0ZUNoYXQSFS5yb3V0ZWd1aWRlLlJvdXRlTm90ZRoVLnJvdXRlZ3VpZGUuUm91dGVOb3RlIgAoATABQmgKG2lvLmdycGMuZXhhbXBsZXMucm91dGVndWlkZUIPUm91dGVHdWlkZVByb3RvUAFaNmdvb2dsZS5nb2xhbmcub3JnL2dycGMvZXhhbXBsZXMvcm91dGVfZ3VpZGUvcm91dGVndWlkZWIGcHJvdG8z", + "protoFile": "// Copyright 2015 gRPC authors.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nsyntax = \"proto3\";\n\noption go_package = \"google.golang.org/grpc/examples/route_guide/routeguide\";\noption java_multiple_files = true;\noption java_package = \"io.grpc.examples.routeguide\";\noption java_outer_classname = \"RouteGuideProto\";\n\npackage routeguide;\n\n// Interface exported by the server.\nservice RouteGuide {\n // A simple RPC.\n //\n // Obtains the feature at a given position.\n //\n // A feature with an empty name is returned if there's no feature at the given\n // position.\n rpc GetFeature(Point) returns (Feature) {}\n\n // A server-to-client streaming RPC.\n //\n // Obtains the Features available within the given Rectangle. Results are\n // streamed rather than returned at once (e.g. in a response message with a\n // repeated field), as the rectangle may cover a large area and contain a\n // huge number of features.\n rpc ListFeatures(Rectangle) returns (stream Feature) {}\n\n // A client-to-server streaming RPC.\n //\n // Accepts a stream of Points on a route being traversed, returning a\n // RouteSummary when traversal is completed.\n rpc RecordRoute(stream Point) returns (RouteSummary) {}\n\n // A Bidirectional streaming RPC.\n //\n // Accepts a stream of RouteNotes sent while a route is being traversed,\n // while receiving other RouteNotes (e.g. from other users).\n rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}\n}\n\n// Points are represented as latitude-longitude pairs in the E7 representation\n// (degrees multiplied by 10**7 and rounded to the nearest integer).\n// Latitudes should be in the range +/- 90 degrees and longitude should be in\n// the range +/- 180 degrees (inclusive).\nmessage Point {\n int32 latitude = 1;\n int32 longitude = 2;\n}\n\n// A latitude-longitude rectangle, represented as two diagonally opposite\n// points \"lo\" and \"hi\".\nmessage Rectangle {\n // One corner of the rectangle.\n Point lo = 1;\n\n // The other corner of the rectangle.\n Point hi = 2;\n}\n\n// A feature names something at a given point.\n//\n// If a feature could not be named, the name is empty.\nmessage Feature {\n // The name of the feature.\n string name = 1;\n\n // The point where the feature is detected.\n Point location = 2;\n}\n\n// A RouteNote is a message sent while at a given point.\nmessage RouteNote {\n // The location from which the message is sent.\n Point location = 1;\n\n // The message to be sent.\n string message = 2;\n}\n\n// A RouteSummary is received in response to a RecordRoute rpc.\n//\n// It contains the number of individual points received, the number of\n// detected features, and the total distance covered as the cumulative sum of\n// the distance between each point.\nmessage RouteSummary {\n // The number of points received.\n int32 point_count = 1;\n\n // The number of known features passed while traversing the route.\n int32 feature_count = 2;\n\n // The distance covered in metres.\n int32 distance = 3;\n\n // The duration of the traversal in seconds.\n int32 elapsed_time = 4;\n}\n" + } + }, + "name": "protobuf", + "version": "0.1.14" + } + ] + }, + "provider": { + "name": "grpcprovider" + } +} \ No newline at end of file diff --git a/test/integration/grpc/grpc.json.bak b/test/integration/grpc/grpc.json.bak new file mode 100644 index 00000000..c6a4aaae --- /dev/null +++ b/test/integration/grpc/grpc.json.bak @@ -0,0 +1,127 @@ +{ + "consumer": { + "name": "message-consumer" + }, + "interactions": [ + { + "description": "a grpc test", + "interactionMarkup": { + "markup": "```protobuf\nmessage Feature {\n string name = 1;\n message .routeguide.Point location = 2;\n}\n```\n", + "markupType": "COMMON_MARK" + }, + "key": "77ba5cdb5c36d34f", + "pending": false, + "pluginConfiguration": { + "protobuf": { + "descriptorKey": "32f7898819c9f3ece72c5f9de784d705", + "service": "RouteGuide/GetFeature" + } + }, + "providerStates": [ + { + "name": "some state" + }, + { + "name": "some state 2", + "params": { + "state2 key": "state2 val" + } + } + ], + "request": { + "contents": { + "content": "CLQBEMgB", + "contentType": "application/protobuf;message=Point", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.latitude": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.longitude": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + } + }, + "response": [ + { + "contents": { + "content": "CghCaWcgVHJlZRIGCLQBEMgB", + "contentType": "application/protobuf;message=Feature", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.location.latitude": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.location.longitude": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.name": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + } + } + ], + "type": "Synchronous/Messages" + } + ], + "metadata": { + "pact-node": { + "meta-key": "meta-val" + }, + "pactRust": { + "ffi": "0.3.13", + "models": "0.4.5" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": { + "32f7898819c9f3ece72c5f9de784d705": { + "protoDescriptors": "CukGChFyb3V0ZV9ndWlkZS5wcm90bxIKcm91dGVndWlkZSJBCgVQb2ludBIaCghsYXRpdHVkZRgBIAEoBVIIbGF0aXR1ZGUSHAoJbG9uZ2l0dWRlGAIgASgFUglsb25naXR1ZGUiUQoJUmVjdGFuZ2xlEiEKAmxvGAEgASgLMhEucm91dGVndWlkZS5Qb2ludFICbG8SIQoCaGkYAiABKAsyES5yb3V0ZWd1aWRlLlBvaW50UgJoaSJMCgdGZWF0dXJlEhIKBG5hbWUYASABKAlSBG5hbWUSLQoIbG9jYXRpb24YAiABKAsyES5yb3V0ZWd1aWRlLlBvaW50Ughsb2NhdGlvbiJUCglSb3V0ZU5vdGUSLQoIbG9jYXRpb24YASABKAsyES5yb3V0ZWd1aWRlLlBvaW50Ughsb2NhdGlvbhIYCgdtZXNzYWdlGAIgASgJUgdtZXNzYWdlIpMBCgxSb3V0ZVN1bW1hcnkSHwoLcG9pbnRfY291bnQYASABKAVSCnBvaW50Q291bnQSIwoNZmVhdHVyZV9jb3VudBgCIAEoBVIMZmVhdHVyZUNvdW50EhoKCGRpc3RhbmNlGAMgASgFUghkaXN0YW5jZRIhCgxlbGFwc2VkX3RpbWUYBCABKAVSC2VsYXBzZWRUaW1lMoUCCgpSb3V0ZUd1aWRlEjYKCkdldEZlYXR1cmUSES5yb3V0ZWd1aWRlLlBvaW50GhMucm91dGVndWlkZS5GZWF0dXJlIgASPgoMTGlzdEZlYXR1cmVzEhUucm91dGVndWlkZS5SZWN0YW5nbGUaEy5yb3V0ZWd1aWRlLkZlYXR1cmUiADABEj4KC1JlY29yZFJvdXRlEhEucm91dGVndWlkZS5Qb2ludBoYLnJvdXRlZ3VpZGUuUm91dGVTdW1tYXJ5IgAoARI/CglSb3V0ZUNoYXQSFS5yb3V0ZWd1aWRlLlJvdXRlTm90ZRoVLnJvdXRlZ3VpZGUuUm91dGVOb3RlIgAoATABQmgKG2lvLmdycGMuZXhhbXBsZXMucm91dGVndWlkZUIPUm91dGVHdWlkZVByb3RvUAFaNmdvb2dsZS5nb2xhbmcub3JnL2dycGMvZXhhbXBsZXMvcm91dGVfZ3VpZGUvcm91dGVndWlkZWIGcHJvdG8z", + "protoFile": "// Copyright 2015 gRPC authors.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nsyntax = \"proto3\";\n\noption go_package = \"google.golang.org/grpc/examples/route_guide/routeguide\";\noption java_multiple_files = true;\noption java_package = \"io.grpc.examples.routeguide\";\noption java_outer_classname = \"RouteGuideProto\";\n\npackage routeguide;\n\n// Interface exported by the server.\nservice RouteGuide {\n // A simple RPC.\n //\n // Obtains the feature at a given position.\n //\n // A feature with an empty name is returned if there's no feature at the given\n // position.\n rpc GetFeature(Point) returns (Feature) {}\n\n // A server-to-client streaming RPC.\n //\n // Obtains the Features available within the given Rectangle. Results are\n // streamed rather than returned at once (e.g. in a response message with a\n // repeated field), as the rectangle may cover a large area and contain a\n // huge number of features.\n rpc ListFeatures(Rectangle) returns (stream Feature) {}\n\n // A client-to-server streaming RPC.\n //\n // Accepts a stream of Points on a route being traversed, returning a\n // RouteSummary when traversal is completed.\n rpc RecordRoute(stream Point) returns (RouteSummary) {}\n\n // A Bidirectional streaming RPC.\n //\n // Accepts a stream of RouteNotes sent while a route is being traversed,\n // while receiving other RouteNotes (e.g. from other users).\n rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}\n}\n\n// Points are represented as latitude-longitude pairs in the E7 representation\n// (degrees multiplied by 10**7 and rounded to the nearest integer).\n// Latitudes should be in the range +/- 90 degrees and longitude should be in\n// the range +/- 180 degrees (inclusive).\nmessage Point {\n int32 latitude = 1;\n int32 longitude = 2;\n}\n\n// A latitude-longitude rectangle, represented as two diagonally opposite\n// points \"lo\" and \"hi\".\nmessage Rectangle {\n // One corner of the rectangle.\n Point lo = 1;\n\n // The other corner of the rectangle.\n Point hi = 2;\n}\n\n// A feature names something at a given point.\n//\n// If a feature could not be named, the name is empty.\nmessage Feature {\n // The name of the feature.\n string name = 1;\n\n // The point where the feature is detected.\n Point location = 2;\n}\n\n// A RouteNote is a message sent while at a given point.\nmessage RouteNote {\n // The location from which the message is sent.\n Point location = 1;\n\n // The message to be sent.\n string message = 2;\n}\n\n// A RouteSummary is received in response to a RecordRoute rpc.\n//\n// It contains the number of individual points received, the number of\n// detected features, and the total distance covered as the cumulative sum of\n// the distance between each point.\nmessage RouteSummary {\n // The number of points received.\n int32 point_count = 1;\n\n // The number of known features passed while traversing the route.\n int32 feature_count = 2;\n\n // The distance covered in metres.\n int32 distance = 3;\n\n // The duration of the traversal in seconds.\n int32 elapsed_time = 4;\n}\n" + } + }, + "name": "protobuf", + "version": "0.1.14" + } + ] + }, + "provider": { + "name": "message-provider" + } +} \ No newline at end of file diff --git a/test/integration/grpc/route_guide.proto b/test/integration/grpc/route_guide.proto new file mode 100644 index 00000000..966c434a --- /dev/null +++ b/test/integration/grpc/route_guide.proto @@ -0,0 +1,111 @@ +// Copyright 2015 gRPC authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +option go_package = "google.golang.org/grpc/examples/route_guide/routeguide"; +option java_multiple_files = true; +option java_package = "io.grpc.examples.routeguide"; +option java_outer_classname = "RouteGuideProto"; + +package routeguide; + +// Interface exported by the server. +service RouteGuide { + // A simple RPC. + // + // Obtains the feature at a given position. + // + // A feature with an empty name is returned if there's no feature at the given + // position. + rpc GetFeature(Point) returns (Feature) {} + + // A server-to-client streaming RPC. + // + // Obtains the Features available within the given Rectangle. Results are + // streamed rather than returned at once (e.g. in a response message with a + // repeated field), as the rectangle may cover a large area and contain a + // huge number of features. + rpc ListFeatures(Rectangle) returns (stream Feature) {} + + // A client-to-server streaming RPC. + // + // Accepts a stream of Points on a route being traversed, returning a + // RouteSummary when traversal is completed. + rpc RecordRoute(stream Point) returns (RouteSummary) {} + + // A Bidirectional streaming RPC. + // + // Accepts a stream of RouteNotes sent while a route is being traversed, + // while receiving other RouteNotes (e.g. from other users). + rpc RouteChat(stream RouteNote) returns (stream RouteNote) {} +} + +// Points are represented as latitude-longitude pairs in the E7 representation +// (degrees multiplied by 10**7 and rounded to the nearest integer). +// Latitudes should be in the range +/- 90 degrees and longitude should be in +// the range +/- 180 degrees (inclusive). +message Point { + int32 latitude = 1; + int32 longitude = 2; +} + +// A latitude-longitude rectangle, represented as two diagonally opposite +// points "lo" and "hi". +message Rectangle { + // One corner of the rectangle. + Point lo = 1; + + // The other corner of the rectangle. + Point hi = 2; +} + +// A feature names something at a given point. +// +// If a feature could not be named, the name is empty. +message Feature { + // The name of the feature. + string name = 1; + + // The point where the feature is detected. + Point location = 2; +} + +// A RouteNote is a message sent while at a given point. +message RouteNote { + // The location from which the message is sent. + Point location = 1; + + // The message to be sent. + string message = 2; +} + +// A RouteSummary is received in response to a RecordRoute rpc. +// +// It contains the number of individual points received, the number of +// detected features, and the total distance covered as the cumulative sum of +// the distance between each point. +message RouteSummary { + // The number of points received. + int32 point_count = 1; + + // The number of known features passed while traversing the route. + int32 feature_count = 2; + + // The distance covered in metres. + int32 distance = 3; + + // The duration of the traversal in seconds. + int32 elapsed_time = 4; +} diff --git a/test/matt.consumer.integration.spec.ts b/test/matt.consumer.integration.spec.ts new file mode 100644 index 00000000..5c210de6 --- /dev/null +++ b/test/matt.consumer.integration.spec.ts @@ -0,0 +1,159 @@ +import axios from 'axios'; +import path = require('path'); +import chai = require('chai'); +import net = require('net'); +import chaiAsPromised = require('chai-as-promised'); +import { + ConsumerMessagePact, + ConsumerPact, + makeConsumerMessagePact, + makeConsumerPact, +} from '../src/consumer'; +import { setLogLevel } from '../src/logger'; +import { FfiSpecificationVersion } from '../src/ffi/types'; + +chai.use(chaiAsPromised); +const expect = chai.expect; + +describe('MATT protocol test', () => { + setLogLevel('trace'); + + let provider: ConsumerPact; + let tcpProvider: ConsumerMessagePact; + let port: number; + const HOST = '127.0.0.1'; + + describe('HTTP test', () => { + beforeEach(() => { + const mattRequest = `{"request": {"body": "hello"}}`; + const mattResponse = `{"response":{"body":"world"}}`; + + provider = makeConsumerPact('matt-consumer', 'matt-provider'); + provider.addPlugin('matt', '0.0.1'); + + const interaction = provider.newInteraction(''); + interaction.uponReceiving('A request to communicate via MATT'); + interaction.withRequest('POST', '/matt'); + interaction.withPluginRequestInteractionContents( + 'application/matt', + mattRequest + ); + interaction.withStatus(200); + interaction.withPluginResponseInteractionContents( + 'application/matt', + mattResponse + ); + + port = provider.createMockServer(HOST); + }); + + afterEach(() => { + provider.cleanupPlugins(); + provider.cleanupMockServer(port); + }); + + it('returns a valid MATT message over HTTP', () => { + return axios + .request({ + baseURL: `http://${HOST}:${port}`, + headers: { + 'content-type': 'application/matt', + Accept: 'application/matt', + }, + data: generateMattMessage('hello'), + method: 'POST', + url: '/matt', + }) + .then((res) => { + expect(parseMattMessage(res.data)).to.eq('world'); + }) + .then(() => { + expect(provider.mockServerMatchedSuccessfully(port)).to.be.true; + }) + .then(() => { + // You don't have to call this, it's just here to check it works + const mismatches = provider.mockServerMismatches(port); + expect(mismatches).to.have.length(0); + }) + .then(() => { + provider.writePactFile(path.join(__dirname, '__testoutput__')); + }); + }); + }); + + describe.only('TCP Messages', () => { + beforeEach(() => { + tcpProvider = makeConsumerMessagePact( + 'matt-tcp-consumer', + 'matt-tcp-provider', + FfiSpecificationVersion.SPECIFICATION_VERSION_V4 + ); + }); + describe('with MATT protocol', async () => { + afterEach(() => { + tcpProvider.writePactFileForPluginServer(port, path.join(__dirname, '__testoutput__')); + tcpProvider.cleanupPlugins() + // tcpProvider.cleanupMockServer(port) + }); + + beforeEach(() => { + const mattMessage = `{"request": {"body": "hellotcp"}, "response":{"body":"tcpworld"}}`; + tcpProvider.addPlugin('matt', '0.0.1'); + + const message = tcpProvider.newSynchronousMessage('a MATT message'); + message.withPluginRequestResponseInteractionContents( + 'application/matt', + mattMessage + ); + + // TODO: this seems not to be written to the pact file + port = tcpProvider.pactffiCreateMockServerForTransport( + HOST, + 'matt', + '' + ); + }); + + it('generates a pact with success', async () => { + const message = await sendMattMessageTCP('hello', HOST, port); + expect(message).to.eq('tcpworld'); + + const res = tcpProvider.mockServerMatchedSuccessfully(port); + expect(res).to.eq(true); + + const mismatches = tcpProvider.mockServerMismatches(port); + expect(mismatches.length).to.eq(0); + }); + }); + }); +}); + +const parseMattMessage = (raw: string): string => { + return raw.replace(/(MATT)+/g, '').trim(); +}; +const generateMattMessage = (raw: string): string => { + return `MATT${raw}MATT`; +}; + +const sendMattMessageTCP = ( + message: string, + host: string, + port: number +): Promise => { + const socket = net.connect({ + port: port, + host: host, + }); + + const res = socket.write(generateMattMessage(message) + '\n'); + + if (!res) { + throw Error('unable to connect to host'); + } + + return new Promise((resolve, reject) => { + socket.on('data', (data) => { + resolve(parseMattMessage(data.toString())); + }); + }); +}; diff --git a/test/matt.provider.integration.spec.ts b/test/matt.provider.integration.spec.ts new file mode 100644 index 00000000..f26e6030 --- /dev/null +++ b/test/matt.provider.integration.spec.ts @@ -0,0 +1,96 @@ +import path = require('path'); +import net = require('net'); +import chai = require('chai'); +import chaiAsPromised = require('chai-as-promised'); +import { setLogLevel } from '../src/logger'; +import verifier from '../src/verifier'; +import express = require('express'); +import * as http from 'http'; + +chai.use(chaiAsPromised); + +describe.only('MATT protocol test', () => { + setLogLevel('info'); + + describe('HTTP and TCP Provider', () => { + const HOST = '127.0.0.1'; + const HTTP_PORT = 8888; + const TCP_PORT = 8889; + beforeEach(async () => { + await startHTTPServer(HOST, HTTP_PORT); + await startTCPServer(HOST, TCP_PORT); + }); + + it('returns a valid MATT message over HTTP and TCP', () => { + return verifier({ + providerBaseUrl: 'http://localhost:8888', + transports: [ + { + port: TCP_PORT, + protocol: 'matt', + scheme: 'tcp', + }, + ], + pactUrls: [ + path.join( + __dirname, + '__testoutput__', + 'matt-consumer-matt-provider.json' + ), + path.join( + __dirname, + '__testoutput__', + 'matt-tcp-consumer-matt-tcp-provider.json' + ), + ], + }).verify(); + }); + }); +}); + +const startHTTPServer = (host: string, port: number): Promise => { + const server: express.Express = express(); + + server.post('/matt', (req, res) => { + console.log('received a /matt message', req.body); + res.setHeader('content-type', 'application/matt'); + res.send(generateMattMessage('world')); + }); + + let s: http.Server; + return new Promise((resolve) => { + s = server.listen(port, host, () => resolve()); + }).then(() => s); +}; + +const startTCPServer = (host: string, port: number) => { + const server = net.createServer(); + + server.on('connection', (sock) => { + console.log(`Connected to client ${sock.remoteAddress}:${sock.remotePort}`); + + sock.on('data', (data) => { + const msg = parseMattMessage(data.toString()); + console.log(`Received data from ${sock.remoteAddress}: ${msg}`); + + sock.write(generateMattMessage('tcpworld')); + sock.write('\n'); + }); + }); + + return new Promise((resolve, reject) => { + server.listen(port, host); + + server.on('listening', () => { + console.log('listening!'); + resolve(null); + }); + }); +}; + +const parseMattMessage = (raw: string): string => { + return raw.replace(/(MATT)+/g, '').trim(); +}; +const generateMattMessage = (raw: string): string => { + return `MATT${raw}MATT`; +}; diff --git a/test/message.integration.spec.ts b/test/message.integration.spec.ts index 78251bcb..21936ad2 100644 --- a/test/message.integration.spec.ts +++ b/test/message.integration.spec.ts @@ -2,11 +2,11 @@ import chai = require('chai'); import chaiAsPromised = require('chai-as-promised'); import * as rimraf from 'rimraf'; import zlib = require('zlib'); -import { - ConsumerMessagePact, - makeConsumerAsyncMessagePact, -} from '../src/consumer'; +import { ConsumerMessagePact, makeConsumerMessagePact } from '../src/consumer'; +import { FfiSpecificationVersion } from '../src/ffi/types'; import { setLogLevel } from '../src/logger'; +import { load } from '@grpc/proto-loader'; +import * as grpc from '@grpc/grpc-js'; chai.use(chaiAsPromised); const expect = chai.expect; @@ -26,59 +26,186 @@ describe('FFI integration test for the Message Consumer API', () => { rimraf.sync('/tmp/pact/*.json'); }); - describe('with JSON data', () => { + describe('Asynchronous Messages', () => { + describe('with JSON data', () => { + beforeEach(() => { + pact = makeConsumerMessagePact( + 'message-consumer', + 'message-provider', + FfiSpecificationVersion.SPECIFICATION_VERSION_V3 + ); + }); + + it('generates a pact with success', () => { + pact.addMetadata('pact-node', 'meta-key', 'meta-val'); + const message = pact.newAsynchronousMessage(''); + message.expectsToReceive('a product event'); + message.given('some state'); + message.givenWithParam('some state 2', 'state2 key', 'state2 val'); + message.withContents( + JSON.stringify({ foo: 'bar' }), + 'application/json' + ); + message.withMetadata('meta-key', 'meta-val'); + + const reified = message.reifyMessage(); + + expect(JSON.parse(reified).contents).to.have.property('foo', 'bar'); + + pact.writePactFile('/tmp/pact'); + }); + }); + + // See https://github.com/pact-foundation/pact-reference/issues/171 for why we have an OS switch here + // Windows: does not have magic mime matcher, uses content-type + // OSX on CI: does not magic mime matcher, uses content-type + // OSX: has magic mime matcher, sniffs content + // Linux: has magic mime matcher, sniffs content + describe('with binary data', () => { + it('generates a pact with success', () => { + const message = pact.newAsynchronousMessage(''); + message.expectsToReceive('a binary event'); + message.given('some state'); + message.givenWithParam('some state 2', 'state2 key', 'state2 val'); + message.withBinaryContents( + bytes, + isWin || (isOSX && isCI) + ? 'application/octet-stream' + : 'application/gzip' + ); + message.withMetadata('meta-key', 'meta-val'); + + const reified = message.reifyMessage(); + const contents = JSON.parse(reified).contents; + + // Check the base64 encoded contents can be decoded, unzipped and equals the secret + const buf = Buffer.from(contents, 'base64'); + const deflated = zlib.gunzipSync(buf).toString('utf8'); + expect(deflated).to.equal(secret); + + pact.writePactFile('/tmp/pact'); + }); + }); + }); + describe('Synchronous Messages', () => { beforeEach(() => { - pact = makeConsumerAsyncMessagePact( + pact = makeConsumerMessagePact( 'message-consumer', - 'message-provider' + 'message-provider', + FfiSpecificationVersion.SPECIFICATION_VERSION_V4 ); }); + describe('with JSON data', () => { + it('generates a pact with success', () => { + pact.addMetadata('pact-node', 'meta-key', 'meta-val'); + const message = pact.newSynchronousMessage('A synchronous message'); + message.given('some state'); + message.givenWithParam('some state 2', 'state2 key', 'state2 val'); + message.withRequestContents( + JSON.stringify({ foo: 'bar' }), + 'application/json' + ); + message.withResponseContents( + JSON.stringify({ foo: 'bar' }), + 'application/json' + ); + message.withMetadata('meta-key', 'meta-val'); - it('generates a pact with success', () => { - pact.addMetadata('pact-node', 'meta-key', 'meta-val'); - const message = pact.newMessage(''); - message.expectsToReceive('a product event'); - message.given('some state'); - message.givenWithParam('some state 2', 'state2 key', 'state2 val'); - message.withContents(JSON.stringify({ foo: 'bar' }), 'application/json'); - message.withMetadata('meta-key', 'meta-val'); - - const reified = message.reifyMessage(); + // const reified = message.reifyMessage(); - expect(JSON.parse(reified).contents).to.have.property('foo', 'bar'); + // expect(JSON.parse(reified).contents).to.have.property('foo', 'bar'); - pact.writePactFile('/tmp/pact', false); + pact.writePactFile('/tmp/pact'); + }); }); - }); - // See https://github.com/pact-foundation/pact-reference/issues/171 for why we have an OS switch here - // Windows: does not have magic mime matcher, uses content-type - // OSX on CI: does not magic mime matcher, uses content-type - // OSX: has magic mime matcher, sniffs content - // Linux: has magic mime matcher, sniffs content - describe('with binary data', () => { - it('generates a pact with success', () => { - const message = pact.newMessage(''); - message.expectsToReceive('a binary event'); - message.given('some state'); - message.givenWithParam('some state 2', 'state2 key', 'state2 val'); - message.withBinaryContents( - bytes, - isWin || (isOSX && isCI) - ? 'application/octet-stream' - : 'application/gzip' - ); - message.withMetadata('meta-key', 'meta-val'); + describe.skip('with plugin contents (gRPC)', async () => { + const protoFile = `${__dirname}/integration/grpc/route_guide.proto`; + + let port: number; + + afterEach(() => { + pact.cleanupPlugins(); + }); + + beforeEach(() => { + const grpcInteraction = + `{ + "pact:proto": "` + + protoFile + + `", + "pact:proto-service": "RouteGuide/GetFeature", + "pact:content-type": "application/protobuf", + "request": { + "latitude": "matching(number, 180)", + "longitude": "matching(number, 200)" + }, + "response": { + "name": "matching(type, 'Big Tree')", + "location": { + "latitude": "matching(number, 180)", + "longitude": "matching(number, 200)" + } + } + }`; + + pact.addMetadata('pact-node', 'meta-key', 'meta-val'); + pact.addPlugin('protobuf', '0.1.14'); + + const message = pact.newSynchronousMessage('a grpc test'); + message.given('some state'); + message.givenWithParam('some state 2', 'state2 key', 'state2 val'); + message.withPluginRequestResponseInteractionContents( + 'application/protobuf', + grpcInteraction + ); + message.withMetadata('meta-key', 'meta-val'); - const reified = message.reifyMessage(); - const contents = JSON.parse(reified).contents; + port = pact.pactffiCreateMockServerForTransport( + '127.0.0.1', + 'grpc', + '' + ); + }); - // Check the base64 encoded contents can be decoded, unzipped and equals the secret - const buf = Buffer.from(contents, 'base64'); - const deflated = zlib.gunzipSync(buf).toString('utf8'); - expect(deflated).to.equal(secret); + it('generates a pact with success', async () => { + const feature: any = await getFeature(`127.0.0.1:${port}`, protoFile); + expect(feature.name).to.eq('Big Tree'); - pact.writePactFile('/tmp/pact', false); + const res = pact.mockServerMatchedSuccessfully(port); + expect(res).to.eq(true); + + const mismatches = pact.mockServerMismatches(port); + expect(mismatches.length).to.eq(0); + + pact.writePactFile('/tmp/pact'); + }); }); }); }); + +const getFeature = async (address: string, protoFile: string) => { + const def = await load(protoFile); + const routeguide: any = grpc.loadPackageDefinition(def).routeguide; + + const client = new routeguide.RouteGuide( + address, + grpc.credentials.createInsecure() + ); + + return new Promise((resolve, reject) => { + client.GetFeature( + { + latitude: 180, + longitude: 200, + }, + (e: Error, feature: any) => { + if (e) { + reject(e); + } else { + resolve(feature); + } + } + ); + }); +}; diff --git a/test/plugin-verifier.integration.spec.ts b/test/plugin-verifier.integration.spec.ts new file mode 100644 index 00000000..58f9be80 --- /dev/null +++ b/test/plugin-verifier.integration.spec.ts @@ -0,0 +1,139 @@ +import verifierFactory from '../src/verifier'; +import chai = require('chai'); +import chaiAsPromised = require('chai-as-promised'); +import { loadSync } from '@grpc/proto-loader'; +import * as grpc from '@grpc/grpc-js'; +import express = require('express'); +import * as http from 'http'; +import { returnJson } from './integration/data-utils'; +import cors = require('cors'); +import bodyParser = require('body-parser'); + +const expect = chai.expect; +chai.use(chaiAsPromised); + +const HTTP_PORT = 50051; +const GRPC_PORT = 50052; + +describe('Verifier Integration Spec', () => { + context('plugin tests', () => { + describe('grpc interaction', () => { + before(async () => { + const server = getGRPCServer(); + startGRPCServer(server, GRPC_PORT); + await startHTTPServer(HTTP_PORT); + }); + + it('should verify the gRPC interactions', async () => { + await verifierFactory({ + providerBaseUrl: `http://127.0.0.1:${HTTP_PORT}`, + transports: [ + { + port: GRPC_PORT, + protocol: 'grpc', + }, + ], + logLevel: "debug", + pactUrls: [`${__dirname}/integration/grpc/grpc.json`], + }).verify(); + + expect('').to.eq(''); + }); + + + it.skip('runs the grpc client', async () => { + const protoFile = `${__dirname}/integration/grpc/route_guide.proto`; + const feature = await getFeature(`127.0.0.1:${GRPC_PORT}`, protoFile); + + console.log(feature) + }) + }); + }); +}); + +const getGRPCServer = () => { + const PROTO_PATH = `${__dirname}/integration/grpc/route_guide.proto`; + + const options = { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + }; + var packageDefinition = loadSync(PROTO_PATH, options); + const routeguide: any = + grpc.loadPackageDefinition(packageDefinition).routeguide; + + const server = new grpc.Server(); + + server.addService(routeguide.RouteGuide.service, { + getFeature: (_: unknown, callback: any) => { + callback(null, { + name: "A place", + latitude: 200, + longitude: 180, + }); + }, + }); + + return server; +}; + +const startGRPCServer = (server: any, port: number) => { + server.bindAsync( + `127.0.0.1:${port}`, + grpc.ServerCredentials.createInsecure(), + (_: unknown, port: number) => { + console.log(`Server running at http://127.0.0.1:${port}`); + server.start(); + } + ); +}; + +const startHTTPServer = (port: number): Promise => { + const server: express.Express = express(); + server.use(cors()); + server.use(bodyParser.json()); + server.use( + bodyParser.urlencoded({ + extended: true, + }) + ); + + // Dummy server to respond to state changes etc. + server.all('/*', returnJson({})); + + let s: http.Server; + return new Promise((resolve) => { + s = server.listen(port, () => resolve()); + }).then(() => s); +}; + + +const getFeature = async (address: string, protoFile: string) => { + const def = loadSync(protoFile); + const routeguide: any = grpc.loadPackageDefinition(def).routeguide; + + const client = new routeguide.RouteGuide( + address, + grpc.credentials.createInsecure() + ); + + return new Promise((resolve, reject) => { + client.GetFeature( + { + latitude: 180, + longitude: 200, + }, + (e: Error, feature: any) => { + if (e) { + reject(e); + } else { + resolve(feature); + } + } + ); + }); + }; +