diff --git a/.gitignore b/.gitignore index c24b91c..4171794 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,167 @@ - -# Created by https://www.toptal.com/developers/gitignore/api/macos,linux,jetbrains,visualstudiocode -# Edit at https://www.toptal.com/developers/gitignore?templates=macos,linux,jetbrains,visualstudiocode +# Created by https://www.toptal.com/developers/gitignore/api/linux,macos,jetbrains,visualstudiocode,python,django,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=linux,macos,jetbrains,visualstudiocode,python,django,windows + +### Django ### +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +db.sqlite3-journal +media + +# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ +# in your Git repository. Update and uncomment the following line accordingly. +# /staticfiles/ + +### Django.Python Stack ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo + +# Django stuff: + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ ### JetBrains ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider @@ -155,6 +316,82 @@ Network Trash Folder Temporary Items .apdisk +### Python ### +# Byte-compiled / optimized / DLL files + +# C extensions + +# Distribution / packaging + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. + +# Installer logs + +# Unit test / coverage reports + +# Translations + +# Django stuff: + +# Flask stuff: + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# Jupyter Notebook + +# IPython + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow + +# Celery stuff + +# SageMath parsed files + +# Environments + +# Spyder project settings + +# Rope project settings + +# mkdocs documentation + +# mypy + +# Pyre type checker + +# pytype static type analyzer + +# Cython debug symbols + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. + ### VisualStudioCode ### .vscode/* !.vscode/settings.json @@ -176,4 +413,36 @@ Temporary Items # Support for Project snippet scope -# End of https://www.toptal.com/developers/gitignore/api/macos,linux,jetbrains,visualstudiocode \ No newline at end of file +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/linux,macos,jetbrains,visualstudiocode,python,django,windows + +.github +.vscode +demo_file.py +tests +conftest.py \ No newline at end of file diff --git a/keploy/__init__.py b/keploy/__init__.py new file mode 100644 index 0000000..144d309 --- /dev/null +++ b/keploy/__init__.py @@ -0,0 +1,3 @@ +from .keploy import Keploy +from .models import * +from .mode import setMode \ No newline at end of file diff --git a/keploy/client.py b/keploy/client.py new file mode 100644 index 0000000..e69de29 diff --git a/keploy/constants.py b/keploy/constants.py new file mode 100644 index 0000000..692fc89 --- /dev/null +++ b/keploy/constants.py @@ -0,0 +1,7 @@ +MODE_RECORD = "record" +MODE_TEST = "test" +MODE_OFF = "off" +USE_HTTPS = 'https' +USE_HTTP = 'http' +ALLOWED_DEPENDENCY_TYPES = ['NO_SQL_DB', 'SQL_DB', 'GRPC', 'HTTP_CLIENT'] +ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE'] \ No newline at end of file diff --git a/keploy/keploy.py b/keploy/keploy.py new file mode 100644 index 0000000..5da5d16 --- /dev/null +++ b/keploy/keploy.py @@ -0,0 +1,263 @@ +import json +import logging +import http.client +import re +import time +from typing import Iterable, List, Mapping, Optional, Sequence +from keploy.mode import mode +from keploy.constants import MODE_TEST, USE_HTTPS + +from keploy.models import Config, Dependency, HttpResp, TestCase, TestCaseRequest, TestReq + + +class Keploy(object): + + def __init__(self, conf:Config) -> None: + + if not isinstance(conf, Config): + raise TypeError("Please provide a valid keploy configuration.") + + logger = logging.getLogger('keploy') + logger.setLevel(logging.DEBUG) + + self._config = conf + self._logger = logger + self._dependencies:Mapping[str, List[Dependency]] = {} + self._responses:Mapping[str, HttpResp] = {} + self._client = None + + if self._config.server.protocol == USE_HTTPS: + self._client = http.client.HTTPSConnection(host=self._config.server.url, port=self._config.server.port) + else: + self._client = http.client.HTTPConnection(host=self._config.server.url, port=self._config.server.port) + + self._client.connect() + + if mode == MODE_TEST: + self.test() + + + def get_dependencies(self, id: str) -> Optional[Iterable[Dependency]]: + return self._dependencies.get(id, None) + + + def get_resp(self, t_id: str) -> Optional[HttpResp]: + return self._responses.get(t_id, None) + + + def put_resp(self, t_id:str, resp: HttpResp) -> None: + self._responses[t_id] = resp + + + def capture(self, request:TestCaseRequest): + self.put(request) + + + def start(self, total:int) -> Optional[str]: + try: + headers = {'Content-type': 'application/json', 'key': self._config.server.licenseKey} + self._client.request("GET", "/{}/regression/start?app={}&total={}".format(self._config.server.suffix, self._config.app.name, total), None, headers) + + response = self._client.getresponse() + if response.status != 200: + self._logger.error("Error occured while fetching start information. Please try again.") + return + + body = json.loads(response.read().decode()) + if body.get('id', None): + return body['id'] + + self._logger.error("failed to start operation.") + return + except: + self._logger.error("Exception occured while starting the test case run.") + + + def end(self, id:str, status:bool) -> None: + try: + headers = {'Content-type': 'application/json', 'key': self._config.server.licenseKey} + self._client.request("GET", "/{}/regression/end?id={}&status={}".format(self._config.server.suffix, id, status), None, headers) + + response = self._client.getresponse() + if response.status != 200: + self._logger.error("failed to perform end operation.") + + return + except: + self._logger.error("Exception occured while ending the test run.") + + + def put(self, rq: TestCaseRequest): + try: + filters = self._config.app.filters + if filters: + match = re.search(filters.urlRegex, rq.uri) + if match: + return None + + headers = {'Content-type': 'application/json', 'key': self._config.server.licenseKey} + bytes_data = json.dumps(rq.__dict__).encode() + self._client.request("POST", "/{}/regression/testcase".format(self._config.server.suffix), bytes_data, headers) + + response = self._client.getresponse() + if response.status != 200: + self._logger.error("failed to send testcase to backend") + + body = json.loads(response.read().decode()) + if body.get('id', None): + self.denoise(body['id'], rq) + except: + self._logger.error("Exception occured while storing the request information. Try again.") + + + def denoise(self, id:str, tcase:TestCaseRequest): + time.sleep(2.0) + try: + unit = TestCase(id, captured=tcase.captured, uri=tcase.uri, req=tcase.httpRequest, deps=tcase.deps) + res = self.simulate(unit) + if not res: + self._logger.error("failed to simulate request") + return + + headers = {'Content-type': 'application/json', 'key': self._config.server.licenseKey} + bin_data = json.dumps(TestReq(id=id, app_id=self._config.app.name, resp=res).__dict__).encode() + self._client.request("POST", "/{}/regression/denoise".format(self._config.server.suffix), bin_data, headers) + + response = self._client.getresponse() + if response.status != 200: + self._logger.error("failed to de-noise request to backend") + except: + self._logger.error("Error occured while denoising the test case request. Skipping...") + + + def simulate(self, test_case:TestCase) -> Optional[HttpResp]: + try: + self._dependencies[test_case.id] = test_case.deps + + heads = test_case.http_req.header + heads['KEPLOY_TEST_ID'] = test_case.id + + cli = http.client.HTTPConnection(self._config.app.host, self._config.app.port) + cli._http_vsn = int(str(test_case.http_req.proto_major) + str(test_case.http_req.proto_minor)) + cli._http_vsn_str = 'HTTP/{}.{}'.format(test_case.http_req.proto_major, test_case.http_req.proto_minor) + + cli.request( + method=test_case.http_req.method, + url=self._config.app.suffix + test_case.http_req.url, + body=json.dumps(test_case.http_req.body).encode(), + headers=heads + ) + + response = self.get_resp(test_case.id) + if not response or not self._responses.pop(test_case.id, None): + self._logger.error("failed loading the response for testcase.") + return + + self._dependencies.pop(test_case.id, None) + cli.close() + + return response + + except Exception as e: + self._logger.exception("Exception occured in simulation of test case with id: %s" %test_case.id) + + + def check(self, r_id:str, tc: TestCase) -> bool: + try: + resp = self.simulate(tc) + if not resp: + self._logger.error("failed to simulate request on local server.") + return False + + headers = {'Content-type': 'application/json', 'key': self._config.server.licenseKey} + bytes_data = json.dumps(TestReq(id=tc.id, app_id=self._config.app.name, run_id=r_id, resp=resp).__dict__).encode() + self._client.request("POST", "/{}/regression/test".format(self._config.server.suffix), bytes_data, headers) + + response = self._client.getresponse() + if response.status != 200: + self._logger.error("failed to read response from backend") + + body = json.loads(response.read().decode()) + if body.get('pass', False): + return body['pass'] + + return False + + except: + self._logger.exception("[SKIP] Failed to check testcase with id: %s" %tc.id) + return False + + + def get(self, id:str) -> Optional[TestCase]: + try: + headers = {'Content-type': 'application/json', 'key': self._config.server.licenseKey} + self._client.request("GET", "/{}/regression/testcase/{}".format(self._config.server.suffix, id), None, headers) + + response = self._client.getresponse() + if response.status != 200: + self._logger.error("failed to get request.") + + body = json.loads(response.read().decode()) + unit = TestCase(**body) + + return unit + + except: + self._logger.error("Exception occured while fetching the test case with id: %s" %id) + return + + + def fetch(self, offset:int=0, limit:int=25) -> Optional[Sequence[TestCase]]: + try: + test_cases = [] + headers = {'Content-type': 'application/json', 'key': self._config.server.licenseKey} + + while True: + self._client.request("GET", "/{}/regression/testcase?app={}&offset={}&limit={}".format(self._config.server.suffix, self._config.app.name, offset, limit), None, headers) + response = self._client.getresponse() + if response.status != 200: + self._logger.error("Error occured while fetching test cases. Please try again.") + return + + body = json.loads(response.read().decode()) + if body: + for idx, case in enumerate(body): + body[idx] = TestCase(**case) + test_cases.extend(body) + offset += limit + else: + break + return test_cases + + except: + self._logger.exception("Exception occured while fetching test cases.") + return + + + def test(self): + passed = True + + time.sleep(self._config.app.delay) + + self._logger.info("Started test operations on the captured test cases.") + cases = self.fetch() + count = len(cases) + + self._logger.info("Total number of test cases to be checked = %d" %count) + run_id = self.start(count) + + if not run_id: + return + + self._logger.info("Started with testing...") + for case in cases: + ok = self.check(run_id, case) + if not ok: + passed = False + self._logger.info("Finished with testing...") + + self._logger.info("Cleaning up things...") + self.end(run_id, passed) + + return passed + diff --git a/keploy/mode.py b/keploy/mode.py new file mode 100644 index 0000000..f3c02ad --- /dev/null +++ b/keploy/mode.py @@ -0,0 +1,19 @@ +from keploy.constants import MODE_OFF, MODE_RECORD, MODE_TEST + + +mode = MODE_OFF + +def isValidMode(mode): + if mode in [MODE_OFF, MODE_RECORD, MODE_TEST]: + return True + return False + +def getMode(): + return mode + +def setMode(m): + if isValidMode(m): + global mode + mode = m + return + raise Exception("Mode:{} not supported by keploy. Please enter a valid mode.".format(m)) diff --git a/keploy/models.py b/keploy/models.py new file mode 100644 index 0000000..2f41d92 --- /dev/null +++ b/keploy/models.py @@ -0,0 +1,168 @@ + +from ast import literal_eval +from typing import Any, Iterable, List, Literal, Optional, Mapping, Sequence + +from keploy.constants import ALLOWED_DEPENDENCY_TYPES, ALLOWED_METHODS + + +def getHostPort(h:str = None, p:int = None): + host = '' + suffix = '' + port = 0 + + if not h and not p: + raise ValueError("Invalid host or port values.") + + if h.startswith(("http:", "https:")): + raise ValueError("please pass host name without http:// or https://") + + host = h + i = h.find("/") + if i > 0: + host = h[:i] + suffix = h[i+1:] + + port = p or 80 + i = h.find(":") + if i > 0: + if p and str(p) != host[i+1:]: + raise ValueError("2 Ports found. Please pass the port as a function argumet only.") + port = int(host[i+1:]) + host = host[:i] + + return (host, port, suffix.strip('/')) + + +class FilterConfig(object): + def __init__(self, regex: str) -> None: + self.urlRegex = regex + + +class AppConfig(object): + def __init__(self, name:str=None, host:str=None, port:int=None, delay:int=5, timeout:int=60, filters:FilterConfig=None): + self.name = name or 'test-keploy' + self.host = host + self.port = port + self.delay = delay + self.timeout = timeout + self.filters = filters + self.suffix = None + + if not host: + raise ValueError("Host not provided in AppConfig.") + + self.host, self.port, self.suffix = getHostPort(host, port) + + +PROTOCOL = Literal['https', 'http'] +class ServerConfig(object): + def __init__(self, protocol:PROTOCOL='https', host:str='api.keploy.io', port:int=None, licenseKey:str=''): + + self.protocol = protocol + self.url = host + self.port = port + self.licenseKey = licenseKey + self.suffix = '' + + if protocol not in ["http", "https"]: + raise ValueError("Invalid protocol type. Please use from available options.") + + if not licenseKey: + self.url, self.port, self.suffix = getHostPort(self.url, self.port) + + +class Config(object): + def __init__(self, appConfig:AppConfig, serverConfig:ServerConfig) -> None: + + if not isinstance(appConfig, AppConfig): + raise TypeError("Please provide a valid app configuration.") + + if not isinstance(serverConfig, ServerConfig): + raise TypeError("Please provide a valid server configuration.") + + self.app = appConfig + self.server = serverConfig + + +TYPES= Literal['NO_SQL_DB', 'SQL_DB', 'GRPC', 'HTTP_CLIENT'] +class Dependency(object): + def __init__(self, name:str, type:TYPES, meta:Mapping[str, str]=None, data:List[bytearray]=None) -> None: + self.name = name + self.type = type + self.meta = meta + self.data = data + + if not type in ALLOWED_DEPENDENCY_TYPES: + raise TypeError("Please provide a valid dependency type.") + + +METHODS = Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE'] +class HttpReq(object): + def __init__(self, method:METHODS=None, proto_major:int=1, proto_minor:int=1, url:str=None, url_params:Mapping[str, str]=None, header:Mapping[str, Sequence[str]]=None, body:str=None) -> None: + self.method = method + self.proto_major = proto_major + self.proto_minor = proto_minor + self.url = url + self.url_params = url_params + self.header = header + self.body = body + + if method not in ALLOWED_METHODS: + raise ValueError("{} method is not supported. Please try some other method.".format(self.method)) + + +class HttpResp(object): + def __init__(self, status_code:int=None, header:Mapping[str, Sequence[str]]=None, body:str=None) -> None: + self.code = status_code + self.header = header + self.body = body + + +class TestCase(object): + def __init__(self, id:str=None, created:int=None, updated:int=None, captured:int=None, + cid:str=None, app_id:str=None, uri:str=None, http_req:HttpReq=None, + http_resp: HttpResp=None, deps:Sequence[Dependency]=None, all_keys:Mapping[str, Sequence[str]]=None, + anchors:Mapping[str, Sequence[str]]=None, noise:Sequence[str]=None ) -> None: + self.id = id + self.created = created + self.updated = updated + self.captured = captured + self.c_id = cid + self.app_id = app_id + self.uri = uri + self.http_req = HttpReq(**http_req) + self.http_resp = HttpResp(**http_resp) + self.deps = [Dependency(**dep) for dep in deps] + self.all_keys = all_keys + self.anchors = anchors + self.noise = noise + + +class TestCaseRequest(object): + def __init__(self, captured:int=None, app_id:str=None, uri:str=None, http_req:HttpReq=None, http_resp:HttpResp=None, deps:Iterable[Dependency]=None) -> None: + self.captured = captured + self.app_id = app_id + self.uri = uri + self.httpRequest = http_req + self.httpResponse = http_resp + self.deps = deps + + if not captured: + raise ValueError("Captured time cannot be empty.") + + if not app_id: + raise ValueError("valid App ID is required to link the TestReq object.") + + +class TestReq(object): + def __init__(self, id:str=None, app_id:str=None, run_id:str=None, resp:HttpResp=None) -> None: + self.id = id + self.app_id = app_id + self.run_id = run_id + self.resp = resp + + if not id: + raise ValueError("ID is required in the TestReq object.") + + if not app_id: + raise ValueError("valid App ID is required to link the TestReq object.") \ No newline at end of file diff --git a/keploy/setup.py b/keploy/setup.py new file mode 100644 index 0000000..ec374ea --- /dev/null +++ b/keploy/setup.py @@ -0,0 +1,27 @@ +from setuptools import setup, find_packages + +VERSION = '0.0.1' +DESCRIPTION = 'Keploy' +LONG_DESCRIPTION = 'Keploy Python SDK' + +# Setting up +setup( + name="keploy", + version=VERSION, + author="Keploy Inc.", + author_email="contact@keploy.io", + description=DESCRIPTION, + long_description=LONG_DESCRIPTION, + packages=find_packages(), + install_requires=[], # add any additional packages that needs to be installed along with your package. + + keywords=['keploy', 'python', 'sdk'], + classifiers= [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Education", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 3", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + ] +) \ No newline at end of file