├── requirements.txt ├── LICENSE ├── template.env ├── playback-proxy ├── color_logger.py ├── settings.py ├── utils.py ├── recorder.py ├── player.py └── main.py ├── proxy-starter.sh ├── .gitignore └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2020.12.5 2 | click==7.1.2 3 | colorama==0.4.4 4 | fastapi==0.63.0 5 | h11==0.11.0 6 | httpcore==0.12.2 7 | httptools==0.1.1 8 | httpx==0.16.1 9 | idna==3.0 10 | pydantic==1.7.3 11 | python-dotenv==0.15.0 12 | PyYAML==5.3.1 13 | rfc3986==1.4.0 14 | six==1.15.0 15 | sniffio==1.2.0 16 | starlette==0.13.6 17 | uvicorn==0.13.3 18 | uvloop==0.14.0 19 | waiting==1.4.1 20 | watchgod==0.6 21 | websocket-client==0.57.0 22 | websockets==8.1 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yurii 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /template.env: -------------------------------------------------------------------------------- 1 | # This is a template settings file for playback-proxy 2 | ################################## 3 | # Mandatory variables 4 | 5 | # Protocol, only `http://` supported for now 6 | PROTOCOL="http://" 7 | 8 | # Endpoint of the real server 9 | ENDPOINT="localhost:8181/" 10 | 11 | 12 | 13 | # Recordings folder 14 | RECORDS_PATH="/recordings/" 15 | 16 | ################################## 17 | # Optional vairables 18 | # Mode, RECORD/PLAYBACK/PROXY. If omitted - run will be in PROXY mode 19 | # MODE="PROXY" 20 | 21 | # Name of current recording. If omitted - run will be in PROXY mode 22 | RECORDING="recording" 23 | 24 | # List of endpoints, separated by pipe |, which are not logged 25 | # IGNORE_LOG="api/v1/calibration|api/v1/outfits/exports/status|api/v1/liveview|api/v1/birdeyeview" 26 | 27 | # List of endpoints, separated by pipe |, of which only single (first) response is stored for playback 28 | # SAVE_SINGLE="api/v1/calibration|api/v1/outfits/exports/status|api/v1/liveview|api/v1/birdeyeview" 29 | 30 | # Socket protocol, only `ws://` supported for now 31 | # SOCKET_PROTOCOL="ws://" 32 | 33 | # Socket endpoint, relative to ENDPOINT variable 34 | # SOCKET_ROP="api/v1/live" -------------------------------------------------------------------------------- /playback-proxy/color_logger.py: -------------------------------------------------------------------------------- 1 | # http://uran198.github.io/en/python/2016/07/12/colorful-python-logging.html 2 | 3 | import logging 4 | import colorama 5 | import copy 6 | 7 | # specify colors for different logging levels 8 | LOG_COLORS = { 9 | logging.ERROR: colorama.Fore.RED, 10 | logging.WARNING: colorama.Fore.YELLOW, 11 | logging.INFO: colorama.Fore.BLUE 12 | } 13 | 14 | 15 | class ColorFormatter(logging.Formatter): 16 | def format(self, record, *args, **kwargs): 17 | # if the corresponding logger has children, they may receive modified 18 | # record, so we want to keep it intact 19 | new_record = copy.copy(record) 20 | if new_record.levelno in LOG_COLORS: 21 | # we want levelname to be in different color, so let's modify it 22 | new_record.levelname = "{color_begin}{level}{color_end}".format( 23 | level=new_record.levelname, 24 | color_begin=LOG_COLORS[new_record.levelno], 25 | color_end=colorama.Style.RESET_ALL, 26 | ) 27 | # now we can let standart formatting take care of the rest 28 | return super(ColorFormatter, self).format(new_record, *args, **kwargs) 29 | 30 | # we want to display only levelname and message 31 | formatter = ColorFormatter("%(levelname)s %(message)s") 32 | 33 | # this handler will write to sys.stderr by default 34 | handler = logging.StreamHandler() 35 | handler.setFormatter(formatter) 36 | 37 | # adding handler to our logger 38 | logger = logging.getLogger(__name__) 39 | logger.addHandler(handler) 40 | logger.setLevel("INFO") -------------------------------------------------------------------------------- /playback-proxy/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from dotenv import load_dotenv 4 | from color_logger import logger 5 | 6 | protocol = None 7 | endpoint = None 8 | mode = None 9 | record_name = None 10 | records_path = None 11 | socket_protocol = None 12 | socket_rop = None 13 | ignore_log = None 14 | save_single = None 15 | 16 | def load_envs(): 17 | logger.info("Loading environment") 18 | load_dotenv() 19 | envs_correct = True 20 | 21 | global protocol, endpoint, mode, record_name, records_path, socket_protocol, socket_rop, ignore_log, save_single 22 | 23 | if (protocol := os.getenv("PROTOCOL")) is None: 24 | logger.error("PROTOCOL variable not found. Check your env file") 25 | envs_correct = False 26 | if (endpoint := os.getenv("ENDPOINT")) is None: 27 | logger.error("ENDPOINT variable not found. Check your env file") 28 | envs_correct = False 29 | if (records_path := os.getenv("RECORDS_PATH")) is None: 30 | logger.error("RECORDS_PATH variable not found. Check your env file") 31 | envs_correct = False 32 | 33 | try: 34 | mode = os.getenv("MODE") 35 | except: 36 | mode = "PROXY" 37 | 38 | try: 39 | record_name = os.getenv("RECORDING") 40 | except: 41 | mode = "PROXY" 42 | 43 | try: 44 | socket_protocol = os.getenv("SOCKET_PROTOCOL") 45 | socket_rop = os.getenv("SOCKET_ROP") 46 | except: 47 | None 48 | 49 | try: 50 | ignore_log = os.getenv("IGNORE_LOG").split('|') 51 | except: 52 | ignore_log = None 53 | 54 | try: 55 | save_single = os.getenv("SAVE_SINGLE").split('|') 56 | except: 57 | save_single = None 58 | 59 | if envs_correct is False: 60 | sys.exit(2) -------------------------------------------------------------------------------- /proxy-starter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ################################################################################ 4 | # Help # 5 | ################################################################################ 6 | Help() 7 | { 8 | # Display Help 9 | echo "Starts the playback-proxy server via uvicorn" 10 | echo 11 | echo "Syntax: ./proxy-starter.sh [-u|m|r|e|a|p|h]" 12 | echo "options:" 13 | echo "u Specify path to uvicorn" 14 | echo "m Specify mode PROXY|RECORD|PLAYBACK" 15 | echo "r Specify recording name" 16 | echo "e Specify env file path" 17 | echo "a Specify host" 18 | echo "p Specify port" 19 | echo "h Print this Help." 20 | echo 21 | } 22 | 23 | ################################################################################ 24 | ################################################################################ 25 | # Main program # 26 | ################################################################################ 27 | ################################################################################ 28 | 29 | while getopts "u:m:r:e:a:p:h" opt; do 30 | case ${opt} in 31 | u) UVICORN_PATH=${OPTARG} ;; 32 | m) MODE=${OPTARG} ;; 33 | r) RECORDING=${OPTARG} ;; 34 | e) ENV_PATH=${OPTARG} ;; 35 | a) HOST=${OPTARG} ;; 36 | p) PORT=${OPTARG} ;; 37 | h) Help 38 | exit ;; 39 | \?) echo "Error: Invalid option" 40 | exit;; 41 | esac 42 | done 43 | 44 | if [ -z ${UVICORN_PATH+x} ]; 45 | then 46 | echo "UVICORN_PATH -u is not set" 47 | exit 1 48 | fi 49 | 50 | if [ -z ${ENV_PATH+x} ]; 51 | then 52 | echo "ENV_PATH -e is not set" 53 | exit 1 54 | fi 55 | 56 | if [ -z ${HOST+x} ]; 57 | then 58 | echo "HOST -a is not set" 59 | exit 1 60 | fi 61 | 62 | if [ -z ${PORT+x} ]; 63 | then 64 | echo "PORT -p is not set" 65 | exit 1 66 | fi 67 | 68 | if [ -n "$MODE" ] && [ -n "${RECORDING}" ] 69 | then 70 | echo "Setting ${MODE} mode, ${RECORDING} record name to env file at ${ENV_PATH}" 71 | sed -i '' -E "s|MODE=\"(..*)\"|MODE=\"${MODE}\"|g" "${ENV_PATH}" 72 | sed -i '' -E "s|RECORDING=\"(..*)\"|RECORDING=\"${RECORDING}\"|g" "${ENV_PATH}" 73 | fi 74 | 75 | $UVICORN_PATH --app-dir ./playback-proxy/ main:app --host "${HOST}" --port "${PORT}" --log-level info --no-access-log --env-file "${ENV_PATH}" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Recordings 132 | /recordings 133 | -------------------------------------------------------------------------------- /playback-proxy/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import time 4 | 5 | from httpx import Response 6 | from color_logger import logger 7 | import settings 8 | 9 | record_path = None 10 | sockets_path = None 11 | singles_path = None 12 | 13 | def set_paths(): 14 | cwd = os.getcwd() 15 | global record_path 16 | record_path = f"{cwd}/{settings.records_path}/{settings.record_name}/" 17 | if settings.socket_rop is not None: 18 | global sockets_path 19 | sockets_path = record_path + "sockets" 20 | if settings.save_single is not None: 21 | global singles_path 22 | singles_path = record_path + "singles" 23 | 24 | logger.info(f"Record path at {record_path}") 25 | 26 | not_found_response = Response(status_code=404) 27 | slash_escape = '\\' 28 | 29 | def escape_uri(uri: str): 30 | return uri.replace('/', slash_escape) 31 | 32 | def unescape_uri(uri: str): 33 | return uri.replace(slash_escape, '/') 34 | 35 | def single_path(uri: str): 36 | return f"{singles_path}/{escape_uri(uri)}.bin" 37 | 38 | def multiple_path(uri: str, counter: int): 39 | return f"{record_path}{multiple_filename(uri, counter)}.bin" 40 | 41 | def multiple_filename(uri: str, counter: int): 42 | return f"{counter}_{escape_uri(uri)}" 43 | 44 | def socket_path(socket_counter: int): 45 | return f"{sockets_path}/{socket_counter}.bin" 46 | 47 | class PResponse: 48 | def __init__(self, r: Response): 49 | self.status_code = r.status_code 50 | self.headers = r.headers 51 | self.content = r.content 52 | 53 | def toResponse(self): 54 | return Response( 55 | status_code=self.status_code, 56 | headers=self.headers, 57 | content=self.content 58 | ) 59 | 60 | class PSocket: 61 | def __init__(self, m: str, last_request: str, time_after: float): 62 | self.message = m 63 | self.last_request = last_request 64 | self.time_after = time_after 65 | 66 | def description(self): 67 | return f"{self.time_after} after {unescape_uri(self.last_request)}: {self.message}" 68 | 69 | class Timer: 70 | def __init__(self): 71 | self._start_time = None 72 | 73 | def start(self): 74 | if self._start_time is None: 75 | self._start_time = time.perf_counter() 76 | 77 | def stop(self): 78 | if self._start_time is not None: 79 | elapsed_time = time.perf_counter() - self._start_time 80 | self._start_time = None 81 | return elapsed_time 82 | 83 | def restart(self): 84 | elapsed_time = self.stop() 85 | self.start() 86 | return elapsed_time 87 | 88 | def nostop_check(self): 89 | if self._start_time is None: 90 | return 0.0 91 | else: 92 | return time.perf_counter() - self._start_time 93 | 94 | -------------------------------------------------------------------------------- /playback-proxy/recorder.py: -------------------------------------------------------------------------------- 1 | from color_logger import logger 2 | from httpx import Response 3 | import settings 4 | 5 | import os 6 | import shutil 7 | try: 8 | import cPickle as pickle 9 | except ModuleNotFoundError: 10 | import pickle 11 | 12 | from utils import PResponse, PSocket, single_path, multiple_path, socket_path, multiple_filename 13 | from utils import Timer 14 | import utils 15 | 16 | class Recorder: 17 | def __init__(self): 18 | self.socket_counter = 0 19 | self.singles_saved = list() 20 | self.multiples_saved = {} 21 | self.last_request = "First" 22 | self.timer = None 23 | 24 | def prepare(self): 25 | try: 26 | shutil.rmtree(utils.record_path) 27 | except OSError as e: 28 | logger.info(f"Nothing to delete at {utils.record_path}") 29 | else: 30 | logger.info(f"Removed existing record folder at {utils.record_path}") 31 | 32 | try: 33 | os.mkdir(utils.record_path) 34 | if settings.save_single is not None: 35 | os.mkdir(utils.singles_path) 36 | if settings.socket_rop is not None: 37 | os.mkdir(utils.sockets_path) 38 | except OSError as e: 39 | logger.error(f"Error creating record folder at {utils.record_path}: {str(e)}") 40 | else: 41 | logger.info(f"Created new record folder at {utils.record_path}") 42 | 43 | def start(self): 44 | if self.timer is None: 45 | self.timer = Timer() 46 | self.timer.start() 47 | 48 | def save(self, uri: str, response: Response): 49 | pResponse = PResponse(response) 50 | if uri in settings.save_single: 51 | self.save_single(uri, pResponse) 52 | else: 53 | self.start() 54 | self.save_multiple(uri, pResponse) 55 | 56 | def save_multiple(self, uri: str, response: PResponse): 57 | counter = self.multiples_saved.get(uri, 0) 58 | to = multiple_path(uri, counter) 59 | logger.info(f"Saving response to {to}") 60 | with open(to, "wb+") as f: 61 | pickle.dump(response, f, -1) 62 | counter += 1 63 | self.multiples_saved[uri] = counter 64 | self.last_request = multiple_filename(uri, counter - 1) 65 | self.timer.restart() 66 | 67 | def save_single(self, uri: str, response): 68 | if uri in self.singles_saved: 69 | return 70 | to = single_path(uri) 71 | logger.info(f"Saving single response to {to}") 72 | with open(to, "wb+") as f: 73 | pickle.dump(response, f, -1) 74 | self.singles_saved.append(uri) 75 | 76 | def save_socket(self, message: object): 77 | time_after = self.timer.nostop_check() 78 | pSocket = PSocket(message, self.last_request, time_after) 79 | to = socket_path(self.socket_counter) 80 | logger.info(f"Saving socket message to {to}") 81 | with open(to, "wb+") as f: 82 | pickle.dump(pSocket, f, -1) 83 | self.socket_counter += 1 84 | -------------------------------------------------------------------------------- /playback-proxy/player.py: -------------------------------------------------------------------------------- 1 | from color_logger import logger 2 | from httpx import Response 3 | import os 4 | import sys 5 | import shutil 6 | try: 7 | import cPickle as pickle 8 | except ModuleNotFoundError: 9 | import pickle 10 | 11 | import utils 12 | import settings 13 | from utils import PResponse, PSocket, unescape_uri, not_found_response 14 | from threading import Timer 15 | 16 | class Player: 17 | def __init__(self, send_socket): 18 | self.socket_counter = 0 19 | self.singles_saved = {} 20 | self.sockets_saved = [] 21 | self.multiples_saved = {} 22 | self.last_request = "First" 23 | self.send_socket = send_socket 24 | self.dispatchers = list() 25 | 26 | def prepare(self): 27 | if os.path.exists(utils.record_path) is False: 28 | logger.error(f"Recording folder not found at {utils.record_path}") 29 | sys.exit(2) 30 | 31 | if utils.singles_path is not None: 32 | self.load_singles() 33 | if utils.sockets_path is not None: 34 | self.load_sockets() 35 | 36 | def load_singles(self): 37 | for single in os.listdir(utils.singles_path): 38 | path = f"{utils.singles_path}/{single}" 39 | logger.info(f"Attempting to load single response from {path}") 40 | with open(path, 'rb') as f: 41 | self.singles_saved[os.path.splitext(unescape_uri(single))[0]] = pickle.load(f) 42 | 43 | def load_sockets(self): 44 | for socket in os.listdir(utils.sockets_path): 45 | path = f"{utils.sockets_path}/{socket}" 46 | logger.info(f"Attempting to load socket message from {path}") 47 | with open(path, 'rb') as f: 48 | self.sockets_saved.append(pickle.load(f)) 49 | logger.info(f"Loaded {len(self.sockets_saved)} socket events: ") 50 | 51 | def start(self): 52 | logger.info("---Player started---") 53 | self.check_socket() 54 | 55 | def load_next(self, uri: str): 56 | if uri in settings.save_single and uri in self.singles_saved: 57 | return self.singles_saved[uri].toResponse() 58 | 59 | counter = self.multiples_saved.get(uri, 0) 60 | path = utils.multiple_path(uri, counter) 61 | if not os.path.exists(path): 62 | logger.warning(f"Response not found at {path}") 63 | if counter is not 0: 64 | logger.warning(f"Attempting previous response") 65 | counter -= 1 66 | path = utils.multiple_path(uri, counter) 67 | else: 68 | logger.error(f"Unknown call") 69 | return not_found_response 70 | logger.info(f"Attempting to load response from {path}") 71 | with open(path, 'rb') as f: 72 | pResponse: PResponse = pickle.load(f) 73 | counter += 1 74 | self.multiples_saved[uri] = counter 75 | self.last_request = utils.multiple_filename(uri, counter - 1) 76 | self.check_socket() 77 | return pResponse.toResponse() 78 | 79 | def check_socket(self): 80 | to_send = list() 81 | for socket in self.sockets_saved: 82 | if socket.last_request == self.last_request: 83 | to_send.append(socket) 84 | 85 | for socket in to_send: 86 | self.dispatch_socket(socket) 87 | self.sockets_saved.remove(socket) 88 | 89 | def dispatch_socket(self, socket: PSocket): 90 | logger.info(f"Sending {socket.message} via socket in {socket.time_after} seconds") 91 | t = Timer(socket.time_after, self.send_socket, args=(socket.message,)) 92 | t.start() 93 | self.dispatchers.append(t) 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to playback-proxy 👋 2 | ![Version](https://img.shields.io/badge/version-0.1.0-blue.svg?cacheSeconds=2592000) 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | 5 | > A proxy tool that records communication (requests, websockets) between client and server. This recording can later be used for tests as a mock backend. It works in 2 modes, **RECORD** and **PLAYBACK**, capturing and reproducing the server responses respectively. 6 | 7 | ## Supported Features 8 | - Record multiple calls to the same endpoint. These will be reproduced in the same order during **PLAYBACK** as they were requested. 9 | - Record web socket events. These will be reproduced based on the last request and time before socket was received. This means that if the socket was received 2 seconds after a particular call in **RECORD** mode, during **PLAYBACK** it will be sent 2 seconds after that particular call is requested. 10 | - Specify a list of endpoints that are recorded only once. Same response will be used during **PLAYBACK** for every call. See `SAVE_SINGLE` parameter in the `.env` file 11 | - Specify a list of endpoints that are not printed to the log. See `IGNORE_LOG` parameter in the `.env` file 12 | 13 | ## Unsupported (yet) Features 14 | - Saving responses in any other format (json, plaintext) than binary 15 | - Using `https` and `wss` protocols 16 | - Support for HAR files as records 17 | - Support for optional delay 18 | - Any other ideas people might have 19 | 20 | ## Install 21 | ```sh 22 | git clone https://github.com/kaphacius/playback-proxy.git 23 | ``` 24 | 25 | ## Setup 26 | Install all dependencies with 27 | ```sh 28 | pip3 install -r requirements.txt 29 | ``` 30 | 31 | ## 🚀 Usage 32 | There are 2 modes of running the tool: RECORD and PLAYBACK. 33 | - During RECORD, all communication between client and server is stored. 34 | - Duruing PLAYBACK, the socket uses previously stored responses when being requested. 35 | 36 | Firstly, set up an `.env` [file](https://github.com/kaphacius/playback-proxy/blob/main/template.env) with mandatory and optional parameters. Copy the and rename [*template.env*](https://github.com/kaphacius/playback-proxy/blob/main/template.env) to a desired name. 37 | Mandatory parameters: 38 | - `PROTOCOL` - protocol used for communication (only http for now) 39 | - `ENDPOINT` - the address of the server to which the proxy will connect to 40 | - `MODE` - current mode. Can be changed later during when launching the proxy. 41 | - `RECORDS_PATH` - relative path to where all of the recordings will be stored. Must exists before running. 42 | - `RECORDING` - name of the current recording. This will be appended to RECORDS_PATH and a folder will be created to store saved data. Can be changed later when launching the proxy. 43 | 44 | Make the starter script executable: 45 | ```sh 46 | chmod +x proxy-starter.sh 47 | ``` 48 | Then, run the proxy in ***RECORD*** mode. Specify the name of the current recording `RECORDING`. This will create a folder (or use an existing one) where the responses will be saved. Set relative path to your specific `.env` file via `PATH_TO_ENV_FILE`. Specify address and port where the client will be connecting via `PROXY_SERVER_ADDRESS` and `PROXY_PORT`. 49 | ```sh 50 | ./proxy-starter.sh -m RECORD -r {RECORDING} -e {PATH_TO_ENV_FILE} -a {PROXY_SERVER_ADDRESS} -p {PROXY_PORT} 51 | ``` 52 | Perform necessary interactions with the backend and stop the proxy by pressing `Ctrl+C`. 53 | 54 | Finally, run the proxy in ***PLAYBACK*** mode. Interact with the server the same way as during recording - receive the same responses. 55 | ```sh 56 | ./proxy-starter.sh -m PLAYBACK -r {RECORDING} -e {PATH_TO_ENV_FILE} -a {PROXY_SERVER_ADDRESS} -p {PROXY_PORT} 57 | ``` 58 | 59 | ## Author 60 | 61 | 👤 **Yurii Zadoianchuk** 62 | 63 | * Github: [@kaphacius](https://github.com/kaphacius) 64 | 65 | ## 🤝 Contributing 66 | 67 | Contributions, issues and feature requests are welcome! 68 | 69 | Feel free to check [issues page](https://github.com/kaphacius/playback-proxy/issues). 70 | 71 | ## Show your support 72 | 73 | Give a ⭐️ if this project helped you! 74 | 75 | ## 📝 License 76 | 77 | Copyright © 2021 [Yurii Zadoianchuk](https://github.com/kaphacius). 78 | 79 | This project is [MIT](https://opensource.org/licenses/MIT) licensed. 80 | 81 | *** 82 | _This README was generated with ❤️ by [readme-md-generator](https://github.com/kefranabg/readme-md-generator)_ 83 | -------------------------------------------------------------------------------- /playback-proxy/main.py: -------------------------------------------------------------------------------- 1 | import websocket as _websocket 2 | from fastapi import FastAPI 3 | from fastapi import WebSocket as fWebSocket 4 | from starlette.endpoints import WebSocketEndpoint 5 | from starlette.requests import Request 6 | from starlette.responses import Response, StreamingResponse 7 | from httpx import AsyncClient 8 | from io import BytesIO 9 | import sys 10 | import time 11 | import threading 12 | import os 13 | from waiting import wait 14 | import logging 15 | from color_logger import logger 16 | import asyncio 17 | import settings 18 | from recorder import Recorder 19 | from player import Player 20 | import utils 21 | 22 | client = AsyncClient() 23 | app = FastAPI() 24 | 25 | recorder: Recorder = None 26 | player: Player = None 27 | is_playback = False 28 | is_record = False 29 | is_proxy = False 30 | 31 | def accept_socket(message: str): 32 | loop = asyncio.new_event_loop() 33 | asyncio.set_event_loop(loop) 34 | loop.run_until_complete(inSocket.send_bytes(message.encode('utf-8'))) 35 | 36 | def print_welcome(mode: str): 37 | record: str = None 38 | if settings.record_name != None: 39 | record = f" {settings.record_name} " 40 | else: 41 | record = " " 42 | logger.info("*************************************************") 43 | logger.info(f"\n\nSTARTING{record}IN {mode} MODE\n\n") 44 | logger.info("*************************************************") 45 | 46 | def quit_proxy(): 47 | os.system("kill -9 `ps -jaxwww | grep \"[.]/playback-proxy\" | awk '{print $2}'`") 48 | 49 | def start(record_name: str = None, mode: str = None): 50 | if record_name != None and mode != None: 51 | settings.mode = mode 52 | settings.record_name = record_name 53 | 54 | set_mode() 55 | 56 | if is_record: 57 | utils.set_paths() 58 | print_welcome("RECORDING") 59 | global recorder 60 | recorder = Recorder() 61 | recorder.prepare() 62 | elif is_playback: 63 | utils.set_paths() 64 | print_welcome("PLAYBACK") 65 | global player 66 | player = Player(accept_socket) 67 | player.prepare() 68 | elif is_proxy: 69 | print_welcome("PROXY") 70 | 71 | def set_mode(): 72 | global is_playback 73 | global is_record 74 | global is_proxy 75 | is_playback = settings.mode == "PLAYBACK" 76 | is_record = settings.mode == "RECORD" 77 | is_proxy = is_record or settings.mode == "PROXY" 78 | 79 | settings.load_envs() 80 | start() 81 | 82 | def proxied_url(rop: str): 83 | return f"{settings.protocol}{settings.endpoint}{rop}" 84 | 85 | async def proxy_request(request: Request, rop: str): 86 | if is_proxy and rop not in settings.ignore_log: 87 | logger.info(f"Asking {request.method} {request.url}") 88 | 89 | result: httpx.Response 90 | 91 | if is_playback: 92 | result = player.load_next(rop) 93 | else: 94 | body = await request.body() 95 | result = await client.request( 96 | method=request.method, 97 | url=proxied_url(rop), 98 | params=request.query_params, 99 | content=body 100 | ) 101 | 102 | if is_record: 103 | recorder.save(rop, result) 104 | 105 | if is_proxy and rop not in settings.ignore_log: 106 | logger.info(f"Received {result.status_code} from {result.url}") 107 | 108 | headers = result.headers 109 | content_type = None 110 | response: Response = None 111 | 112 | try: 113 | content_type = result.headers['content-type'] 114 | except KeyError: 115 | response = Response( 116 | result.text, 117 | headers=headers, 118 | status_code=result.status_code 119 | ) 120 | 121 | if response == None and result.headers['content-type'] == 'application/json': 122 | response = Response( 123 | result.text, 124 | media_type="application/json", 125 | headers=headers, 126 | status_code=result.status_code 127 | ) 128 | elif response == None and result.headers['content-type'].startswith("image"): 129 | response = StreamingResponse( 130 | BytesIO(result.content), 131 | media_type=result.headers['content-type'], 132 | headers=headers, 133 | status_code=result.status_code 134 | ) 135 | 136 | return response 137 | 138 | @app.get("/{rest_of_path:path}") 139 | async def on_get(request: Request, rest_of_path: str): 140 | return await proxy_request(request, rest_of_path) 141 | 142 | @app.post("/{rest_of_path:path}") 143 | async def on_post(request: Request, rest_of_path: str): 144 | split = rest_of_path.split("/") 145 | if split[0] == "__playback-proxy": 146 | if split[1] == "quit": 147 | logger.info("Quitting proxy") 148 | quit_proxy() 149 | return Response("Shutting down proxy", media_type='text/plain') 150 | elif split[1] == "record": 151 | start(split[-1], "RECORD") 152 | return Response(f"Re-starting proxy for {split[-1]} in RECORD mode", media_type='text/plain') 153 | elif split[1] == "play": 154 | start(split[-1], "PLAYBACK") 155 | return Response(f"Re-starting proxy for {split[-1]} in PLAYBACK mode", media_type='text/plain') 156 | 157 | return await proxy_request(request, rest_of_path) 158 | 159 | @app.put("/{rest_of_path:path}") 160 | async def on_put(request: Request, rest_of_path: str): 161 | return await proxy_request(request, rest_of_path) 162 | 163 | @app.delete("/{rest_of_path:path}") 164 | async def on_delete(request: Request, rest_of_path: str): 165 | return await proxy_request(request, rest_of_path) 166 | 167 | out_connected = False 168 | inSocket: fWebSocket 169 | outSocket: _websocket.WebSocketApp 170 | out_socket_endpoint = f"{settings.socket_protocol}{settings.endpoint}{settings.socket_rop}" 171 | 172 | def outConnected(): 173 | return out_connected 174 | 175 | if settings.socket_rop is not None: 176 | logger.info(f"Setting up socket on {settings.socket_rop}") 177 | @app.websocket_route(f"/{settings.socket_rop}") 178 | class MessagesEndpoint(WebSocketEndpoint): 179 | async def on_connect(self, in_ws): 180 | global inSocket, outSocket 181 | inSocket = in_ws 182 | await in_ws.accept() 183 | 184 | logger.info(f"IN socket connected on {settings.socket_rop}") 185 | 186 | if is_record: 187 | global recorder 188 | recorder.start() 189 | elif is_playback: 190 | player.start() 191 | 192 | if is_proxy: 193 | outSocket = _websocket.WebSocketApp(out_socket_endpoint, 194 | on_message = out_on_message, 195 | on_error = out_on_error, 196 | on_close = out_on_close) 197 | outSocket.on_open = out_on_open 198 | t = threading.Thread(target=outSocketThread, args=(outSocket,)) 199 | t.daemon = True 200 | t.start() 201 | wait(outConnected) 202 | 203 | async def on_receive(self, in_ws, data) -> None: 204 | logger.info("Received from IN socket " + data.decode("utf-8")) 205 | if is_proxy: 206 | outSocket.send(data) 207 | 208 | async def on_disconnect(self, in_ws, close_code): 209 | logger.info(f"IN socket disconnected on {settings.socket_rop}") 210 | 211 | 212 | def outSocketThread(ws: _websocket): 213 | ws.on_open = out_on_open 214 | ws.run_forever() 215 | 216 | def out_on_message(ws, message): 217 | logger.info(f"Received from OUT socket {message}") 218 | 219 | if is_record: 220 | global recorder 221 | recorder.save_socket(message) 222 | 223 | loop = asyncio.new_event_loop() 224 | asyncio.set_event_loop(loop) 225 | loop.run_until_complete(inSocket.send_bytes(message)) 226 | 227 | def out_on_error(ws, error): 228 | logger.error(f"Got error on OUT socket {error}") 229 | 230 | def out_on_close(ws): 231 | logger.warning(f"OUT socket was closed") 232 | 233 | def out_on_open(ws): 234 | logger.info(f"OUT socket connected to {out_socket_endpoint}") 235 | global out_connected 236 | out_connected = True 237 | 238 | --------------------------------------------------------------------------------