From 456567c83a5f155381eebb7dd3f6b60d3bc0060b Mon Sep 17 00:00:00 2001 From: Matt Fellows Date: Wed, 23 Jun 2021 23:26:47 +1000 Subject: [PATCH] feat: support promises in filters + state handlers Adds back the proxy infrastructure to allow native promises in the request filters and provider state handlers. BREAKING CHANGE: the signature of state handlers has been updated to accept either a single function with parameters, or an object that can specify optional teardown and setup functions that run on the different state phases. BREAKING CHANGE: callbackTimeout is now timeout --- .istanbul.yml | 4 +- .nycrc | 2 +- README.md | 88 +++---- examples/v3/e2e/test/provider.spec.js | 50 ++-- .../consumer/transaction-service.js | 31 +++ .../consumer/transaction-service.test.js | 56 ++++- .../provider/account-service.js | 42 ++++ .../provider/account-service.test.js | 28 +-- .../v3/todo-consumer/test/provider.spec.js | 4 +- native/src/verify.rs | 237 ++---------------- src/dsl/verifier/proxy/proxy.ts | 3 +- .../proxy/stateHandler/setupStates.spec.ts | 106 +++++--- .../proxy/stateHandler/setupStates.ts | 62 +++-- .../proxy/stateHandler/stateHandler.spec.ts | 27 +- .../proxy/stateHandler/stateHandler.ts | 4 +- src/dsl/verifier/proxy/tracer.ts | 4 +- src/dsl/verifier/proxy/types.ts | 43 +++- src/dsl/verifier/verifier.spec.ts | 2 +- src/v3/pact.ts | 5 +- src/v3/verifier.spec.ts | 54 ++-- src/v3/verifier.ts | 195 +++++++++----- 21 files changed, 570 insertions(+), 477 deletions(-) diff --git a/.istanbul.yml b/.istanbul.yml index 01d4b1a79..3d27527cc 100644 --- a/.istanbul.yml +++ b/.istanbul.yml @@ -5,11 +5,11 @@ check: statements: 80 lines: 80 branches: 80 - functions: 80 + functions: 75 excludes: [] each: statements: 60 lines: 60 branches: 65 - functions: 80 + functions: 75 excludes: ['src/dsl/verifier.ts'] diff --git a/.nycrc b/.nycrc index 82d2c14e5..192a409a8 100644 --- a/.nycrc +++ b/.nycrc @@ -21,6 +21,6 @@ "instrument": true, "lines": 80, "statements": 80, - "functions": 80, + "functions": 75, "branches": 80 } diff --git a/README.md b/README.md index bdcd52d31..9182c88fe 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Read [Getting started with Pact] for more information for beginners. - [Match based on type](#match-based-on-type) - [Match based on arrays](#match-based-on-arrays) - [Match by regular expression](#match-by-regular-expression) + - [A note about typescript](#a-note-about-typescript) - [GraphQL API](#graphql-api) - [Tutorial (60 minutes)](#tutorial-60-minutes) - [Examples](#examples) @@ -325,7 +326,7 @@ new Verifier(opts).verifyProvider().then(function () { | `pactUrls` | false | array | Array of local pact file paths or HTTP-based URLs. Required if _not_ using a Pact Broker. | | `providerStatesSetupUrl` | false | string | Deprecated (use URL to send PUT requests to setup a given provider state | | `stateHandlers` | false | object | Map of "state" to a function that sets up a given provider state. See docs below for more information | -| `requestFilter` | false | function | Function that may be used to alter the incoming request or outgoing response from the verification process. See below for use. | +| `requestFilter` | false | function ([Express middleware](https://expressjs.com/en/guide/using-middleware.html)) | Function that may be used to alter the incoming request or outgoing response from the verification process. See below for use. | | `beforeEach` | false | function | Function to execute prior to each interaction being validated | | `afterEach` | false | function | Function to execute after each interaction has been validated | | `pactBrokerUsername` | false | string | Username for Pact Broker basic authentication | @@ -1096,21 +1097,15 @@ Then when the provider is verified, the provider state callback can return a map For example: ```js - stateHandlers: { - "Account Test001 exists": (setup, params) => { - if (setup) { - const account = new Account(0, 0, "Test001", params.accountRef, new AccountNumber(0), Date.now(), Date.now()) - const persistedAccount = accountRepository.save(account) - return { accountNumber: persistedAccount.accountNumber.id } - } else { - return null - } - } - }, +stateHandlers: { + "Account Test001 exists": (params) => { + const account = new Account(0, 0, "Test001", params.accountRef, new AccountNumber(0), Date.now(), Date.now()) + const persistedAccount = accountRepository.save(account) + return { accountNumber: persistedAccount.accountNumber.id } + } +}, ``` -**NOTE:** Async callbacks and returning promises from the provider state callbacks is not currently supported. - ### Using Pact with XML You can write both consumer and provider verification tests with XML requests or responses. For an example, see [examples/v3/todo-consumer/test/consumer.spec.js](https://github.com/pact-foundation/pact-js/blob/feat/v3.0.0/examples/v3/todo-consumer/test/consumer.spec.js). @@ -1173,7 +1168,7 @@ The `VerifierV3` class can verify your provider in a similar way to the existing | `callbackTimeout` | false | number | Timeout in milliseconds for request filters and provider state handlers to execute within | | `publishVerificationResult` | false | boolean | Publish verification result to Broker (_NOTE_: you should only enable this during CI builds) | | `providerVersion` | false | string | Provider version, required to publish verification result to Broker. Optional otherwise. | -| `requestFilter ` | false | RequestHandler | | +| `requestFilter` | false | function ([Express middleware](https://expressjs.com/en/guide/using-middleware.html)) | | | `stateHandlers` | false | object | Map of "state" to a function that sets up a given provider state. See docs [below](#provider-state-callbacks) for more information | | `consumerVersionTags` | false | string\|array | Retrieve the latest pacts with given tag(s) | | `providerVersionTags` | false | string\|array | Tag(s) to apply to the provider application | @@ -1186,57 +1181,64 @@ The `VerifierV3` class can verify your provider in a similar way to the existing #### Request Filters -Request filters now take a request object as a parameter, and need to return the mutated one. +Request filters are simple [Express middleware functions](https://expressjs.com/en/guide/using-middleware.html): ```javascript -requestFilter: req => { +requestFilter: (req, res, next) => { req.headers["MY_SPECIAL_HEADER"] = "my special value" // e.g. ADD Bearer token req.headers["authorization"] = `Bearer ${token}` - // Need to return the request back again - return req + // Need to call the next middleware in the chain + next() }, ``` #### Provider state callbacks -Provider state callbacks have been updated to support parameters and return values. The first parameter is a boolean indicating whether it is a setup call -(run before the verification) or a tear down call (run afterwards). The second optional parameter is a key-value map of any parameters defined in the -pact file. Provider state callbacks can also return a map of key-value values. These are used with provider-state injected values (see the section on that above). +Provider state callbacks have been updated to support parameters and return values. + +Simple callbacks run before the verification and receive optional parameters containing any key-value parameters defined in the pact file. + +The second form of callback accepts a `setup` and `teardown` function that execute on the lifecycle of the state setup. `setup` runs prior to the test, and `teardown` runs after the actual request has been sent to the provider. + +Provider state callbacks can also return a map of key-value values. These are used with provider-state injected values (see the section on that above). ```javascript stateHandlers: { - "Has no animals": setup => { - if (setup) { - animalRepository.clear() - return { description: `Animals removed to the db` } + // Simple state handler, runs before the verification + "Has no animals": () => { + return animalRepository.clear() + }, + // Runs only on setup phase (no teardown) + "Has some animals": { + setup: () => { + return importData() } }, - "Has some animals": setup => { - if (setup) { - importData() - return { - description: `Animals added to the db`, - count: animalRepository.count(), - } + // Runs only on teardown phase (no setup) + "Has a broken dependency": { + setup: () => { + // make some dependency fail... + return Promise.resolve() + }, + teardown: () => { + // fix the broken dependency! + return Promise.resolve() } }, - "Has an animal with ID": (setup, parameters) => { - if (setup) { - importData() - animalRepository.first().id = parameters.id - return { - description: `Animal with ID ${parameters.id} added to the db`, - id: parameters.id, - } + // Return provider specific IDs + "Has an animal with ID": async (parameters) => { + await importData() + animalRepository.first().id = parameters.id + return { + description: `Animal with ID ${parameters.id} added to the db`, + id: parameters.id, } }, ``` -**NOTE:** Async callbacks and returning promises from the provider state callbacks is not currently supported. - ### Debugging issues with Pact-JS V3 You can change the log levels using the `LOG_LEVEL` environment variable. diff --git a/examples/v3/e2e/test/provider.spec.js b/examples/v3/e2e/test/provider.spec.js index 32ee63975..c7806c75f 100644 --- a/examples/v3/e2e/test/provider.spec.js +++ b/examples/v3/e2e/test/provider.spec.js @@ -20,49 +20,41 @@ describe('Pact Verification', () => { let token = 'INVALID TOKEN'; return new VerifierV3({ + logLevel: 'info', provider: 'Animal Profile Service V3', providerBaseUrl: 'http://localhost:8081', - - requestFilter: (req) => { + requestFilter: (req, res, next) => { console.log( 'Middleware invoked before provider API - injecting Authorization token' ); - req.headers['MY_SPECIAL_HEADER'] = 'my special value'; // e.g. ADD Bearer token req.headers['authorization'] = `Bearer ${token}`; - - return req; + next(); }, stateHandlers: { - 'Has no animals': (setup) => { - if (setup) { - animalRepository.clear(); - return Promise.resolve({ - description: `Animals removed to the db`, - }); - } + 'Has no animals': () => { + animalRepository.clear(); + return Promise.resolve({ + description: `Animals removed to the db`, + }); }, - 'Has some animals': (setup) => { - if (setup) { - importData(); - return Promise.resolve({ - description: `Animals added to the db`, - count: animalRepository.count(), - }); - } + 'Has some animals': () => { + importData(); + return Promise.resolve({ + description: `Animals added to the db`, + count: animalRepository.count(), + }); }, - 'Has an animal with ID': (setup, parameters) => { - if (setup) { - importData(); - animalRepository.first().id = parameters.id; - return Promise.resolve({ - description: `Animal with ID ${parameters.id} added to the db`, - id: parameters.id, - }); - } + 'Has an animal with ID': (parameters) => { + importData(); + animalRepository.first().id = parameters.id; + return Promise.resolve({ + description: `Animal with ID ${parameters.id} added to the db`, + id: parameters.id, + }); }, 'is not authenticated': () => { token = ''; diff --git a/examples/v3/provider-state-injected/consumer/transaction-service.js b/examples/v3/provider-state-injected/consumer/transaction-service.js index 856ac87e4..dc8192287 100644 --- a/examples/v3/provider-state-injected/consumer/transaction-service.js +++ b/examples/v3/provider-state-injected/consumer/transaction-service.js @@ -34,6 +34,37 @@ module.exports = { }); }, + // same as createTransaction, but demonstrating using a PostBody + createTransactionWithPostBody: (accountId, amountInCents) => { + return axios + .post( + accountServiceUrl + '/accounts/search/findOneByAccountNumberIdInBody', + { + accountNumber: accountId, + }, + { + headers: { + Accept: 'application/hal+json', + }, + } + ) + .then(({ data }) => { + // This is the point where a real transaction service would create the transaction, but for the purpose + // of this example we'll assume this has happened here + let id = Math.floor(Math.random() * Math.floor(100000)); + return { + account: { + accountNumber: data.accountNumber.id, + accountReference: data.accountRef, + }, + transaction: { + id, + amount: amountInCents, + }, + }; + }); + }, + getText: (id) => { return axios.get(accountServiceUrl + '/data/' + id).then((data) => { return data; diff --git a/examples/v3/provider-state-injected/consumer/transaction-service.test.js b/examples/v3/provider-state-injected/consumer/transaction-service.test.js index e884b3a1e..356c9beea 100644 --- a/examples/v3/provider-state-injected/consumer/transaction-service.test.js +++ b/examples/v3/provider-state-injected/consumer/transaction-service.test.js @@ -72,6 +72,61 @@ describe('Transaction service - create a new transaction for an account', () => }); }); + // Uncomment when https://github.com/pact-foundation/pact-reference/issues/116 is resolved + it.skip('queries the account service for the account details using POST body', () => { + provider + .given('Account Test001 exists', { accountRef: 'Test001' }) + .uponReceiving('a request to get the account details via POST') + .withRequest({ + method: 'POST', + path: '/accounts/search/findOneByAccountNumberIdInBody', + body: { accountNumber: fromProviderState('${accountNumber}', 100) }, + headers: { + Accept: 'application/hal+json', + 'Content-Type': 'application/json; charset=utf-8', + }, + }) + .willRespondWith({ + status: 200, + headers: { 'Content-Type': 'application/hal+json' }, + body: { + id: integer(1), + version: integer(0), + name: string('Test'), + accountRef: string('Test001'), + createdDate: datetime("yyyy-MM-dd'T'HH:mm:ss.SSSX"), + lastModifiedDate: datetime("yyyy-MM-dd'T'HH:mm:ss.SSSX"), + accountNumber: { + id: fromProviderState('${accountNumber}', 100), + }, + _links: { + self: { + href: url2('http://localhost:8080', [ + 'accounts', + regex('\\d+', '100'), + ]), + }, + account: { + href: url2('http://localhost:8080', [ + 'accounts', + regex('\\d+', '100'), + ]), + }, + }, + }, + }); + + return provider.executeTest(async (mockserver) => { + transactionService.setAccountServiceUrl(mockserver.url); + return transactionService + .createTransactionWithPostBody(100, 100000) + .then((result) => { + expect(result.account.accountNumber).to.equal(100); + expect(result.transaction.amount).to.equal(100000); + }); + }); + }); + // MatchersV3.fromProviderState on body it('test text data', () => { provider @@ -127,7 +182,6 @@ describe('Transaction service - create a new transaction for an account', () => return provider.executeTest(async (mockserver) => { transactionService.setAccountServiceUrl(mockserver.url); return transactionService.getXml(42).then((result) => { - console.log(result.data); expect(result.data).to.equal( `random42` ); diff --git a/examples/v3/provider-state-injected/provider/account-service.js b/examples/v3/provider-state-injected/provider/account-service.js index ed683425b..7b86855f6 100644 --- a/examples/v3/provider-state-injected/provider/account-service.js +++ b/examples/v3/provider-state-injected/provider/account-service.js @@ -45,6 +45,48 @@ server.get('/accounts/search/findOneByAccountNumberId', (req, res) => { }); }); +server.post('/accounts/search/findOneByAccountNumberIdInBody', (req, res) => { + // To check and demonstrate typed responses. See https://github.com/pact-foundation/pact-reference/issues/116 + if (typeof req.body.accountNumber !== 'number') { + console.log( + `accountNumber should be a number, got a ${typeof req.body.accountNumber}` + ); + return res.status(400); + } + + return accountRepository + .findByAccountNumber(req.body.accountNumber) + .then((account) => { + if (account) { + res.header('Content-Type', 'application/hal+json; charset=utf-8'); + let baseUrl = + req.protocol + '://' + req.hostname + ':' + req.socket.localPort; + let body = { + _links: { + account: { + href: baseUrl + '/accounts/' + account.accountNumber.id, + }, + self: { + href: baseUrl + '/accounts/' + account.accountNumber.id, + }, + }, + accountNumber: { + id: +account.accountNumber.id, + }, + accountRef: account.referenceId, + createdDate: new Date(account.created).toISOString(), + id: account.id, + lastModifiedDate: new Date(account.updated).toISOString(), + name: account.name, + version: account.version, + }; + res.json(body); + } else { + res.status(404).end(); + } + }); +}); + server.get('/data/xml/:id', (req, res) => { res.header('Content-Type', 'application/xml; charset=utf-8'); res.send(` diff --git a/examples/v3/provider-state-injected/provider/account-service.test.js b/examples/v3/provider-state-injected/provider/account-service.test.js index cf69ccf1d..3fdc469fe 100644 --- a/examples/v3/provider-state-injected/provider/account-service.test.js +++ b/examples/v3/provider-state-injected/provider/account-service.test.js @@ -15,10 +15,9 @@ describe('Account Service', () => { let opts = { provider: 'Account Service', providerBaseUrl: 'http://localhost:8081', - stateHandlers: { - 'Account Test001 exists': (setup, params) => { - if (setup) { + 'Account Test001 exists': { + setup: (params) => { let account = new Account( 0, 0, @@ -29,20 +28,17 @@ describe('Account Service', () => { Date.now() ); let persistedAccount = accountRepository.save(account); - return { accountNumber: persistedAccount.accountNumber.id }; - } else { - return null; - } + return Promise.resolve({ + accountNumber: persistedAccount.accountNumber.id, + }); + }, }, - 'set id': (setup, params) => { - if (setup) { - return { id: params.id }; - } + 'set id': { + setup: (params) => Promise.resolve({ id: params.id }), }, - 'set path': (setup, params) => { - if (setup) { - return { id: params.id, path: params.path }; - } + 'set path': { + setup: (params) => + Promise.resolve({ id: params.id, path: params.path }), }, }, @@ -54,6 +50,6 @@ describe('Account Service', () => { ], }; - return new VerifierV3(opts).verifyProvider().then((output) => true); + return new VerifierV3(opts).verifyProvider(); }); }); diff --git a/examples/v3/todo-consumer/test/provider.spec.js b/examples/v3/todo-consumer/test/provider.spec.js index f5c32e8bd..85d73527e 100644 --- a/examples/v3/todo-consumer/test/provider.spec.js +++ b/examples/v3/todo-consumer/test/provider.spec.js @@ -21,8 +21,8 @@ describe('Pact XML Verification', () => { providerBaseUrl: 'http://localhost:8081', pactUrls: ['./pacts/TodoApp-TodoServiceV3.json'], stateHandlers: { - 'i have a list of projects': (setup) => {}, - 'i have a project': (setup) => {}, + 'i have a list of projects': (params) => {}, + 'i have a project': (params) => {}, }, }; diff --git a/native/src/verify.rs b/native/src/verify.rs index da5495d43..de62de911 100644 --- a/native/src/verify.rs +++ b/native/src/verify.rs @@ -1,3 +1,4 @@ +use pact_verifier::callback_executors::HttpRequestProviderStateExecutor; use std::sync::Arc; use serde_json::Value; use neon::prelude::*; @@ -80,179 +81,22 @@ fn get_integer_value(cx: &mut FunctionContext, obj: &JsObject, name: &str) -> Op } #[derive(Clone)] -struct RequestFilterCallback { - callback_handler: EventHandler, +struct ProviderStateCallback<'a> { + callback_handlers: &'a HashMap, timeout: u64 } -impl RequestFilterExecutor for RequestFilterCallback { - fn call(self: Arc, request: &Request) -> Request { - let (sender, receiver) = mpsc::channel(); - let request_copy = request.clone(); - self.callback_handler.schedule_with(move |cx, this, callback| { - let js_method = cx.string(request_copy.method); - let js_path = cx.string(request_copy.path); - let js_query = JsObject::new(cx); - let js_headers = JsObject::new(cx); - let js_request = JsObject::new(cx); - let js_body = cx.string(request_copy.body.str_value()); - - if let Some(query) = request_copy.query { - query.iter().for_each(|(k, v)| { - let vars = JsArray::new(cx, v.len() as u32); - v.iter().enumerate().for_each(|(i, val)| { - let qval = cx.string(val); - vars.set(cx, i as u32, qval).unwrap(); - }); - js_query.set(cx, k.as_str(), vars).unwrap(); - }); - }; - - if let Some(headers) = request_copy.headers { - headers.iter().for_each(|(k, v)| { - let vars = JsArray::new(cx, v.len() as u32); - v.iter().enumerate().for_each(|(i, val)| { - let hval = cx.string(val); - vars.set(cx, i as u32, hval).unwrap(); - }); - js_headers.set(cx, k.to_lowercase().as_str(), vars).unwrap(); - }); - }; - - js_request.set(cx, "method", js_method).unwrap(); - js_request.set(cx, "path", js_path).unwrap(); - js_request.set(cx, "headers", js_headers).unwrap(); - js_request.set(cx, "query", js_query).unwrap(); - js_request.set(cx, "body", js_body).unwrap(); - let args = vec![js_request]; - let result = callback.call(cx, this, args); - - match result { - Ok(val) => { - if let Ok(js_obj) = val.downcast::() { - let mut request = Request::default(); - if let Ok(val) = js_obj.get(cx, "method").unwrap().downcast::() { - request.method = val.value(); - } - if let Ok(val) = js_obj.get(cx, "path").unwrap().downcast::() { - request.path = val.value(); - } - if let Ok(val) = js_obj.get(cx, "body").unwrap().downcast::() { - request.body = val.value().into(); - } - - if let Ok(query_map) = js_obj.get(cx, "query").unwrap().downcast::() { - let mut map = hashmap!{}; - let props = query_map.get_own_property_names(cx).unwrap(); - for prop in props.to_vec(cx).unwrap() { - let prop_name = prop.downcast::().unwrap().value(); - let prop_val = query_map.get(cx, prop_name.as_str()).unwrap(); - if let Ok(array) = prop_val.downcast::() { - let vec = array.to_vec(cx).unwrap(); - map.insert(prop_name, vec.iter().map(|item| { - item.downcast::().unwrap().value() - }).collect()); - } else { - map.insert(prop_name, vec![prop_val.downcast::().unwrap().value()]); - } - } - request.query = Some(map) - } - - if let Ok(header_map) = js_obj.get(cx, "headers").unwrap().downcast::() { - let mut map = hashmap!{}; - let props = header_map.get_own_property_names(cx).unwrap(); - for prop in props.to_vec(cx).unwrap() { - let prop_name = prop.downcast::().unwrap().value(); - let prop_val = header_map.get(cx, prop_name.as_str()).unwrap(); - if let Ok(array) = prop_val.downcast::() { - let vec = array.to_vec(cx).unwrap(); - map.insert(prop_name, vec.iter().map(|item| { - item.downcast::().unwrap().value() - }).collect()); - } else { - map.insert(prop_name, vec![prop_val.downcast::().unwrap().value()]); - } - } - request.headers = Some(map) - } - - sender.send(request).unwrap(); - } else { - error!("Request filter did not return an object"); - } - }, - Err(err) => { - error!("Request filter threw an exception: {}", err); - } - } - }); - - receiver.recv_timeout(Duration::from_millis(self.timeout)).unwrap_or(request.clone()) - } -} - #[derive(Clone)] -struct ProviderStateCallback<'a> { - callback_handlers: &'a HashMap, - timeout: u64 +pub struct NullRequestFilterExecutor { + // This field is added (and is private) to guarantee that this struct + // is never instantiated accidentally, and is instead only able to be + // used for type-level programming. + _private_field: (), } -#[async_trait] -impl ProviderStateExecutor for ProviderStateCallback<'_> { - async fn call(self: Arc, interaction_id: Option, provider_state: &ProviderState, setup: bool, _client: Option<&reqwest::Client>) -> Result, ProviderStateError> { - match self.callback_handlers.get(&provider_state.name) { - Some(callback) => { - let (sender, receiver) = mpsc::channel(); - let state = provider_state.clone(); - let iid = interaction_id.clone(); - callback.schedule_with(move |cx, this, callback| { - let args = if !state.params.is_empty() { - let js_parameter = JsObject::new(cx); - for (ref parameter, ref value) in state.params { - serde_value_to_js_object_attr(cx, &js_parameter, parameter, value).unwrap(); - }; - vec![cx.boolean(setup).upcast::(), js_parameter.upcast::()] - } else { - vec![cx.boolean(setup).upcast::()] - }; - let callback_result = callback.call(cx, this, args); - match callback_result { - Ok(val) => { - if let Ok(vals) = val.downcast::() { - let js_props = vals.get_own_property_names(cx).unwrap(); - let props: HashMap = js_props.to_vec(cx).unwrap().iter().map(|prop| { - let prop_name = prop.downcast::().unwrap().value(); - let prop_val = vals.get(cx, prop_name.as_str()).unwrap(); - (prop_name, js_value_to_serde_value(&prop_val, cx)) - }).collect(); - debug!("Provider state callback result = {:?}", props); - sender.send(Ok(props)).unwrap(); - } else { - debug!("Provider state callback did not return a map of values. Ignoring."); - sender.send(Ok(hashmap!{})).unwrap(); - } - }, - Err(err) => { - error!("Provider state callback for '{}' failed: {}", state.name, err); - let error = ProviderStateError { description: format!("Provider state callback for '{}' failed: {}", state.name, err), interaction_id: iid }; - sender.send(Result::, ProviderStateError>::Err(error)).unwrap(); - } - }; - }); - match receiver.recv_timeout(Duration::from_millis(self.timeout)) { - Ok(result) => { - debug!("Received {:?} from callback", result); - result - }, - Err(_) => Err(ProviderStateError { description: format!("Provider state callback for '{}' timed out after {} ms", provider_state.name, self.timeout), interaction_id }) - } - }, - None => { - error!("No provider state callback defined for '{}'", provider_state.name); - Err(ProviderStateError { description: format!("No provider state callback defined for '{}'", provider_state.name), interaction_id }) - } - } +impl RequestFilterExecutor for NullRequestFilterExecutor { + fn call(self: Arc, _request: &Request) -> Request { + unimplemented!("NullRequestFilterExecutor should never be called") } } @@ -261,8 +105,9 @@ struct BackgroundTask { pub pacts: Vec, pub filter_info: FilterInfo, pub consumers_filter: Vec, - pub options: VerificationOptions, - pub state_handlers: HashMap + pub options: VerificationOptions, + pub state_handlers: HashMap, + pub state_change_url: Option } impl Task for BackgroundTask { @@ -275,11 +120,12 @@ impl Task for BackgroundTask { panic::catch_unwind(|| { match tokio::runtime::Builder::new_current_thread().enable_all().build() { Ok(runtime) => runtime.block_on(async { - let provider_state_executor = ProviderStateCallback { - callback_handlers: &self.state_handlers, - timeout: self.options.request_timeout - }; - pact_verifier::verify_provider_async(self.provider_info.clone(), self.pacts.clone(), self.filter_info.clone(), self.consumers_filter.clone(), self.options.clone(), &Arc::new(provider_state_executor)).await + let provider_state_executor = Arc::new(HttpRequestProviderStateExecutor { + state_change_url: self.state_change_url.clone(), + state_change_body: true, + state_change_teardown: true, + }); + pact_verifier::verify_provider_async(self.provider_info.clone(), self.pacts.clone(), self.filter_info.clone(), self.consumers_filter.clone(), self.options.clone(), &provider_state_executor).await }), Err(err) => { error!("Verify process failed to start the tokio runtime: {}", err); @@ -464,42 +310,9 @@ pub fn verify_provider(mut cx: FunctionContext) -> JsResult { debug!("provider_info = {:?}", provider_info); - let request_timeout = get_integer_value(&mut cx, &config, "callbackTimeout").unwrap_or(5000); + let request_timeout = get_integer_value(&mut cx, &config, "timeout").unwrap_or(5000); - let request_filter = match config.get(&mut cx, "requestFilter") { - Ok(request_filter) => match request_filter.downcast::() { - Ok(val) => { - let this = cx.this(); - Some(Arc::new(RequestFilterCallback { - callback_handler: EventHandler::new(&cx, this, val), - timeout: request_timeout - })) - }, - Err(_) => None - }, - _ => None - }; - - debug!("request_filter done"); - - let mut callbacks = hashmap![]; - match config.get(&mut cx, "stateHandlers") { - Ok(state_handlers) => match state_handlers.downcast::() { - Ok(state_handlers) => { - let this = cx.this(); - let props = state_handlers.get_own_property_names(&mut cx).unwrap(); - for prop in props.to_vec(&mut cx).unwrap() { - let prop_name = prop.downcast::().unwrap().value(); - let prop_val = state_handlers.get(&mut cx, prop_name.as_str()).unwrap(); - if let Ok(callback) = prop_val.downcast::() { - callbacks.insert(prop_name, EventHandler::new(&cx, this, callback)); - } - }; - }, - Err(_) => () - }, - _ => () - }; + let callbacks = hashmap![]; let publish = match config.get(&mut cx, "publishVerificationResult") { Ok(publish) => match publish.downcast::() { @@ -543,6 +356,8 @@ pub fn verify_provider(mut cx: FunctionContext) -> JsResult { _ => false }; + let state_change_url = get_string_value(&mut cx, &config, "providerStatesSetupUrl"); + let filter_info = interaction_filter(); match filter_info { @@ -559,7 +374,7 @@ pub fn verify_provider(mut cx: FunctionContext) -> JsResult { publish, provider_version, build_url: None, - request_filter, + request_filter: None, provider_tags, disable_ssl_verification, request_timeout, @@ -567,7 +382,7 @@ pub fn verify_provider(mut cx: FunctionContext) -> JsResult { }; debug!("Starting background task"); - BackgroundTask { provider_info, pacts, filter_info, consumers_filter, options, state_handlers: callbacks }.schedule(callback); + BackgroundTask { provider_info, pacts, filter_info, consumers_filter, options, state_handlers: callbacks, state_change_url }.schedule(callback); debug!("Done"); Ok(cx.undefined()) diff --git a/src/dsl/verifier/proxy/proxy.ts b/src/dsl/verifier/proxy/proxy.ts index 5b02f6709..336259fdd 100644 --- a/src/dsl/verifier/proxy/proxy.ts +++ b/src/dsl/verifier/proxy/proxy.ts @@ -48,7 +48,8 @@ export const createProxy = ( // Proxy server will respond to Verifier process app.all('/*', (req, res) => { - logger.debug('Proxing', req.path); + logger.debug(`Proxying ${req.path}`); + proxy.web(req, res, { changeOrigin: config.changeOrigin === true, secure: config.validateSSL === true, diff --git a/src/dsl/verifier/proxy/stateHandler/setupStates.spec.ts b/src/dsl/verifier/proxy/stateHandler/setupStates.spec.ts index 54dc4f408..b9bbec50b 100644 --- a/src/dsl/verifier/proxy/stateHandler/setupStates.spec.ts +++ b/src/dsl/verifier/proxy/stateHandler/setupStates.spec.ts @@ -3,18 +3,32 @@ import chaiAsPromised from 'chai-as-promised'; import sinon from 'sinon'; import logger from '../../../../common/logger'; -import { ProxyOptions } from '../types'; +import { ProxyOptions, ProviderState } from '../types'; import { setupStates } from './setupStates'; +import { JsonMap } from '../../../../common/jsonTypes'; chai.use(chaiAsPromised); const { expect } = chai; describe('#setupStates', () => { - const state = 'thing exists'; + const state: ProviderState = { + state: 'thing exists', + action: 'setup', + params: {}, + }; + const state2: ProviderState = { + state: 'another thing exists', + action: 'setup', + params: { + id: 1, + }, + }; const providerBaseUrl = 'http://not.exists'; let executed: boolean; + let setup: boolean; + let teardown: boolean; const DEFAULT_OPTIONS = (): ProxyOptions => ({ providerBaseUrl, @@ -22,9 +36,19 @@ describe('#setupStates', () => { next(); }, stateHandlers: { - [state]: () => { + [state.state]: () => { executed = true; - return Promise.resolve('done'); + return Promise.resolve({}); + }, + [state2.state]: { + setup: (params) => { + setup = true; + return Promise.resolve(params as JsonMap); + }, + teardown: (params) => { + teardown = true; + return Promise.resolve(params as JsonMap); + }, }, }, }); @@ -34,46 +58,66 @@ describe('#setupStates', () => { beforeEach(() => { opts = DEFAULT_OPTIONS(); executed = false; + setup = false; + teardown = false; }); describe('when there are provider states on the pact', () => { describe('and there are handlers associated with those states', () => { - it('executes the handler and returns a set of Promises', async () => { - const res = await setupStates( - { - states: [state], - }, - opts - ); - - expect(res).lengthOf(1); - expect(executed).to.be.true; + describe('that return provider state injected values', () => { + it('executes the handler and returns the data', async () => { + opts.stateHandlers = { + [state.state]: () => { + executed = true; + return Promise.resolve({ data: true }); + }, + }; + const res = await setupStates(state, opts); + + expect(res).to.have.property('data', true); + expect(executed).to.be.true; + }); + }); + describe('that do not return a value', () => { + it('executes the handler and returns an empty Promise', async () => { + await setupStates(state, opts); + + expect(executed).to.be.true; + }); + }); + describe('that specify a setup and teardown function', () => { + it('executes the lifecycle specific handler and returns any provider state injected values', async () => { + const res = await setupStates(state2, opts); + + expect(res).to.eq(state2.params); + expect(setup).to.be.true; + expect(teardown).to.be.false; + setup = false; + + const res2 = await setupStates( + { + ...state2, + action: 'teardown', + }, + opts + ); + + expect(res2).to.eq(state2.params); + expect(teardown).to.be.true; + expect(setup).to.be.false; + }); }); }); describe('and there are no handlers associated with those states', () => { - it('executes the handler and returns an empty Promise', async () => { + it('does not execute the handler and returns an empty Promise', async () => { const spy = sinon.spy(logger, 'warn'); delete opts.stateHandlers; - const res = await setupStates( - { - states: [state], - }, - opts - ); - - expect(res).lengthOf(0); + await setupStates(state, opts); + expect(spy.callCount).to.eql(1); expect(executed).to.be.false; }); }); }); - - describe('when there are no provider states on the pact', () => { - it('executes the handler and returns an empty Promise', async () => { - const res = await setupStates({}, opts); - - expect(res).lengthOf(0); - }); - }); }); diff --git a/src/dsl/verifier/proxy/stateHandler/setupStates.ts b/src/dsl/verifier/proxy/stateHandler/setupStates.ts index 4d8c203a6..596a86ba0 100644 --- a/src/dsl/verifier/proxy/stateHandler/setupStates.ts +++ b/src/dsl/verifier/proxy/stateHandler/setupStates.ts @@ -1,24 +1,58 @@ import logger from '../../../../common/logger'; -import { ProviderState, ProxyOptions } from '../types'; +import { + ProxyOptions, + StateFunc, + StateFuncWithSetup, + ProviderState, + StateHandler, +} from '../types'; +import { JsonMap } from '../../../../common/jsonTypes'; -// Lookup the handler based on the description, or get the default handler +const isStateFuncWithSetup = ( + fn: StateFuncWithSetup | StateFunc +): fn is StateFuncWithSetup => + (fn as StateFuncWithSetup).setup !== undefined || + (fn as StateFuncWithSetup).teardown !== undefined; + +// Transform a regular state function to one with the setup/teardown functions +const transformStateFunc = (fn: StateHandler): StateFuncWithSetup => + isStateFuncWithSetup(fn) ? fn : { setup: fn }; + +// Lookup the handler based on the description export const setupStates = ( - descriptor: ProviderState, + state: ProviderState, config: ProxyOptions -): Promise => { - const promises: Array> = []; +): Promise => { + logger.debug({ state }, 'setting up state'); - if (descriptor.states) { - descriptor.states.forEach((state) => { - const handler = config.stateHandlers ? config.stateHandlers[state] : null; + const handler = config.stateHandlers + ? config.stateHandlers[state.state] + : null; - if (handler) { - promises.push(handler()); - } else { - logger.warn(`No state handler found for "${state}", ignoring`); + if (!handler) { + if (state.action === 'setup') { + logger.warn(`no state handler found for state: "${state.state}"`); + } + return Promise.resolve(); + } + + const stateFn = transformStateFunc(handler); + switch (state.action) { + case 'setup': + if (stateFn.setup) { + logger.debug(`setting up state '${state.state}'`); + return stateFn.setup(state.params); + } + break; + case 'teardown': + if (stateFn.teardown) { + logger.debug(`tearing down state '${state.state}'`); + return stateFn.teardown(state.params); } - }); + break; + default: + logger.debug(`unknown state action '${state.action}' received, ignoring`); } - return Promise.all(promises); + return Promise.resolve(); }; diff --git a/src/dsl/verifier/proxy/stateHandler/stateHandler.spec.ts b/src/dsl/verifier/proxy/stateHandler/stateHandler.spec.ts index d0b34b5f3..0c4befd04 100644 --- a/src/dsl/verifier/proxy/stateHandler/stateHandler.spec.ts +++ b/src/dsl/verifier/proxy/stateHandler/stateHandler.spec.ts @@ -15,32 +15,30 @@ const { expect } = chai; describe('#createProxyStateHandler', () => { let res: any; const mockResponse = { - sendStatus: (status: number) => { - res = status; - }, status: (status: number) => { res = status; return { send: () => {}, }; }, + json: (data: any) => data, }; context('when valid state handlers are provided', () => { beforeEach(() => { - sinon.stub(setupStatesModule, 'setupStates').returns(Promise.resolve()); + sinon.stub(setupStatesModule, 'setupStates').returns(Promise.resolve({})); }); afterEach(() => { (setupStatesModule.setupStates as SinonSpy).restore(); }); - it('returns a 200', () => { + it('returns a 200', async () => { const h = createProxyStateHandler({} as ProxyOptions); + const data = await h( + {} as express.Request, + mockResponse as express.Response + ); - return expect( - h({} as express.Request, mockResponse as express.Response) - ).to.eventually.be.fulfilled.then(() => { - expect(res).to.eql(200); - }); + expect(data).to.deep.eq({}); }); }); @@ -53,14 +51,11 @@ describe('#createProxyStateHandler', () => { afterEach(() => { (setupStatesModule.setupStates as SinonSpy).restore(); }); - it('returns a 500', () => { + it('returns a 500', async () => { const h = createProxyStateHandler({} as ProxyOptions); + await h({} as express.Request, mockResponse as express.Response); - return expect( - h({} as express.Request, mockResponse as express.Response) - ).to.eventually.be.fulfilled.then(() => { - expect(res).to.eql(500); - }); + expect(res).to.eql(500); }); }); }); diff --git a/src/dsl/verifier/proxy/stateHandler/stateHandler.ts b/src/dsl/verifier/proxy/stateHandler/stateHandler.ts index 81206a690..1fd292ca1 100644 --- a/src/dsl/verifier/proxy/stateHandler/stateHandler.ts +++ b/src/dsl/verifier/proxy/stateHandler/stateHandler.ts @@ -1,6 +1,6 @@ import express from 'express'; -import { ProviderState, ProxyOptions } from '../types'; +import { ProxyOptions, ProviderState } from '../types'; import { setupStates } from './setupStates'; export const createProxyStateHandler = (config: ProxyOptions) => ( @@ -10,6 +10,6 @@ export const createProxyStateHandler = (config: ProxyOptions) => ( const message: ProviderState = req.body; return setupStates(message, config) - .then(() => res.sendStatus(200)) + .then((data) => res.json(data)) .catch((e) => res.status(500).send(e)); }; diff --git a/src/dsl/verifier/proxy/tracer.ts b/src/dsl/verifier/proxy/tracer.ts index 99cbd505f..38fa51c68 100644 --- a/src/dsl/verifier/proxy/tracer.ts +++ b/src/dsl/verifier/proxy/tracer.ts @@ -53,7 +53,7 @@ export const createResponseTracer = (): express.RequestHandler => ( chunks.push(Buffer.from(chunk)); } const body = Buffer.concat(chunks).toString('utf8'); - logger.debug('outgoing response', removeEmptyResponseProperties(body, res)); + logger.debug(removeEmptyResponseProperties(body, res), 'outgoing response'); oldEnd.apply(res, [chunk]); }; if (typeof next === 'function') { @@ -66,6 +66,6 @@ export const createRequestTracer = (): express.RequestHandler => ( _, next ) => { - logger.debug('incoming request', removeEmptyRequestProperties(req)); + logger.debug(removeEmptyRequestProperties(req), 'incoming request'); next(); }; diff --git a/src/dsl/verifier/proxy/types.ts b/src/dsl/verifier/proxy/types.ts index d4df5c8d8..0318b63c1 100644 --- a/src/dsl/verifier/proxy/types.ts +++ b/src/dsl/verifier/proxy/types.ts @@ -1,20 +1,51 @@ import express from 'express'; import { LogLevel } from '../../options'; +import { JsonMap, AnyJson } from '../../../common/jsonTypes'; -export interface StateHandler { - [name: string]: () => Promise; -} +export type Hook = () => Promise; +/** + * State handlers map a state description to a function + * that can setup the provider state + */ +export interface StateHandlers { + [name: string]: StateHandler; +} +/** + * Incoming provider state request + */ export interface ProviderState { - states?: [string]; + action: StateAction; + params: JsonMap; + state: string; } -export type Hook = () => Promise; +/** + * Specifies whether the state handler being setup or shutdown + */ +export type StateAction = 'setup' | 'teardown'; + +/** + * Respond to the state setup event, optionally returning a map of provider + * values to dynamically inject into the incoming request to test + */ +export type StateFunc = (parameters?: AnyJson) => Promise; + +/** + * Respond to the state setup event, with the ability to hook into the setup/teardown + * phase of the state + */ +export type StateFuncWithSetup = { + setup?: StateFunc; + teardown?: StateFunc; +}; + +export type StateHandler = StateFuncWithSetup | StateFunc; export interface ProxyOptions { logLevel?: LogLevel; requestFilter?: express.RequestHandler; - stateHandlers?: StateHandler; + stateHandlers?: StateHandlers; beforeEach?: Hook; afterEach?: Hook; validateSSL?: boolean; diff --git a/src/dsl/verifier/verifier.spec.ts b/src/dsl/verifier/verifier.spec.ts index 5c9dd1fc2..671a8f00e 100644 --- a/src/dsl/verifier/verifier.spec.ts +++ b/src/dsl/verifier/verifier.spec.ts @@ -35,7 +35,7 @@ describe('Verifier', () => { stateHandlers: { [state]: () => { executed = true; - return Promise.resolve('done'); + return Promise.resolve(); }, }, }; diff --git a/src/v3/pact.ts b/src/v3/pact.ts index ec9632db0..adf3a3aa4 100644 --- a/src/v3/pact.ts +++ b/src/v3/pact.ts @@ -3,6 +3,7 @@ import * as MatchersV3 from './matchers'; import { version as pactPackageVersion } from '../../package.json'; import PactNative, { Mismatch, MismatchRequest } from '../../native/index.node'; +import { JsonMap } from '../common/jsonTypes'; /** * Options for the mock server @@ -32,7 +33,7 @@ export interface PactV3Options { export interface V3ProviderState { description: string; - parameters?: unknown; + parameters?: JsonMap; } type TemplateHeaders = { @@ -162,7 +163,7 @@ export class PactV3 { ); } - public given(providerState: string, parameters?: unknown): PactV3 { + public given(providerState: string, parameters?: JsonMap): PactV3 { this.states.push({ description: providerState, parameters }); return this; } diff --git a/src/v3/verifier.spec.ts b/src/v3/verifier.spec.ts index cb7ec1185..d1dd51381 100644 --- a/src/v3/verifier.spec.ts +++ b/src/v3/verifier.spec.ts @@ -16,39 +16,35 @@ const { expect } = chai; describe('V3 Verifier', () => { describe('invalid configuration', () => { it('returns an error when no provider name is given', () => { - const result = new VerifierV3({ - logLevel: '', - provider: '', - providerBaseUrl: '', - }).verifyProvider(); - - return expect(result).to.eventually.be.rejectedWith( - Error, - 'Provider name is required' - ); + expect( + () => + new VerifierV3({ + logLevel: 'fatal', + provider: '', + providerBaseUrl: 'http://localhost', + }) + ).to.throw('provider name is required'); }); it('returns an error when no pactBrokerUrl and an empty list of pactUrls is given', () => { - const result = new VerifierV3({ - logLevel: '', - provider: 'unitTest', - providerBaseUrl: '', - pactUrls: [], - }).verifyProvider(); - return expect(result).to.eventually.be.rejectedWith( - Error, - 'Either a list of pactUrls or a pactBrokerUrl must be provided' - ); + expect( + () => + new VerifierV3({ + logLevel: 'fatal', + provider: 'unitTest', + providerBaseUrl: 'http://localhost', + pactUrls: [], + }) + ).to.throw('a list of pactUrls or a pactBrokerUrl must be provided'); }); it('returns an error when no pactBrokerUrl an no pactUrls is given', () => { - const result = new VerifierV3({ - logLevel: '', - provider: 'unitTest', - providerBaseUrl: '', - }).verifyProvider(); - return expect(result).to.eventually.be.rejectedWith( - Error, - 'Either a list of pactUrls or a pactBrokerUrl must be provided' - ); + expect( + () => + new VerifierV3({ + logLevel: 'fatal', + provider: 'unitTest', + providerBaseUrl: 'http://localhost', + }) + ).to.throw('a list of pactUrls or a pactBrokerUrl must be provided'); }); }); }); diff --git a/src/v3/verifier.ts b/src/v3/verifier.ts index 075da4edd..28126a3d4 100644 --- a/src/v3/verifier.ts +++ b/src/v3/verifier.ts @@ -1,20 +1,17 @@ import { isEmpty } from 'ramda'; -import express from 'express'; -import ConfigurationError from '../errors/configurationError'; -import logger from '../common/logger'; +import { ProxyOptions, StateHandlers } from 'dsl/verifier/proxy/types'; + +import * as express from 'express'; +import * as http from 'http'; +import * as url from 'url'; +import { localAddresses } from '../common/net'; +import { createProxy, waitForServerReady } from '../dsl/verifier/proxy'; -import PactNative from '../../native/index.node'; +import ConfigurationError from '../errors/configurationError'; +import logger, { setLogLevel } from '../common/logger'; -/** - * Define needed state for given pacts - */ -export type StateHandler = ( - setup: boolean, - parameters: Record -) => void; +import * as PactNative from '../../native/index.node'; -// Commented out fields highlight areas we need to look at for compatibility -// with existing API, as a sort of "TODO" list. export interface VerifierV3Options { provider: string; logLevel: string; @@ -25,27 +22,18 @@ export interface VerifierV3Options { pactBrokerUsername?: string; pactBrokerPassword?: string; pactBrokerToken?: string; - - /** - * The timeout in milliseconds for request filters and provider state handlers - * to execute within - */ - callbackTimeout?: number; - // customProviderHeaders?: string[] + // The timeout in milliseconds for state handlers and individuals + // requests to execute within + timeout?: number; publishVerificationResult?: boolean; providerVersion?: string; requestFilter?: express.RequestHandler; - stateHandlers?: Record; - + stateHandlers?: StateHandlers; consumerVersionTags?: string | string[]; providerVersionTags?: string | string[]; consumerVersionSelectors?: ConsumerVersionSelector[]; enablePending?: boolean; - // timeout?: number; - // verbose?: boolean; includeWipPactsSince?: string; - // out?: string; - // logDir?: string; disableSSLVerification?: boolean; } @@ -61,62 +49,133 @@ interface InternalVerifierOptions { consumerVersionSelectorsString?: string[]; } +export type VerifierOptions = VerifierV3Options & ProxyOptions; export class VerifierV3 { - private config: VerifierV3Options; + private config: VerifierOptions; - constructor(config: VerifierV3Options) { + private address = 'http://localhost'; + + private stateSetupPath = '/_pactSetup'; + + constructor(config: VerifierOptions) { this.config = config; + this.validateConfiguration(); } /** * Verify a HTTP Provider */ public verifyProvider(): Promise { - return new Promise((resolve, reject) => { - const config: VerifierV3Options & InternalVerifierOptions = { - ...this.config, - }; + // Start the verification CLI proxy server + const server = createProxy(this.config, this.stateSetupPath); - if (isEmpty(this.config)) { - reject(new ConfigurationError('No configuration provided to verifier')); - } + // Run the verification once the proxy server is available + // and properly shut down the proxy before returning + return waitForServerReady(server) + .then(this.runProviderVerification()) + .then( + (result: unknown) => + new Promise((resolve) => { + server.close(() => { + resolve(result); + }); + }) + ) + .catch( + (e: Error) => + new Promise((_, reject) => { + server.close(() => { + reject(e); + }); + }) + ); + } - // This is just too messy to do on the rust side. neon-serde would have helped, but appears unmaintained - // and is currently incompatible - if (this.config.consumerVersionSelectors) { - config.consumerVersionSelectorsString = this.config.consumerVersionSelectors.map( - (s) => JSON.stringify(s) - ); - } + private validateConfiguration() { + const config: VerifierV3Options & InternalVerifierOptions = { + ...this.config, + }; - if (!this.config.provider) { - reject(new ConfigurationError('Provider name is required')); - } - if ( - (isEmpty(this.config.pactUrls) || !this.config.pactUrls) && - !this.config.pactBrokerUrl - ) { - reject( - new ConfigurationError( - 'Either a list of pactUrls or a pactBrokerUrl must be provided' - ) + if (this.config.logLevel && !isEmpty(this.config.logLevel)) { + setLogLevel(this.config.logLevel); + } + + if (this.config.validateSSL === undefined) { + this.config.validateSSL = true; + } + + if (this.config.changeOrigin === undefined) { + this.config.changeOrigin = false; + + if (!this.isLocalVerification()) { + this.config.changeOrigin = true; + logger.warn( + `non-local provider address ${this.config.providerBaseUrl} detected, setting 'changeOrigin' to 'true'. This property can be overridden.` ); } + } - try { - PactNative.verify_provider(this.config, (err, val) => { - if (err || !val) { - logger.debug('In verify_provider callback: FAILED with', err, val); - reject(err); - } else { - logger.debug('In verify_provider callback: SUCCEEDED with', val); - resolve(val); - } - }); - logger.debug('Submitted test to verify_provider'); - } catch (e) { - reject(e); - } - }); + if (isEmpty(this.config)) { + throw new ConfigurationError('no configuration provided to verifier'); + } + + // This is just too messy to do on the rust side. neon-serde would have helped, but appears unmaintained + // and is currently incompatible + if (this.config.consumerVersionSelectors) { + config.consumerVersionSelectorsString = this.config.consumerVersionSelectors.map( + (s) => JSON.stringify(s) + ); + } + + if (!this.config.provider) { + throw new ConfigurationError('provider name is required'); + } + + if ( + (isEmpty(this.config.pactUrls) || !this.config.pactUrls) && + !this.config.pactBrokerUrl + ) { + throw new ConfigurationError( + 'a list of pactUrls or a pactBrokerUrl must be provided' + ); + } + + return config; + } + + // Run the Verification CLI process + private runProviderVerification() { + return (server: http.Server) => + new Promise((resolve, reject) => { + const opts = { + providerStatesSetupUrl: `${this.address}:${server.address().port}${ + this.stateSetupPath + }`, + ...this.config, + providerBaseUrl: `${this.address}:${server.address().port}`, + }; + + try { + PactNative.verify_provider(opts, (err, val) => { + if (err || !val) { + logger.trace({ err, val }, 'verification failed'); + reject(err); + } else { + logger.trace({ val }, 'verification succeeded'); + resolve(val); + } + }); + logger.trace('submitted test to verify_provider'); + } catch (e) { + reject(e); + } + }); + } + + private isLocalVerification() { + const u = new url.URL(this.config.providerBaseUrl); + return ( + localAddresses.includes(u.host) || localAddresses.includes(u.hostname) + ); } }