# encoding=utf-8
import collections.abc as abc
import logging
import platform
import subprocess # nosec
import time
from pathlib import Path
from threading import Lock
from typing import Iterable, List, Mapping, MutableMapping, Sequence, Set, Union, cast
import requests
from _pytest.fixtures import FixtureRequest # type: ignore
from furl import furl
from mbtest.imposters import Imposter
from mbtest.imposters.base import JsonStructure
from requests import RequestException
DEFAULT_MB_EXECUTABLE = str(
Path("node_modules") / ".bin" / ("mb.cmd" if platform.system() == "Windows" else "mb")
)
logger = logging.getLogger(__name__)
[docs]def mock_server(
request: FixtureRequest,
executable: Union[str, Path] = DEFAULT_MB_EXECUTABLE,
port: int = 2525,
timeout: int = 5,
debug: bool = True,
allow_injection: bool = True,
local_only: bool = True,
data_dir: Union[str, None] = ".mbdb",
) -> "ExecutingMountebankServer":
"""`Pytest fixture <https://docs.pytest.org/en/latest/fixture.html>`_, making available a mock server, running one
or more impostors, one for each domain being mocked.
Use in a pytest conftest.py fixture as follows::
@pytest.fixture(scope="session")
def mock_server(request):
return server.mock_server(request)
Test will look like::
def test_an_imposter(mock_server):
imposter = Imposter(Stub(Predicate(path='/test'),
Response(body='sausages')),
record_requests=True)
with mock_server(imposter) as s:
r = requests.get('{0}/test'.format(imposter.url))
assert_that(r, is_response().with_status_code(200).and_body("sausages"))
assert_that(s, had_request(path='/test', method="GET"))
:param request: Request for a fixture from a test or fixture function.
:param executable: Alternate location for the Mountebank executable.
:param port: Server port.
:param timeout: specifies how long to wait for the Mountebank server to start.
:param debug: Start the server in debug mode, which records all requests. This needs to be `True` for the
:py:func:`mbtest.matchers.had_request` matcher to work.
:param allow_injection: Allow JavaScript injection. If `True`, `local_only` should also be `True`,as per
`Mountebank security <http://www.mbtest.org/docs/security>`_.
:param local_only: Accept request only from localhost.
:param data_dir: Persist all operations to disk, in this directory.
:returns: Mock server.
"""
server = ExecutingMountebankServer(
executable=executable,
port=port,
timeout=timeout,
debug=debug,
allow_injection=allow_injection,
local_only=local_only,
data_dir=data_dir,
)
def close():
server.close()
request.addfinalizer(close)
return server
[docs]class MountebankServer:
"""Allow addition of imposters to an already running Mountebank mock server.
Test will look like::
def test_an_imposter(mock_server):
mb = MountebankServer(1234)
imposter = Imposter(Stub(Predicate(path='/test'),
Response(body='sausages')),
record_requests=True)
with mb(imposter) as s:
r = requests.get('{0}/test'.format(imposter.url))
assert_that(r, is_response().with_status_code(200).and_body("sausages"))
assert_that(s, had_request(path='/test', method="GET"))
Impostors will be torn down when the `with` block is exited.
:param port: Server port.
:param scheme: Server scheme, if not `http`.
:param host: Server host, if not `localhost`.
:param imposters_path: Impostors path, if not `imposters`.
"""
def __init__(
self,
port: int,
scheme: str = "http",
host: str = "localhost",
imposters_path: str = "imposters",
):
self.server_port = port
self.host = host
self.scheme = scheme
self.imposters_path = imposters_path
def __call__(self, imposters: Sequence[Imposter]) -> "MountebankServer":
self.imposters = imposters
self.running_imposters_by_port = {} # type: MutableMapping[int, Imposter]
return self
def __enter__(self) -> "MountebankServer":
self.add_imposters(self.imposters)
return self
def __exit__(self, ex_type, ex_value, ex_traceback) -> None:
self.delete_imposters()
[docs] def add_imposters(self, definition: Union[Imposter, Iterable[Imposter]]) -> None:
"""Add imposters to Mountebank server.
:param definition: One or more Imposters.
:type definition: Imposter or list(Imposter)
"""
if isinstance(definition, abc.Iterable):
for imposter in definition:
self.add_imposters(imposter)
else:
json = definition.as_structure()
post = requests.post(self.server_url, json=json, timeout=10)
post.raise_for_status()
definition.port = post.json()["port"]
definition.host = self.host
self.running_imposters_by_port[cast(int, definition.port)] = definition
[docs] def delete_imposters(self) -> None:
while self.running_imposters_by_port:
imposter_port, imposter = self.running_imposters_by_port.popitem()
requests.delete(self.imposter_url(imposter_port)).raise_for_status()
[docs] def get_actual_requests(self) -> Mapping[int, JsonStructure]:
requests_by_impostor = {}
for imposter_port in self.running_imposters_by_port:
response = requests.get(self.imposter_url(imposter_port), timeout=5)
response.raise_for_status()
json = response.json()
requests_by_impostor[imposter_port] = json["requests"]
return requests_by_impostor
@property
def server_url(self) -> furl:
return furl().set(
scheme=self.scheme, host=self.host, port=self.server_port, path=self.imposters_path
)
[docs] def imposter_url(self, imposter_port: int) -> furl:
return self.server_url.add(path=str(imposter_port))
[docs] def query_all_impostors(self):
server_info = requests.get(self.server_url)
imposters = server_info.json()["imposters"]
for imposter in imposters:
yield Imposter.from_structure(requests.get(imposter["_links"]["self"]["href"]).json())
[docs]class ExecutingMountebankServer(MountebankServer):
"""A Mountebank mock server, running one or more impostors, one for each domain being mocked.
Test will look like::
def test_an_imposter(mock_server):
mb = ExecutingMountebankServer()
imposter = Imposter(Stub(Predicate(path='/test'),
Response(body='sausages')),
record_requests=True)
with mb(imposter) as s:
r = requests.get('{0}/test'.format(imposter.url))
assert_that(r, is_response().with_status_code(200).and_body("sausages"))
assert_that(s, had_request(path='/test', method="GET"))
mb.close()
The mountebank server will be started when this class is instantiated, and needs to be closed if it's not to be
left running. Consider using the :meth:`mock_server` pytest fixture, which will take care of this for you.
:param executable: Optional, alternate location for the Mountebank executable.
:param port: Server port.
:param timeout: How long to wait for the Mountebank server to start.
:param debug: Start the server in debug mode, which records all requests. This needs to be `True` for the
:py:func:`mbtest.matchers.had_request` matcher to work.
:param allow_injection: Allow JavaScript injection. If `True`, `local_only` should also be `True`,as per
`Mountebank security <http://www.mbtest.org/docs/security>`_.
:param local_only: Accept request only from localhost.
:param data_dir: Persist all operations to disk, in this directory.
"""
running = set() # type: Set[int]
start_lock = Lock()
def __init__(
self,
executable: Union[str, Path] = DEFAULT_MB_EXECUTABLE,
port: int = 2525,
timeout: int = 5,
debug: bool = True,
allow_injection: bool = True,
local_only: bool = True,
data_dir: Union[str, None] = ".mbdb",
) -> None:
super(ExecutingMountebankServer, self).__init__(port)
with self.start_lock:
if self.server_port in self.running:
raise MountebankPortInUseException(
"Already running on port {0}.".format(self.server_port)
)
try:
options = self._build_options(port, debug, allow_injection, local_only, data_dir)
self.mb_process = subprocess.Popen([executable] + options) # nosec
self._await_start(timeout)
self.running.add(port)
logger.info(
"Spawned mb process %s on port %s.", self.mb_process.pid, self.server_port
)
except OSError:
logger.error(
"Failed to spawn mb process with executable at %s. Have you installed Mountebank?",
executable,
)
raise
@staticmethod
def _build_options(
port: int, debug: bool, allow_injection: bool, local_only: bool, data_dir: Union[str, None]
):
options = [
"start",
"--port",
str(port),
] # type: List[str]
if debug:
options.append("--debug")
if allow_injection:
options.append("--allowInjection")
if local_only:
options.append("--localOnly")
if data_dir:
options += [
"--datadir",
data_dir,
]
return options
def _await_start(self, timeout: int) -> None:
start_time = time.time()
while time.time() - start_time < timeout:
try:
requests.get(self.server_url, timeout=1).raise_for_status()
started = True
break
except RequestException:
started = False
time.sleep(0.1)
if not started:
raise MountebankTimeoutError(
"Mountebank failed to start within {0} seconds.".format(timeout)
)
logger.debug("Server started at %s.", self.server_url)
[docs] def close(self) -> None:
self.mb_process.terminate()
self.mb_process.wait()
self.running.remove(self.server_port)
logger.info(
"Terminated mb process %s on port %s status %s.",
self.mb_process.pid,
self.server_port,
self.mb_process.returncode,
)
[docs]class MountebankException(Exception):
"""Exception using Mountebank server."""
pass
[docs]class MountebankPortInUseException(Exception):
"""Mountebank server failed to start - port already in use."""
pass
[docs]class MountebankTimeoutError(MountebankException):
"""Mountebank server failed to start in time."""
pass