diff --git a/examples/v3/todo/requirements.txt b/examples/v3/todo/requirements.txt index 6757016f19..3150327cf4 100644 --- a/examples/v3/todo/requirements.txt +++ b/examples/v3/todo/requirements.txt @@ -3,3 +3,4 @@ json2xml==3.3.2 pytest==5.4.2 pact-python==2.0.0b0 requests==2.23.0 +pytest-flask==1.1.0 diff --git a/examples/v3/todo/src/todo_provider.py b/examples/v3/todo/src/todo_provider.py index 5d1fe3489f..3e4045ef07 100644 --- a/examples/v3/todo/src/todo_provider.py +++ b/examples/v3/todo/src/todo_provider.py @@ -3,46 +3,53 @@ from json2xml.utils import readfromstring import json -app = Flask(__name__) +def create_app(): + app = Flask(__name__) -@app.route('/projects') -def projects(): - todo_response = [ - { - 'id': 1, - 'name': "Project 1", - 'type': "activity", - 'due': "2016-02-11T09:46:56.023Z", - 'tasks': [ - { - 'done': True, - 'id': 1, - 'name': "Task 1", - }, - { - 'done': True, - 'id': 2, - 'name': "Task 2", - }, - { - 'done': True, - 'id': 3, - 'name': "Task 3", - }, - { - 'done': True, - 'id': 4, - 'name': "Task 4", - }, - ] - } - ] - if request.headers['accept'] == 'application/xml': - return json2xml.Json2xml(readfromstring(json.dumps(todo_response)), wrapper='projects').to_xml() - else: - return jsonify(todo_response) + @app.route('/') + def index(): + return '' + + @app.route('/projects') + def projects(): + todo_response = [ + { + 'id': 1, + 'name': "Project 1", + 'type': "activity", + 'due': "2016-02-11T09:46:56.023Z", + 'tasks': [ + { + 'done': True, + 'id': 1, + 'name': "Task 1", + }, + { + 'done': True, + 'id': 2, + 'name': "Task 2", + }, + { + 'done': True, + 'id': 3, + 'name': "Task 3", + }, + { + 'done': True, + 'id': 4, + 'name': "Task 4", + }, + ] + } + ] + if request.headers['accept'] == 'application/xml': + return json2xml.Json2xml(readfromstring(json.dumps(todo_response)), wrapper='projects').to_xml() + else: + return jsonify(todo_response) + + return app if __name__ == '__main__': - app.run(debug=True, port=5001) + create_app().run(debug=True, port=5001) diff --git a/examples/v3/todo/tests/provider.spec.js b/examples/v3/todo/tests/provider.spec.js deleted file mode 100644 index 594575b49a..0000000000 --- a/examples/v3/todo/tests/provider.spec.js +++ /dev/null @@ -1,39 +0,0 @@ -const { - PactV3, - MatchersV3, - XmlBuilder, - VerifierV3, -} = require("@pact-foundation/pact/v3") -const chai = require("chai") -const chaiAsPromised = require("chai-as-promised") -chai.use(chaiAsPromised) -const { server } = require("../provider.js") -const path = require("path") - -server.listen(8081, () => { - console.log("SOAP API listening on http://localhost:8081") -}) - -describe("Pact XML Verification", () => { - it("validates the expectations of Matching Service", () => { - const opts = { - provider: "XML Service", - providerBaseUrl: "http://localhost:8081", - pactUrls: ["./pacts/TodoApp-TodoServiceV3.json"], - // pactUrls: [ - // path.resolve( - // process.cwd(), - // "./pacts/matching_service-animal_profile_service.json" - // ), - // ], - stateHandlers: { - "i have a list of projects": setup => {}, - }, - } - - return new VerifierV3(opts).verifyProvider().then(output => { - console.log("Pact Verification Complete!") - console.log(output) - }) - }) -}) diff --git a/examples/v3/todo/tests/test_todo_provider.py b/examples/v3/todo/tests/test_todo_provider.py new file mode 100644 index 0000000000..c789d7877d --- /dev/null +++ b/examples/v3/todo/tests/test_todo_provider.py @@ -0,0 +1,19 @@ +import pytest +from ..src.todo_provider import create_app +from pact import VerifierV3 +from flask import url_for + + +@pytest.fixture(scope='session') +def app(): + return create_app() + + +@pytest.mark.usefixtures('live_server') +def test_pact_verification(): + verifier = VerifierV3(provider='TodoServiceV3', + provider_base_url=url_for('index', _external=True)) + assert verifier.verify_pacts( + sources=['./pacts/TodoApp-TodoServiceV3.json'] + ) + diff --git a/pact-python-v3/Cargo.lock b/pact-python-v3/Cargo.lock index a9c6e2bf81..e47ceb3344 100644 --- a/pact-python-v3/Cargo.lock +++ b/pact-python-v3/Cargo.lock @@ -993,6 +993,8 @@ dependencies = [ name = "pact_python_v3" version = "0.0.0" dependencies = [ + "ansi_term 0.12.1", + "async-trait", "bytes", "cpython", "env_logger 0.7.1", @@ -1003,7 +1005,10 @@ dependencies = [ "pact_mock_server", "pact_mock_server_ffi", "pact_verifier", + "regex", + "reqwest", "serde_json", + "url", "uuid", ] diff --git a/pact-python-v3/Cargo.toml b/pact-python-v3/Cargo.toml index 82e4dcced7..dc063a8e61 100644 --- a/pact-python-v3/Cargo.toml +++ b/pact-python-v3/Cargo.toml @@ -20,11 +20,20 @@ serde_json = "1.0" uuid = { version = "0.8", features = ["v4"] } lazy_static = "1.4.0" bytes = { version = "1", features = ["serde"] } +url = "2.2.0" +ansi_term = "0.12.1" +async-trait = "0.1.24" +regex = "1" [dependencies.cpython] version = "0.5" features = ["extension-module"] +[dependencies.reqwest] +version = "0.11.0" +default-features = false +features = ["rustls-tls"] + [target.x86_64-apple-darwin] rustflags = [ "-C", "link-arg=-undefined", diff --git a/pact-python-v3/src/lib.rs b/pact-python-v3/src/lib.rs index 140bc1155f..d84d3552b6 100644 --- a/pact-python-v3/src/lib.rs +++ b/pact-python-v3/src/lib.rs @@ -2,7 +2,7 @@ use std::cell::RefCell; use std::collections::HashMap; use std::fs; use std::str::FromStr; -use std::sync::Mutex; +use std::sync::{Mutex, Arc}; use bytes::Bytes; use cpython::*; @@ -22,6 +22,9 @@ use pact_mock_server_ffi::bodies::{process_array, process_object}; use serde_json::{json, Value}; use serde_json::map::Map; use uuid::Uuid; +use crate::verifier::{setup_provider_config, PythonProviderStateExecutor}; + +mod verifier; lazy_static! { static ref MANAGER: Mutex = Mutex::new(ServerManager::new()); @@ -31,6 +34,7 @@ py_module_initializer!(pact_python_v3, |py, m| { m.add(py, "__doc__", "Pact Python V3 support (provided by Pact-Rust FFI)")?; m.add(py, "init", py_fn!(py, init_lib(*args, **kwargs)))?; m.add(py, "generate_datetime_string", py_fn!(py, generate_datetime_string(format: &str)))?; + m.add(py, "verify_provider", py_fn!(py, verify_provider(*args, **kwargs)))?; m.add_class::(py)?; Ok(()) }); @@ -501,3 +505,32 @@ fn json_to_pyobj(py: Python, val: &Value) -> PyObject { Value::Object(map) => map.iter().map(|(key, value)| (key.clone(), json_to_pyobj(py, value))).collect::>().to_py_object(py).into_object() } } + +fn verify_provider( + py: Python, + args: &PyTuple, + kwargs: Option<&PyDict> +) -> PyResult { + let arg1 = args.get_item(py, 0); + let provider = arg1.cast_as::(py)?.to_string_lossy(py); + let arg2 = args.get_item(py, 1); + let base_url = arg2.cast_as::(py)?.to_string_lossy(py); + let arg3 = args.get_item(py, 2); + let options = arg3.cast_as::(py)?; + + debug!("Verifying provider '{}' running at '{}'", provider, base_url); + let (provider_info, source, options, filter, consumers) = setup_provider_config(py, provider.as_ref(), base_url.as_ref(), options)?; + + debug!("Pact sources = {:?}", source); + let result = pact_verifier::verify_provider( + provider_info, + source, + filter, + consumers, + options, + &Arc::new(PythonProviderStateExecutor::new()) + ); + debug!("result = {}", result); + + Ok(PyBool::get(py, result)) +} diff --git a/pact-python-v3/src/verifier.rs b/pact-python-v3/src/verifier.rs new file mode 100644 index 0000000000..cdb27a512e --- /dev/null +++ b/pact-python-v3/src/verifier.rs @@ -0,0 +1,242 @@ +use cpython::{Python, PyDict, PyResult, PyObject, PyBool, PyErr, exc, PyList, PyString}; +use pact_verifier::{PactSource, FilterInfo, VerificationOptions, ProviderInfo}; +use log::*; +use url::Url; +use ansi_term::Colour::*; +use pact_verifier::callback_executors::{RequestFilterExecutor, ProviderStateExecutor, ProviderStateError}; +use std::sync::Arc; +use pact_matching::models::Request; +use std::collections::HashMap; +use serde_json::Value; +use pact_matching::models::provider_states::ProviderState; +use async_trait::async_trait; +use regex::Regex; +use maplit::*; + +pub(crate) fn setup_provider_config( + py: Python, + provider: &str, + base_url: &str, + kwargs: &PyDict +) -> PyResult<(ProviderInfo, Vec, VerificationOptions, FilterInfo, Vec)> { + let mut provider_info = ProviderInfo { + name: provider.to_string(), + .. ProviderInfo::default() + }; + + match Url::parse(base_url) { + Ok(url) => { + provider_info.protocol = url.scheme().into(); + provider_info.host = url.host_str().unwrap_or("localhost").into(); + provider_info.port = url.port(); + provider_info.path = url.path().into(); + }, + Err(err) => { + error!("Failed to parse base_url: {}", err); + println!(" {}", Red.paint("ERROR: base_url is not a valid URL")); + return Err(PyErr::new::(py, format!("Failed to parse base_url: {}", err))); + } + }; + + let mut pacts: Vec = vec![]; + dbg!(kwargs.len(py)); + if let Some(pact_urls) = kwargs.get_item(py, "sources") { + if let Ok(pact_urls) = pact_urls.cast_as::(py) { + for pact in pact_urls.iter(py) { + if let Ok(pact) = pact.cast_as::(py) { + let pact_str = pact.to_string_lossy(py); + let re = Regex::new(r"^\w+://").unwrap(); + if dbg!(re.is_match(pact_str.as_ref())) { + pacts.push(PactSource::URL(pact_str.to_string(), None)) + } else { + pacts.push(PactSource::File(pact_str.to_string())) + } + } else { + println!(" {}", Yellow.paint (format!("WARN: pact_url '{}' is not a valid URL string", pact))) + } + } + } else { + println!(" {}", Yellow.paint ("WARN: pact_urls does not contain a valid list of URL strings")) + } + } + + // let provider_tags = match get_string_array(&mut cx, &config, "providerVersionTags") { + // Ok(tags) => tags, + // Err(e) => return cx.throw_error(e) + // }; + // + // match config.get(&mut cx, "pactBrokerUrl") { + // Ok(url) => match url.downcast::() { + // Ok(url) => { + // let pending = get_bool_value(&mut cx, &config, "enablePending"); + // let wip = get_string_value(&mut cx, &config, "includeWipPactsSince"); + // let consumer_version_tags = match get_string_array(&mut cx, &config, "consumerVersionTags") { + // Ok(tags) => tags, + // Err(e) => return cx.throw_error(e) + // }; + // let selectors = consumer_tags_to_selectors(consumer_version_tags); + // + // if let Some(username) = get_string_value(&mut cx, &config, "pactBrokerUsername") { + // let password = get_string_value(&mut cx, &config, "pactBrokerPassword"); + // pacts.push(PactSource::BrokerWithDynamicConfiguration { provider_name: provider.clone(), broker_url: url.value(), enable_pending: pending, include_wip_pacts_since: wip, provider_tags: provider_tags.clone(), selectors: selectors, auth: Some(HttpAuth::User(username, password)), links: vec![] }) + // } else if let Some(token) = get_string_value(&mut cx, &config, "pactBrokerToken") { + // pacts.push(PactSource::BrokerWithDynamicConfiguration { provider_name: provider.clone(), broker_url: url.value(), enable_pending: pending, include_wip_pacts_since: wip, provider_tags: provider_tags.clone(), selectors: selectors, auth: Some(HttpAuth::Token(token)), links: vec![] }) + // } else { + // pacts.push(PactSource::BrokerWithDynamicConfiguration { provider_name: provider.clone(), broker_url: url.value(), enable_pending: pending, include_wip_pacts_since: wip, provider_tags: provider_tags.clone(), selectors: selectors, auth: None, links: vec![] }) + // } + // }, + // Err(_) => { + // if !url.is_a::() { + // println!(" {}", Red.paint("ERROR: pactBrokerUrl must be a string value")); + // cx.throw_error("pactBrokerUrl must be a string value")?; + // } + // } + // }, + // _ => () + // }; + // + // debug!("pacts = {:?}", pacts); + // if pacts.is_empty() { + // println!(" {}", Red.paint("ERROR: No pacts were found to verify!")); + // cx.throw_error("No pacts were found to verify!")?; + // } + // + // let mut provider_info = ProviderInfo { + // name: provider.clone(), + // .. ProviderInfo::default() + // }; + // + // match get_string_value(&mut cx, &config, "providerBaseUrl") { + // Some(url) => match Url::parse(&url) { + // Ok(url) => { + // provider_info.protocol = url.scheme().into(); + // provider_info.host = url.host_str().unwrap_or("localhost").into(); + // provider_info.port = url.port(); + // provider_info.path = url.path().into(); + // }, + // Err(err) => { + // error!("Failed to parse pactBrokerUrl: {}", err); + // println!(" {}", Red.paint("ERROR: pactBrokerUrl is not a valid URL")); + // } + // }, + // None => () + // }; + // + // debug!("provider_info = {:?}", provider_info); + // + // let callback_timeout = get_integer_value(&mut cx, &config, "callbackTimeout").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: callback_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 publish = match config.get(&mut cx, "publishVerificationResult") { + // Ok(publish) => match publish.downcast::() { + // Ok(publish) => publish.value(), + // Err(_) => { + // warn!("publishVerificationResult must be a boolean value. Ignoring it"); + // false + // } + // }, + // _ => false + // }; + // + // let provider_version = match config.get(&mut cx, "providerVersion") { + // Ok(provider_version) => match provider_version.downcast::() { + // Ok(provider_version) => Some(provider_version.value().to_string()), + // Err(_) => if !provider_version.is_a::() { + // println!(" {}", Red.paint("ERROR: providerVersion must be a string value")); + // return cx.throw_error("providerVersion must be a string value") + // } else { + // None + // } + // }, + // _ => None + // }; + // + // if publish && provider_version.is_none() { + // println!(" {}", Red.paint("ERROR: providerVersion must be provided if publishing verification results in enabled (publishVerificationResult == true)")); + // return cx.throw_error("providerVersion must be provided if publishing verification results in enabled (publishVerificationResult == true)")? + // } + // + // let disable_ssl_verification = match config.get(&mut cx, "disableSSLVerification") { + // Ok(disable) => match disable.downcast::() { + // Ok(disable) => disable.value(), + // Err(_) => { + // if !disable.is_a::() { + // warn!("disableSSLVerification must be a boolean value. Ignoring it"); + // } + // false + // } + // }, + // _ => false + // }; + + let filter_info = FilterInfo::None; + let consumers_filter: Vec = vec![]; + let options = VerificationOptions { + // publish, + // provider_version, + // build_url: None, + // request_filter, + // provider_tags, + // disable_ssl_verification, + // callback_timeout, + .. VerificationOptions::default() + }; + Ok((provider_info, pacts, options, filter_info, consumers_filter)) +} + +pub(crate) struct PythonRequestFilterExecutor; + +impl RequestFilterExecutor for PythonRequestFilterExecutor { + fn call(self: Arc, request: &Request) -> Request { + unimplemented!() + } +} + +pub(crate) struct PythonProviderStateExecutor; + +impl PythonProviderStateExecutor { + pub(crate) fn new() -> PythonProviderStateExecutor { + PythonProviderStateExecutor {} + } +} + +#[async_trait] +impl ProviderStateExecutor for PythonProviderStateExecutor { + async fn call(self: Arc, interaction_id: Option, provider_state: &ProviderState, setup: bool, client: Option<&reqwest::Client>) -> Result, ProviderStateError> { + Ok(hashmap!{}) + } +} diff --git a/pact/__init__.py b/pact/__init__.py index 03bf69dd1d..c887bce886 100644 --- a/pact/__init__.py +++ b/pact/__init__.py @@ -8,8 +8,10 @@ from .provider import Provider from .verifier import Verifier from .pact_v3 import PactV3 +from .verifier_v3 import VerifierV3 from .__version__ import __version__ # noqa: F401 __all__ = ('Broker', 'Consumer', 'EachLike', 'Like', 'MessageConsumer', 'MessagePact', - 'Pact', 'Provider', 'SomethingLike', 'Term', 'Format', 'Verifier', 'PactV3') + 'Pact', 'Provider', 'SomethingLike', 'Term', 'Format', 'Verifier', 'PactV3', + 'VerifierV3') diff --git a/pact/verifier_v3.py b/pact/verifier_v3.py new file mode 100644 index 0000000000..6d857a6f38 --- /dev/null +++ b/pact/verifier_v3.py @@ -0,0 +1,59 @@ +"""Classes and methods to verify Contracts (V3 implementation).""" + +from pact_python_v3 import verify_provider + + +class VerifierV3(object): + """A Pact V3 Verifier.""" + + def __init__(self, provider, provider_base_url, **kwargs): + """Create a new Verifier. + + Args: + provider ([String]): provider name + provider_base_url ([String]): provider url + + """ + self.provider = provider + self.provider_base_url = provider_base_url + + def __str__(self): + """Return string representation. + + Returns: + [String]: verifier description. + + """ + return 'V3 Verifier for {} with url {}'.format(self.provider, self.provider_base_url) + + def verify_pacts(self, **kwargs): + """Verify the pacts against the provider. + + Args: + sources ([string]): Pact sources + pactBrokerUrl?: string + providerStatesSetupUrl?: string + pactBrokerUsername?: string + pactBrokerPassword?: string + pactBrokerToken?: string + + callbackTimeout?: number + publishVerificationResult?: boolean + providerVersion?: string + requestFilter?: (req: any) => any + stateHandlers?: any + + consumerVersionTags?: string | string[] + providerVersionTags?: string | string[] + // consumerVersionSelectors?: ConsumerVersionSelector[]; + enablePending?: boolean + includeWipPactsSince?: string + disableSSLVerification?: boolean + + Returns: + success: True if no failures + + """ + + print(kwargs) + return verify_provider(self.provider, self.provider_base_url, kwargs)