From 3152917ce3544b67e6aa9423441efba73f567814 Mon Sep 17 00:00:00 2001 From: kvs8 Date: Mon, 18 Sep 2023 00:22:54 +0700 Subject: [PATCH 1/6] Request format --- .github/workflows/test.yml | 2 +- .gitignore | 2 + Makefile | 8 +- README.md | 49 +++++-- requirements-dev.txt | 9 +- requirements.txt | 9 +- tests/e2e/contexts/execution_directory.py | 9 +- .../scenarios/launch_vedro_replay_tests.py | 79 ++++++++-- tests/e2e/test_data/get_requests.http | 7 + tests/e2e/test_data/get_requests.txt | 2 + tests/e2e/test_data/post_requests.http | 21 +++ tests/e2e/vedro.cfg.py | 2 +- tests/unit/parse_requests_test.py | 135 ++++++++++++++++++ tests/unit/test_data/get_requests.http | 39 +++++ tests/unit/test_data/get_requests.txt | 2 + tests/unit/test_data/post_requests.http | 29 ++++ vedro_replay/__init__.py | 14 +- vedro_replay/generator.py | 12 +- vedro_replay/parser.py | 117 +++++++++++++++ vedro_replay/replay.py | 7 +- vedro_replay/request.py | 39 ++++- vedro_replay/templates/contexts.py.j2 | 12 +- vedro_replay/templates/interfaces.py.j2 | 18 +-- vedro_replay/templates/scenario.py.j2 | 7 +- 24 files changed, 557 insertions(+), 73 deletions(-) create mode 100644 tests/e2e/test_data/get_requests.http create mode 100644 tests/e2e/test_data/get_requests.txt create mode 100644 tests/e2e/test_data/post_requests.http create mode 100644 tests/unit/parse_requests_test.py create mode 100644 tests/unit/test_data/get_requests.http create mode 100644 tests/unit/test_data/get_requests.txt create mode 100644 tests/unit/test_data/post_requests.http create mode 100644 vedro_replay/parser.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30ad9be..c9f914a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,4 +24,4 @@ jobs: - name: Install dependencies run: make install - name: Test - run: make install-vedro-replay test + run: make install-vedro-replay lint unit e2e diff --git a/.gitignore b/.gitignore index 2de1dcc..e4d7a51 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ dist/ .pypirc *.sh + +tests/e2e/.vedro diff --git a/Makefile b/Makefile index aa78ec1..e5c63e5 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,14 @@ install: install-vedro-replay: python3 setup.py install -.PHONY: test -test: +.PHONY: e2e +e2e: cd tests/e2e && vedro run -vvv +.PHONY: unit +unit: + cd tests/unit && pytest + .PHONY: check-types check-types: python3 -m mypy vedro_replay --strict diff --git a/README.md b/README.md index 26d3235..2f9a980 100644 --- a/README.md +++ b/README.md @@ -57,24 +57,22 @@ options: --force Forced regeneration. The files will be overwritten ``` -To be able to generate a test, you need to have a directory with files containing GET requests +To be able to generate a test, you need to have a directory with files containing requests (`requests` directory is expected by default, you can specify a specific directory using the `--path_requests` argument). -_(So far only use of GET requests is possible)_ Example: ```shell tests # Root directory |----requests -|----|----byid.txt # File with API requests of the /byid method -|----|----search.txt # File with API requests of the /search method +|----|----byid.http # File with API requests of the /byid method +|----|----search.http # File with API requests of the /search method ``` -Example of file contents: +Example of file contents (for more information about the request format, see the following paragraph): ```shell -$ cat requests/byid.txt -/byid?id=123 -/byid?id=234 -... +$ cat requests/byid.http +### byid request with id=123 +GET http://{{host}}/byid?id=123 ``` Having requests, you can generate tests on them: ```shell @@ -84,23 +82,46 @@ Example of generation: ``` tests # Root directory |----requests -|----|----byid.txt # File with API requests of the /byid method -|----|----search.txt # File with API requests of the /search method +|----|----byid.http # File with API requests of the /byid method +|----|----search.http # File with API requests of the /search method |----contexts |----helpers |----interfaces |----scenarios # Testing scenarios -|----|----byid.py # Scenario, using requests from a file requests/byid.txt +|----|----byid.py # Scenario, using requests from a file requests/byid.http |----|----search.py |----config.py |----vedro.cfg.py ``` +### Request format +The request format is based on +[format .http from jetbrains](https://www.jetbrains.com/help/idea/exploring-http-syntax.html) +The structure of the request has the following form: +```shell +### Comment +Method Request-URI +Header-field: Header-value + +JSON-Body +``` + +Rules: +- Each request starts with a string with the characters "###" at the beginning. +Also on the same line it is possible to write a comment (optional) to the query that will be output in the test being run. +- http method must consist of capital letters +- Request-URI should always have the format http(s)://{{host}}[path][query]. +The host looks like this, for the ability to send requests for tests using an http client inside the IDE Idea/Pycharm/... +- Headers are optional +- Json-body is optional + +Examples can be found [here](tests/unit/test_data/get_requests.http) and [here](tests/unit/test_data/post_requests.http) + ### Running tests To run the tests, need two hosts to send requests to them. You need to set environment variables in any convenient way: ```shell -GOLDEN_API_URL=http://master.app -TESTING_API_URL=http://branch.app +GOLDEN_API_URL=master.app +TESTING_API_URL=branch.app ``` After that, you can run the tests: diff --git a/requirements-dev.txt b/requirements-dev.txt index 17a8ea3..e5a13c2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,8 @@ -jj==2.7.2 -vedro-jj==0.1.1 +jj==2.8.1 +vedro-jj==0.2.0 jj_district42==1.0.0 -flake8==6.0.0 +flake8==6.1.0 isort==5.12.0 -mypy==1.3.0 +mypy==1.5.1 mypy-extensions==1.0.0 +pytest==7.4.2 diff --git a/requirements.txt b/requirements.txt index 357fd39..bda79a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ jinja2==3.1.2 -httpx==0.24.0 cabina==0.7.1 -d42==1.5.1 -vedro==1.8.3 -vedro-valera-validator==1.1.0 +d42==1.7.0 +vedro==1.10.0 +vedro-valera-validator==1.1.1 requests-toolbelt==1.0.0 +pyparsing==3.1.1 +vedro-httpx==0.3.0 diff --git a/tests/e2e/contexts/execution_directory.py b/tests/e2e/contexts/execution_directory.py index cf2811b..2bdd3d3 100644 --- a/tests/e2e/contexts/execution_directory.py +++ b/tests/e2e/contexts/execution_directory.py @@ -5,15 +5,14 @@ @vedro.context -def execution_directory(dir_launch: str, dir_requests: str, file_requests: str, requests: str) -> None: +def execution_directory(dir_launch: str, dir_requests: str, file_requests: str) -> None: if os.path.exists(dir_launch): subprocess.run(['rm', '-r', dir_launch], check=True) os.mkdir(dir_launch) os.mkdir(dir_launch + '/' + dir_requests) - with open(f'{dir_launch}/{dir_requests}/{file_requests}', 'w') as file: - file.write(requests) + subprocess.run(['cp', 'test_data/' + file_requests, f"{dir_launch}/{dir_requests}/{file_requests}"], check=True) - os.environ.setdefault('GOLDEN_API_URL', 'http://localhost:8080') - os.environ.setdefault('TESTING_API_URL', 'http://localhost:8080') + os.environ.setdefault('GOLDEN_API_URL', 'localhost:8080') + os.environ.setdefault('TESTING_API_URL', 'localhost:8080') diff --git a/tests/e2e/scenarios/launch_vedro_replay_tests.py b/tests/e2e/scenarios/launch_vedro_replay_tests.py index cf7f9de..38e50e6 100644 --- a/tests/e2e/scenarios/launch_vedro_replay_tests.py +++ b/tests/e2e/scenarios/launch_vedro_replay_tests.py @@ -1,25 +1,73 @@ +from typing import List + import vedro -from contexts.execution_directory import execution_directory -from contexts.mocked_api import mocked_api +from contexts import execution_directory, mocked_api from interfaces import VedroReplayCLI, VedroTestCLI from jj_district42 import HistorySchema from vedro import params +from vedro_replay.request import Request + class Scenario(vedro.Scenario): subject = 'launch vedro-replay tests: {subject}' - @params('default generate', + @params('default generate by .txt file', 'requests', - 'get.txt', - '/get\n/get', + 'get_requests.txt', + [ + Request( + method="GET", + request_uri="http://{{host}}/1.0/secure-resource?q=123", + ), + Request( + method="GET", + request_uri="http://{{host}}/1.0/secure-resource?q=example", + ), + ] ) - @params('generate with --path-requests', + @params('default generate by GET requests from .http file', + 'requests', + 'get_requests.http', + [ + Request( + comment='Запрос с установкой заголовка "Authorization" для доступа к защищенному ресурсу', + method="GET", + request_uri="http://{{host}}/1.0/secure-resource", + ), + Request( + method="GET", + request_uri="http://{{host}}/1.0/secure-resource", + ), + ] + ) + @params('generate with --path-requests by POST requests from .http file', 'special_requests', - 'list.txt', - '/list\n/list', + 'post_requests.http', + [ + Request( + comment='Назначение прав пользователю', + method="POST", + request_uri="http://{{host}}/1.0/admin-users", + json_body={ + "id": 17399, "user": "a82ec47d-9b72-41d7-9b4d-f36427561dd6", "data": [ + {"item": {"value": "Значение"}} + ] + } + ), + Request( + comment='Назначение прав пользователю', + method="POST", + request_uri="http://{{host}}/1.0/admin-users", + json_body={ + "id": 17399, "user": "a82ec47d-9b72-41d7-9b4d-f36427561dd6", "data": [ + {"item": {"value": "Значение"}} + ] + } + ), + ] ) - def __init__(self, subject: str, dir_request: str, file_requests: str, requests: str): + def __init__(self, subject: str, dir_request: str, file_requests: str, requests: List[Request]): self.subject = subject self.dir_launch = 'launch' self.dir_requests = dir_request @@ -31,7 +79,6 @@ def given_prepared_execution_directory(self): dir_launch=self.dir_launch, dir_requests=self.dir_requests, file_requests=self.file_requests, - requests=self.requests ) async def given_vedro_replay_tests(self): @@ -46,11 +93,17 @@ async def when_replay_tests_running(self): dir_launch=self.dir_launch ).run() - def then_number_requests_sent_should_be_correct(self): + def then_test_was_started_with_correct_subject(self): + for request in self.requests: + assert f'do request: {request.path}. {request.comment}' in self.stdout_vedro_test + + def and_then_number_requests_sent_should_be_correct(self): assert self.api_mock.history == HistorySchema % [ { 'request': { - 'path': request + 'method': request.method, + 'path': request.path, + 'body': request.json_body or b'', } - } for request in self.requests.split('\n') * 2 + } for request in self.requests * 2 ] diff --git a/tests/e2e/test_data/get_requests.http b/tests/e2e/test_data/get_requests.http new file mode 100644 index 0000000..0b751fe --- /dev/null +++ b/tests/e2e/test_data/get_requests.http @@ -0,0 +1,7 @@ +### Запрос с установкой заголовка "Authorization" для доступа к защищенному ресурсу +GET http://{{host}}/1.0/secure-resource +X-Forwarded-For: 213.87.224.239 +Authorization: Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488 + +### +GET http://{{host}}/1.0/secure-resource diff --git a/tests/e2e/test_data/get_requests.txt b/tests/e2e/test_data/get_requests.txt new file mode 100644 index 0000000..24629b1 --- /dev/null +++ b/tests/e2e/test_data/get_requests.txt @@ -0,0 +1,2 @@ +http://{{host}}/1.0/secure-resource?q=123 +http://{{host}}/1.0/secure-resource?q=example diff --git a/tests/e2e/test_data/post_requests.http b/tests/e2e/test_data/post_requests.http new file mode 100644 index 0000000..cdc319f --- /dev/null +++ b/tests/e2e/test_data/post_requests.http @@ -0,0 +1,21 @@ +### +POST http://{{host}}/1.0/admin-users +Content-Type: application/json + +{"id": 17399, "user": "a82ec47d-9b72-41d7-9b4d-f36427561dd6", "data": [{"item": {"value": "Значение"}}]} + +### Назначение прав пользователю +POST http://{{host}}/1.0/admin-users +Content-Type: application/json + +{ + "id": 17399, + "user": "a82ec47d-9b72-41d7-9b4d-f36427561dd6", + "data": [ + { + "item": { + "value": "Значение" + } + } + ] +} diff --git a/tests/e2e/vedro.cfg.py b/tests/e2e/vedro.cfg.py index 59ad17e..bf9df5a 100644 --- a/tests/e2e/vedro.cfg.py +++ b/tests/e2e/vedro.cfg.py @@ -8,5 +8,5 @@ class Plugins(vedro.Config.Plugins): class ValeraValidator(valera_validator.ValeraValidator): enabled = True - class RemoteMock(vedro_jj.RemoteMock): + class RemoteMock(vedro_jj.VedroJJ): enabled = True diff --git a/tests/unit/parse_requests_test.py b/tests/unit/parse_requests_test.py new file mode 100644 index 0000000..ddfb23f --- /dev/null +++ b/tests/unit/parse_requests_test.py @@ -0,0 +1,135 @@ +import pytest + +from vedro_replay import Request, parse_requests + + +@pytest.mark.parametrize("request_file,expected_requests", [ + ( + "test_data/get_requests.txt", + [ + Request( + method="GET", + request_uri="http://{{host}}/1.0/secure-resource?q=123", + ), + Request( + method="GET", + request_uri="http://{{host}}/1.0/secure-resource?q=example", + ), + ] + ), + ( + "test_data/get_requests.http", + [ + Request( + comment="Запрос без параметров", + method="GET", + request_uri="https://{{host}}/", + ), + Request( + comment="Запрос к API для получения списка всех пользователей", + method="GET", + request_uri="https://{{host}}/users", + ), + Request( + comment="Запрос для поиска книги по названию", + method="GET", + request_uri="https://{{host}}/search?query=Harry+Potter", + ), + Request( + comment="Запрос для получения информации о товаре по его ID", + method="GET", + request_uri="https://{{host}}/product?id=12345", + ), + Request( + comment='Запрос с установкой заголовка "Authorization" для доступа к защищенному ресурсу', + method="GET", + request_uri="https://{{host}}/secure-resource", + headers={'Authorization': 'Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488'} + ), + Request( + comment='Запрос с установкой заголовка "User-Agent"', + method="GET", + request_uri="https://{{host}}/secure-resource", + headers={ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0' + } + ), + Request( + comment='Запрос с указанием заголовка "Accept" для запроса JSON-данных', + method="GET", + request_uri="https://{{host}}/data", + headers={'Accept': 'application/json'} + ), + Request( + comment='Запрос с кастомным заголовком "X-Custom-Header" для передачи дополнительных данных', + method="GET", + request_uri="https://{{host}}/resource", + headers={'X-Custom-Header': 'SomeCustomValue'} + ), + Request( + comment='Запрос с заголовком "Accept-Language" для указания предпочтительного языка', + method="GET", + request_uri="https://{{host}}/data", + headers={'Accept-Language': 'en-US'} + ), + Request( + comment='Запрос с кастомным заголовком "X-Request-Id" для идентификации запроса', + method="GET", + request_uri="https://{{host}}/data", + headers={'X-Request-Id': '12345'} + ), + Request( + comment='Запрос с передачей сессионной куки для аутентификации пользователя', + method="GET", + request_uri="https://{{host}}/dashboard", + headers={'Cookie': 'session_id=25b4fe6e-89d1-4b1a-8bd9-05624f7e7488'} + ), + ] + ), + ( + "test_data/post_requests.http", + [ + Request( + comment="Send POST request with json body", + method="POST", + request_uri="https://{{host}}/post", + headers={'Content-Type': 'application/json'}, + json_body={"id": 999, "value": "content"} + ), + Request( + method="POST", + request_uri="https://{{host}}/post", + json_body={"id": 999, "value": "content"} + ), + Request( + comment="Send POST request with json body", + method="POST", + request_uri="https://{{host}}/api/user/1", + headers={'Content-Type': 'application/json', + 'Authorization': 'Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488'}, + json_body={"name": "John Doe", "email": "johndoe@example.com", "data": {"key": "value"}} + ), + Request( + comment="Send a POST request with a comment in different languages to the json text", + method="POST", + request_uri="https://{{host}}/articles/123/comments", + headers={'Content-Type': 'application/json'}, + json_body={ + "comment_en": "Great article! Thanks for sharing.", + "comment_rus": "Комментарий на русском" + } + ), + ] + ), +]) +def test_parse(request_file, expected_requests): + actual_requests = parse_requests(request_file) + + assert len(expected_requests) == len(actual_requests) + + for request_number in range(len(expected_requests)): + assert expected_requests[request_number].comment == actual_requests[request_number].comment + assert expected_requests[request_number].method == actual_requests[request_number].method + assert expected_requests[request_number].request_uri == actual_requests[request_number].request_uri + assert expected_requests[request_number].headers == actual_requests[request_number].headers + assert expected_requests[request_number].json_body == actual_requests[request_number].json_body diff --git a/tests/unit/test_data/get_requests.http b/tests/unit/test_data/get_requests.http new file mode 100644 index 0000000..0cf7e0a --- /dev/null +++ b/tests/unit/test_data/get_requests.http @@ -0,0 +1,39 @@ +### Запрос без параметров +GET https://{{host}}/ + +### Запрос к API для получения списка всех пользователей +GET https://{{host}}/users + +### Запрос для поиска книги по названию +GET https://{{host}}/search?query=Harry+Potter + +### Запрос для получения информации о товаре по его ID +GET https://{{host}}/product?id=12345 + +### Запрос с установкой заголовка "Authorization" для доступа к защищенному ресурсу +GET https://{{host}}/secure-resource +Authorization: Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488 + +### Запрос с установкой заголовка "User-Agent" +GET https://{{host}}/secure-resource +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0 + +### Запрос с указанием заголовка "Accept" для запроса JSON-данных +GET https://{{host}}/data +Accept: application/json + +### Запрос с кастомным заголовком "X-Custom-Header" для передачи дополнительных данных +GET https://{{host}}/resource +X-Custom-Header: SomeCustomValue + +### Запрос с заголовком "Accept-Language" для указания предпочтительного языка +GET https://{{host}}/data +Accept-Language: en-US + +### Запрос с кастомным заголовком "X-Request-Id" для идентификации запроса +GET https://{{host}}/data +X-Request-Id: 12345 + +### Запрос с передачей сессионной куки для аутентификации пользователя +GET https://{{host}}/dashboard +Cookie: session_id=25b4fe6e-89d1-4b1a-8bd9-05624f7e7488 diff --git a/tests/unit/test_data/get_requests.txt b/tests/unit/test_data/get_requests.txt new file mode 100644 index 0000000..24629b1 --- /dev/null +++ b/tests/unit/test_data/get_requests.txt @@ -0,0 +1,2 @@ +http://{{host}}/1.0/secure-resource?q=123 +http://{{host}}/1.0/secure-resource?q=example diff --git a/tests/unit/test_data/post_requests.http b/tests/unit/test_data/post_requests.http new file mode 100644 index 0000000..e6daa56 --- /dev/null +++ b/tests/unit/test_data/post_requests.http @@ -0,0 +1,29 @@ +### Send POST request with json body +POST https://{{host}}/post +Content-Type: application/json + +{ + "id": 999, + "value": "content" +} + +### +POST https://{{host}}/post + +{"id": 999, "value": "content"} + +### Send POST request with json body +POST https://{{host}}/api/user/1 +Content-Type: application/json +Authorization: Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488 + +{"name": "John Doe", "email": "johndoe@example.com", "data": {"key": "value"}} + +### Send a POST request with a comment in different languages to the json text +POST https://{{host}}/articles/123/comments +Content-Type: application/json + +{ + "comment_en": "Great article! Thanks for sharing.", + "comment_rus": "Комментарий на русском" +} diff --git a/vedro_replay/__init__.py b/vedro_replay/__init__.py index 24c3c77..7f8804f 100644 --- a/vedro_replay/__init__.py +++ b/vedro_replay/__init__.py @@ -1,6 +1,18 @@ from .command import command from .excluder import Excluder, filter_response +from .parser import parse_requests from .replay import replay +from .request import Request from .response import JsonResponse, MultipartResponse, Response -__all__ = ("replay", "command", "filter_response", "Excluder", "Response", "JsonResponse", "MultipartResponse") +__all__ = ( + "replay", + "parse_requests", + "command", + "filter_response", + "Request", + "Excluder", + "Response", + "JsonResponse", + "MultipartResponse" +) diff --git a/vedro_replay/generator.py b/vedro_replay/generator.py index 9b134ab..894661f 100644 --- a/vedro_replay/generator.py +++ b/vedro_replay/generator.py @@ -7,6 +7,8 @@ from jinja2 import Environment, FileSystemLoader, Template +from .parser import parse_requests + class GeneratorException(Exception): pass @@ -46,7 +48,9 @@ def _create_dir(self, dir_name: str) -> None: @classmethod def generation_options(cls) -> List[str]: - return [key for key, value in cls.__dict__.items() if type(value) == FunctionType and not key.startswith('_')] + return [ + key for key, value in cls.__dict__.items() if isinstance(value, FunctionType) and not key.startswith('_') + ] class MainGenerator(Generator): @@ -149,7 +153,7 @@ def _scenario(self, file_requests: str, route: str) -> None: @staticmethod def _get_helper_method_name(api_route: str) -> str: - return 'prepare' + api_route.replace('/', '_').replace('.', '') + return 'prepare' + api_route.replace('/', '_').replace('.', '').replace('-', '_') @staticmethod def _get_scenario_name(file_requests: str) -> str: @@ -164,8 +168,8 @@ def _get_file_with_requests(self) -> List[str]: ] def _get_route(self, file_path: str) -> str: - with open(f'{self.__path_requests}/{file_path}') as requests_file: - return requests_file.readline().split('?')[0].strip() + requests = parse_requests(f'{self.__path_requests}/{file_path}') + return requests[0].path def _get_unique_routes(self) -> List[str]: routes = [self._get_route(file_requests) for file_requests in self._get_file_with_requests()] diff --git a/vedro_replay/parser.py b/vedro_replay/parser.py new file mode 100644 index 0000000..066fbe9 --- /dev/null +++ b/vedro_replay/parser.py @@ -0,0 +1,117 @@ +import json +import string +from abc import ABC, abstractmethod +from json import JSONDecodeError +from typing import List + +from pyparsing import ( + Combine, + OneOrMore, + Optional, + ParseException, + Suppress, + Word, + ZeroOrMore, + alphas, + printables, +) + +from .request import Request + + +class RequestParserException(Exception): + pass + + +class IncorrectContentsRequestFile(RequestParserException): + pass + + +class UnsupportedRequestFileFormat(RequestParserException): + pass + + +class RequestParser(ABC): + @classmethod + @abstractmethod + def parse(cls, data: str) -> List[Request]: + pass + + +class TxtRequestParser(RequestParser): + @classmethod + def parse(cls, data: str) -> List[Request]: + return [Request(method="GET", request_uri=request_uri) for request_uri in data.splitlines()] + + +class HttpRequestParser(RequestParser): + rus_alphas = "йцукеёнгшщзхъфывапролджэячсмитьбюЙЦУКЕЁНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ" + symbols = printables + rus_alphas + + delimiter_string = Combine( + Suppress("###") + + Optional(Suppress(ZeroOrMore(" "))) + + Optional(Word(symbols + " ")) + + Suppress("\n") + ).set_name("delimiter string with the format '### comment'").set_results_name("comment") + + method = Word(string.ascii_uppercase).set_name("http method").set_results_name("method") + + request_uri = Combine( + "http" + Optional("s") + + "://{{host}}" + + Word(symbols) + ).set_name("request uri with the format http(s)://{{host}}/...").set_results_name("request_uri") + + header_name = Word(alphas + "-").set_results_name("header_name") + header_value = Word(printables + " ").set_results_name("header_value") + header = (header_name + Suppress(":") + header_value).set_parse_action( + lambda t: {"key": t.header_name, "value": t.header_value}) + headers = ZeroOrMore(header).set_results_name("headers").set_parse_action( + lambda t: {h["key"]: h["value"] for h in t}) + + json_body = Combine( + "{" + + Optional(Suppress("\n")) + + OneOrMore(Word(symbols + " ") + Optional(Suppress("\n"))) + ).set_results_name("json_body") + + request = ( + delimiter_string + + method + + request_uri + + Optional(headers) + + Optional(json_body) + ).set_results_name("request").set_parse_action( + lambda t: { + "comment": t.comment or "", + "method": t.method, + "request_uri": t.request_uri, + "headers": t.headers or None, + "json_body": json.loads(t.json_body) if t.json_body else None + } + ) + + @classmethod + def parse(cls, data: str) -> List[Request]: + return [ + Request(**request_data) + for request_data in OneOrMore(cls.request).parse_string(data, parse_all=True).as_list() + ] + + +def parse_requests(requests_file: str) -> List[Request]: + with open(requests_file) as f: + data = f.read() + + try: + if ".http" in requests_file: + return HttpRequestParser.parse(data=data) + elif ".txt" in requests_file: + return TxtRequestParser.parse(data=data) + else: + raise UnsupportedRequestFileFormat(f"File format {requests_file} not supported") + except ParseException as e: + raise IncorrectContentsRequestFile(f"Failed to process file contents {requests_file}") from e + except JSONDecodeError as e: + raise IncorrectContentsRequestFile(f"Failed to process the json body in the file {requests_file}") from e diff --git a/vedro_replay/replay.py b/vedro_replay/replay.py index 2baf208..c69e8c9 100644 --- a/vedro_replay/replay.py +++ b/vedro_replay/replay.py @@ -3,14 +3,15 @@ from vedro import params +from .parser import parse_requests + def replay(requests_file: str) -> Callable[..., Any]: assert os.path.exists(requests_file) def wrapped(fn: Callable[..., Any]) -> Callable[..., Any]: - with open(requests_file) as f: - for request in f.read().splitlines(): - params(request)(fn) + for request in parse_requests(requests_file): + params(request.comment, request)(fn) return fn return wrapped diff --git a/vedro_replay/request.py b/vedro_replay/request.py index ae7e97d..06e754d 100644 --- a/vedro_replay/request.py +++ b/vedro_replay/request.py @@ -1,6 +1,39 @@ +import copy +import json +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +from jinja2 import Template + + class Request: - def __init__(self, request: str) -> None: - self.request = request + def __init__( + self, method: str, request_uri: str, + comment: str = "", headers: Optional[Dict[Any, Any]] = None, json_body: Optional[Dict[Any, Any]] = None + ) -> None: + self.comment = comment + self.method = method + self.request_uri = request_uri + self.path = urlparse(request_uri).path + self.headers = headers + self.json_body = json_body + + def specify_host(self, host: str) -> "Request": + prepared_request = copy.deepcopy(self) + prepared_request.request_uri = Template(prepared_request.request_uri).render(host=host) + return prepared_request + + def __repr__(self) -> str: + return self.__str__() def __str__(self) -> str: - return str(self.request) + result = "{method} {request_uri}\n".format(method=self.method, request_uri=self.request_uri) + + if self.headers: + for key, value in self.headers.items(): + result += f"{key}: {value}\n" + + if self.json_body: + result += f"{json.dumps(self.json_body)}\n" + + return result diff --git a/vedro_replay/templates/contexts.py.j2 b/vedro_replay/templates/contexts.py.j2 index e3cb94a..d28409c 100644 --- a/vedro_replay/templates/contexts.py.j2 +++ b/vedro_replay/templates/contexts.py.j2 @@ -4,19 +4,17 @@ import vedro from config import Config from interfaces.api import Api -from vedro_replay import Response +from vedro_replay import Request, Response @vedro.context -async def golden_response(url: str, prepare_response_method: Any) -> Response: - api = Api(Config.GOLDEN_API_URL) - response = await api.do_request(url=url) +async def golden_response(request: Request, prepare_response_method: Any) -> Response: + response = await Api().do_request(request=request.specify_host(host=Config.GOLDEN_API_URL)) return prepare_response_method(response) @vedro.context -async def testing_response(url: str, prepare_response_method) -> Response: - api = Api(Config.TESTING_API_URL) - response = await api.do_request(url=url) +async def testing_response(request: Request, prepare_response_method) -> Response: + response = await Api().do_request(request=request.specify_host(host=Config.TESTING_API_URL)) return prepare_response_method(response) diff --git a/vedro_replay/templates/interfaces.py.j2 b/vedro_replay/templates/interfaces.py.j2 index 69ac38c..047894c 100644 --- a/vedro_replay/templates/interfaces.py.j2 +++ b/vedro_replay/templates/interfaces.py.j2 @@ -1,12 +1,14 @@ -import httpx -import vedro +from vedro_httpx import AsyncHTTPInterface, Response +from vedro_replay import Request -class Api(vedro.Interface): - def __init__(self, base_url: str) -> None: - self.base_url = base_url - async def do_request(self, url: str): - async with httpx.AsyncClient(follow_redirects=True) as client: - return await client.get(self.base_url + url) +class Api(AsyncHTTPInterface): + async def do_request(self, request: Request) -> Response: + return await self._request( + method=request.method, + url=request.request_uri, + headers=request.headers, + json=request.json_body + ) diff --git a/vedro_replay/templates/scenario.py.j2 b/vedro_replay/templates/scenario.py.j2 index 3c4469a..e151d1a 100644 --- a/vedro_replay/templates/scenario.py.j2 +++ b/vedro_replay/templates/scenario.py.j2 @@ -3,14 +3,15 @@ from contexts.api import golden_response, testing_response from d42 import from_native from helpers.helpers import {{helper_method_name}} -from vedro_replay import replay +from vedro_replay import Request, replay class Scenario(vedro.Scenario): - subject = "do request: {{api_route}}" + subject = "do request: {{api_route}}. {subject}" @replay("{{path_requests}}/{{file_requests}}") - def __init__(self, request): + def __init__(self, subject: str, request: Request): + self.subject = subject self.request = request async def given_golden_response(self): From c8dc41e7fa210b426e578a2a00800b01dfcf19ce Mon Sep 17 00:00:00 2001 From: kvs8 Date: Fri, 22 Sep 2023 16:04:02 +0700 Subject: [PATCH 2/6] Fix by review --- tests/e2e/contexts/execution_directory.py | 4 +- ...nch_vedro_replay_tests_by_get_requests.py} | 47 ++-------- ...edro_replay_tests_by_requests_with_body.py | 77 +++++++++++++++ ...o_replay_tests_by_requests_with_headers.py | 93 +++++++++++++++++++ tests/e2e/test_data/get_requests.http | 6 +- tests/e2e/test_data/get_requests.txt | 4 +- tests/e2e/test_data/post_requests.http | 11 ++- .../test_data/post_requests_with_headers.http | 6 ++ tests/unit/parse_requests_test.py | 58 ++++++------ tests/unit/test_data/get_requests.http | 22 ++--- tests/unit/test_data/get_requests.txt | 4 +- vedro_replay/parser.py | 33 +++---- vedro_replay/request.py | 16 +--- vedro_replay/templates/contexts.py.j2 | 7 +- vedro_replay/templates/interfaces.py.j2 | 6 +- 15 files changed, 271 insertions(+), 123 deletions(-) rename tests/e2e/scenarios/{launch_vedro_replay_tests.py => launch_vedro_replay_tests_by_get_requests.py} (54%) create mode 100644 tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_body.py create mode 100644 tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_headers.py create mode 100644 tests/e2e/test_data/post_requests_with_headers.http diff --git a/tests/e2e/contexts/execution_directory.py b/tests/e2e/contexts/execution_directory.py index 2bdd3d3..a5ef91d 100644 --- a/tests/e2e/contexts/execution_directory.py +++ b/tests/e2e/contexts/execution_directory.py @@ -14,5 +14,5 @@ def execution_directory(dir_launch: str, dir_requests: str, file_requests: str) subprocess.run(['cp', 'test_data/' + file_requests, f"{dir_launch}/{dir_requests}/{file_requests}"], check=True) - os.environ.setdefault('GOLDEN_API_URL', 'localhost:8080') - os.environ.setdefault('TESTING_API_URL', 'localhost:8080') + os.environ.setdefault('GOLDEN_API_URL', 'http://localhost:8080') + os.environ.setdefault('TESTING_API_URL', 'http://localhost:8080') diff --git a/tests/e2e/scenarios/launch_vedro_replay_tests.py b/tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py similarity index 54% rename from tests/e2e/scenarios/launch_vedro_replay_tests.py rename to tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py index 38e50e6..21d4cc2 100644 --- a/tests/e2e/scenarios/launch_vedro_replay_tests.py +++ b/tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py @@ -10,60 +10,34 @@ class Scenario(vedro.Scenario): - subject = 'launch vedro-replay tests: {subject}' + subject = 'launch vedro-replay by GET requests: {subject}' - @params('default generate by .txt file', + @params('.txt file', 'requests', 'get_requests.txt', [ Request( method="GET", - request_uri="http://{{host}}/1.0/secure-resource?q=123", + url="/1.0/secure-resource?q=123", ), Request( method="GET", - request_uri="http://{{host}}/1.0/secure-resource?q=example", + url="/1.0/secure-resource?q=example", ), ] ) - @params('default generate by GET requests from .http file', - 'requests', + @params('.http file. Generate with --path-requests', + 'special_requests', 'get_requests.http', [ Request( - comment='Запрос с установкой заголовка "Authorization" для доступа к защищенному ресурсу', + comment='GET Request', method="GET", - request_uri="http://{{host}}/1.0/secure-resource", + url="/1.0/secure-resource", ), Request( method="GET", - request_uri="http://{{host}}/1.0/secure-resource", - ), - ] - ) - @params('generate with --path-requests by POST requests from .http file', - 'special_requests', - 'post_requests.http', - [ - Request( - comment='Назначение прав пользователю', - method="POST", - request_uri="http://{{host}}/1.0/admin-users", - json_body={ - "id": 17399, "user": "a82ec47d-9b72-41d7-9b4d-f36427561dd6", "data": [ - {"item": {"value": "Значение"}} - ] - } - ), - Request( - comment='Назначение прав пользователю', - method="POST", - request_uri="http://{{host}}/1.0/admin-users", - json_body={ - "id": 17399, "user": "a82ec47d-9b72-41d7-9b4d-f36427561dd6", "data": [ - {"item": {"value": "Значение"}} - ] - } + url="/1.0/secure-resource?data=all&search=value", ), ] ) @@ -103,7 +77,6 @@ def and_then_number_requests_sent_should_be_correct(self): 'request': { 'method': request.method, 'path': request.path, - 'body': request.json_body or b'', } - } for request in self.requests * 2 + } for request in [r for r in self.requests for _ in range(2)] ] diff --git a/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_body.py b/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_body.py new file mode 100644 index 0000000..8a1f0da --- /dev/null +++ b/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_body.py @@ -0,0 +1,77 @@ +from typing import List + +import vedro +from contexts import execution_directory, mocked_api +from interfaces import VedroReplayCLI, VedroTestCLI +from jj_district42 import HistorySchema +from vedro import params + +from vedro_replay.request import Request + + +class Scenario(vedro.Scenario): + subject = 'launch vedro-replay tests by POST requests with json body' + + @params('requests', + 'post_requests.http', + [ + Request( + method="POST", + url="http://{{host}}/1.0/admin-users", + json_body={ + "id": 17399, "user": "a82ec47d-9b72-41d7-9b4d-f36427561dd6", "data": [ + {"item": {"key": "value"}} + ] + } + ), + Request( + comment='Assigning rights to a user', + method="POST", + url="http://{{host}}/1.0/admin-users", + json_body={ + "id": 17399, "user": "a82ec47d-9b72-41d7-9b4d-f36427561dd6", "data": [ + {"item": {"key": "value"}}, {"item2": {"key2": "value2"}} + ] + } + ), + ] + ) + def __init__(self, dir_request: str, file_requests: str, requests: List[Request]): + self.dir_launch = 'launch' + self.dir_requests = dir_request + self.file_requests = file_requests + self.requests = requests + + def given_prepared_execution_directory(self): + execution_directory( + dir_launch=self.dir_launch, + dir_requests=self.dir_requests, + file_requests=self.file_requests, + ) + + async def given_vedro_replay_tests(self): + self.stdout_vedro_replay, self.stderr_vedro_replay = await VedroReplayCLI( + dir_launch=self.dir_launch, + dir_requests=self.dir_requests + ).run() + + async def when_replay_tests_running(self): + async with mocked_api() as self.api_mock: + self.stdout_vedro_test, self.stderr_vedro_test = await VedroTestCLI( + dir_launch=self.dir_launch + ).run() + + def then_test_was_started_with_correct_subject(self): + for request in self.requests: + assert f'do request: {request.path}. {request.comment}' in self.stdout_vedro_test + + def and_then_number_requests_sent_should_be_correct(self): + assert self.api_mock.history == HistorySchema % [ + { + 'request': { + 'method': request.method, + 'path': request.path, + 'body': request.json_body, + } + } for request in [r for r in self.requests for _ in range(2)] + ] diff --git a/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_headers.py b/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_headers.py new file mode 100644 index 0000000..0218fbc --- /dev/null +++ b/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_headers.py @@ -0,0 +1,93 @@ +from typing import List + +import vedro +from contexts import execution_directory, mocked_api +from interfaces import VedroReplayCLI, VedroTestCLI +from jj_district42 import HistorySchema +from vedro import params + +from vedro_replay.request import Request + + +class Scenario(vedro.Scenario): + subject = 'launch vedro-replay tests by requests with headers: {subject}' + + @params('GET requests from .http file', + 'requests', + 'get_requests_with_headers.http', + [ + Request( + comment='Request with the "Authorization" header setting to access a protected resource', + method="GET", + url="/1.0/secure-resource", + headers={ + "X-Forwarded-For": "213.87.224.239", + "Authorization": "Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488" + } + ), + Request( + comment='Request with cookie', + method="GET", + url="/1.0/secure-resource?query=value", + headers={ + "Cookie": "session_id=25b4fe6e-89d1-4b1a-8bd9-05624f7e7488", + } + ), + ] + ) + @params('POST requests from .http file', + 'requests', + 'post_requests_with_headers.http', + [ + Request( + comment='POST request with headers', + method="POST", + url="/v1/users", + headers={ + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0" + }, + json_body={"name": "John Doe", "email": "johndoe@example.com"} + ), + ] + ) + def __init__(self, subject: str, dir_request: str, file_requests: str, requests: List[Request]): + self.subject = subject + self.dir_launch = 'launch' + self.dir_requests = dir_request + self.file_requests = file_requests + self.requests = requests + + def given_prepared_execution_directory(self): + execution_directory( + dir_launch=self.dir_launch, + dir_requests=self.dir_requests, + file_requests=self.file_requests, + ) + + async def given_vedro_replay_tests(self): + self.stdout_vedro_replay, self.stderr_vedro_replay = await VedroReplayCLI( + dir_launch=self.dir_launch, + dir_requests=self.dir_requests + ).run() + + async def when_replay_tests_running(self): + async with mocked_api() as self.api_mock: + self.stdout_vedro_test, self.stderr_vedro_test = await VedroTestCLI( + dir_launch=self.dir_launch + ).run() + + def then_test_was_started_with_correct_subject(self): + for request in self.requests: + assert f'do request: {request.path}. {request.comment}' in self.stdout_vedro_test + + def and_then_number_requests_sent_should_be_correct(self): + assert self.api_mock.history == HistorySchema % [ + { + 'request': { + 'method': request.method, + 'path': request.path, + 'headers': [...] + [[hn, hv] for hn, hv in request.headers.items()] + [...] + } + } for request in [r for r in self.requests for _ in range(2)] + ] diff --git a/tests/e2e/test_data/get_requests.http b/tests/e2e/test_data/get_requests.http index 0b751fe..9232e6b 100644 --- a/tests/e2e/test_data/get_requests.http +++ b/tests/e2e/test_data/get_requests.http @@ -1,7 +1,5 @@ -### Запрос с установкой заголовка "Authorization" для доступа к защищенному ресурсу +### GET Request GET http://{{host}}/1.0/secure-resource -X-Forwarded-For: 213.87.224.239 -Authorization: Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488 ### -GET http://{{host}}/1.0/secure-resource +GET http://{{host}}/1.0/secure-resource?data=all&search=value diff --git a/tests/e2e/test_data/get_requests.txt b/tests/e2e/test_data/get_requests.txt index 24629b1..4601bcd 100644 --- a/tests/e2e/test_data/get_requests.txt +++ b/tests/e2e/test_data/get_requests.txt @@ -1,2 +1,2 @@ -http://{{host}}/1.0/secure-resource?q=123 -http://{{host}}/1.0/secure-resource?q=example +/1.0/secure-resource?q=123 +/1.0/secure-resource?q=example diff --git a/tests/e2e/test_data/post_requests.http b/tests/e2e/test_data/post_requests.http index cdc319f..0ecb7b9 100644 --- a/tests/e2e/test_data/post_requests.http +++ b/tests/e2e/test_data/post_requests.http @@ -2,9 +2,9 @@ POST http://{{host}}/1.0/admin-users Content-Type: application/json -{"id": 17399, "user": "a82ec47d-9b72-41d7-9b4d-f36427561dd6", "data": [{"item": {"value": "Значение"}}]} +{"id": 17399, "user": "a82ec47d-9b72-41d7-9b4d-f36427561dd6", "data": [{"item": {"key": "value"}}]} -### Назначение прав пользователю +### Assigning rights to a user POST http://{{host}}/1.0/admin-users Content-Type: application/json @@ -14,7 +14,12 @@ Content-Type: application/json "data": [ { "item": { - "value": "Значение" + "key": "value" + } + }, + { + "item2": { + "key2": "value2" } } ] diff --git a/tests/e2e/test_data/post_requests_with_headers.http b/tests/e2e/test_data/post_requests_with_headers.http new file mode 100644 index 0000000..f4d806b --- /dev/null +++ b/tests/e2e/test_data/post_requests_with_headers.http @@ -0,0 +1,6 @@ +### POST request with headers +POST http://{{host}}/v1/users +Content-Type: application/json +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0 + +{"name": "John Doe", "email": "johndoe@example.com"} diff --git a/tests/unit/parse_requests_test.py b/tests/unit/parse_requests_test.py index ddfb23f..e577be1 100644 --- a/tests/unit/parse_requests_test.py +++ b/tests/unit/parse_requests_test.py @@ -9,11 +9,11 @@ [ Request( method="GET", - request_uri="http://{{host}}/1.0/secure-resource?q=123", + url="/1.0/secure-resource?q=123", ), Request( method="GET", - request_uri="http://{{host}}/1.0/secure-resource?q=example", + url="/1.0/secure-resource?q=example", ), ] ), @@ -21,67 +21,67 @@ "test_data/get_requests.http", [ Request( - comment="Запрос без параметров", + comment="Request without parameters", method="GET", - request_uri="https://{{host}}/", + url="/", ), Request( - comment="Запрос к API для получения списка всех пользователей", + comment="API request to get a list of all users", method="GET", - request_uri="https://{{host}}/users", + url="/users", ), Request( - comment="Запрос для поиска книги по названию", + comment="Request to search for a book by title", method="GET", - request_uri="https://{{host}}/search?query=Harry+Potter", + url="/search?query=Harry+Potter", ), Request( - comment="Запрос для получения информации о товаре по его ID", + comment="Request for information about a product by its ID", method="GET", - request_uri="https://{{host}}/product?id=12345", + url="/product?id=12345", ), Request( - comment='Запрос с установкой заголовка "Authorization" для доступа к защищенному ресурсу', + comment='Request with setting the "Authorization" header to access a protected resource', method="GET", - request_uri="https://{{host}}/secure-resource", + url="/secure-resource", headers={'Authorization': 'Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488'} ), Request( - comment='Запрос с установкой заголовка "User-Agent"', + comment='Request with the header "User-Agent"', method="GET", - request_uri="https://{{host}}/secure-resource", + url="/secure-resource", headers={ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0' } ), Request( - comment='Запрос с указанием заголовка "Accept" для запроса JSON-данных', + comment='Request specifying the "Accept" header for requesting JSON data', method="GET", - request_uri="https://{{host}}/data", + url="/data", headers={'Accept': 'application/json'} ), Request( - comment='Запрос с кастомным заголовком "X-Custom-Header" для передачи дополнительных данных', + comment='Request with custom header "X-Custom-Header" for transmitting additional data', method="GET", - request_uri="https://{{host}}/resource", + url="/resource", headers={'X-Custom-Header': 'SomeCustomValue'} ), Request( - comment='Запрос с заголовком "Accept-Language" для указания предпочтительного языка', + comment='Request with the "Accept-Language" header to specify the preferred language', method="GET", - request_uri="https://{{host}}/data", + url="/data", headers={'Accept-Language': 'en-US'} ), Request( - comment='Запрос с кастомным заголовком "X-Request-Id" для идентификации запроса', + comment='Request with custom header "X-Request-Id" to identify the request', method="GET", - request_uri="https://{{host}}/data", + url="/data", headers={'X-Request-Id': '12345'} ), Request( - comment='Запрос с передачей сессионной куки для аутентификации пользователя', + comment='Request with the transfer of a session cookie for user authentication', method="GET", - request_uri="https://{{host}}/dashboard", + url="/dashboard", headers={'Cookie': 'session_id=25b4fe6e-89d1-4b1a-8bd9-05624f7e7488'} ), ] @@ -92,19 +92,19 @@ Request( comment="Send POST request with json body", method="POST", - request_uri="https://{{host}}/post", + url="/post", headers={'Content-Type': 'application/json'}, json_body={"id": 999, "value": "content"} ), Request( method="POST", - request_uri="https://{{host}}/post", + url="/post", json_body={"id": 999, "value": "content"} ), Request( comment="Send POST request with json body", method="POST", - request_uri="https://{{host}}/api/user/1", + url="/api/user/1", headers={'Content-Type': 'application/json', 'Authorization': 'Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488'}, json_body={"name": "John Doe", "email": "johndoe@example.com", "data": {"key": "value"}} @@ -112,7 +112,7 @@ Request( comment="Send a POST request with a comment in different languages to the json text", method="POST", - request_uri="https://{{host}}/articles/123/comments", + url="/articles/123/comments", headers={'Content-Type': 'application/json'}, json_body={ "comment_en": "Great article! Thanks for sharing.", @@ -130,6 +130,6 @@ def test_parse(request_file, expected_requests): for request_number in range(len(expected_requests)): assert expected_requests[request_number].comment == actual_requests[request_number].comment assert expected_requests[request_number].method == actual_requests[request_number].method - assert expected_requests[request_number].request_uri == actual_requests[request_number].request_uri + assert expected_requests[request_number].url == actual_requests[request_number].url assert expected_requests[request_number].headers == actual_requests[request_number].headers assert expected_requests[request_number].json_body == actual_requests[request_number].json_body diff --git a/tests/unit/test_data/get_requests.http b/tests/unit/test_data/get_requests.http index 0cf7e0a..daea6b0 100644 --- a/tests/unit/test_data/get_requests.http +++ b/tests/unit/test_data/get_requests.http @@ -1,39 +1,39 @@ -### Запрос без параметров +### Request without parameters GET https://{{host}}/ -### Запрос к API для получения списка всех пользователей +### API request to get a list of all users GET https://{{host}}/users -### Запрос для поиска книги по названию +### Request to search for a book by title GET https://{{host}}/search?query=Harry+Potter -### Запрос для получения информации о товаре по его ID +### Request for information about a product by its ID GET https://{{host}}/product?id=12345 -### Запрос с установкой заголовка "Authorization" для доступа к защищенному ресурсу +### Request with setting the "Authorization" header to access a protected resource GET https://{{host}}/secure-resource Authorization: Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488 -### Запрос с установкой заголовка "User-Agent" +### Request with the header "User-Agent" GET https://{{host}}/secure-resource User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0 -### Запрос с указанием заголовка "Accept" для запроса JSON-данных +### Request specifying the "Accept" header for requesting JSON data GET https://{{host}}/data Accept: application/json -### Запрос с кастомным заголовком "X-Custom-Header" для передачи дополнительных данных +### Request with custom header "X-Custom-Header" for transmitting additional data GET https://{{host}}/resource X-Custom-Header: SomeCustomValue -### Запрос с заголовком "Accept-Language" для указания предпочтительного языка +### Request with the "Accept-Language" header to specify the preferred language GET https://{{host}}/data Accept-Language: en-US -### Запрос с кастомным заголовком "X-Request-Id" для идентификации запроса +### Request with custom header "X-Request-Id" to identify the request GET https://{{host}}/data X-Request-Id: 12345 -### Запрос с передачей сессионной куки для аутентификации пользователя +### Request with the transfer of a session cookie for user authentication GET https://{{host}}/dashboard Cookie: session_id=25b4fe6e-89d1-4b1a-8bd9-05624f7e7488 diff --git a/tests/unit/test_data/get_requests.txt b/tests/unit/test_data/get_requests.txt index 24629b1..4601bcd 100644 --- a/tests/unit/test_data/get_requests.txt +++ b/tests/unit/test_data/get_requests.txt @@ -1,2 +1,2 @@ -http://{{host}}/1.0/secure-resource?q=123 -http://{{host}}/1.0/secure-resource?q=example +/1.0/secure-resource?q=123 +/1.0/secure-resource?q=example diff --git a/vedro_replay/parser.py b/vedro_replay/parser.py index 066fbe9..2a43d39 100644 --- a/vedro_replay/parser.py +++ b/vedro_replay/parser.py @@ -2,9 +2,11 @@ import string from abc import ABC, abstractmethod from json import JSONDecodeError +from pathlib import PurePosixPath from typing import List from pyparsing import ( + CharsNotIn, Combine, OneOrMore, Optional, @@ -14,6 +16,7 @@ ZeroOrMore, alphas, printables, + restOfLine, ) from .request import Request @@ -41,27 +44,23 @@ def parse(cls, data: str) -> List[Request]: class TxtRequestParser(RequestParser): @classmethod def parse(cls, data: str) -> List[Request]: - return [Request(method="GET", request_uri=request_uri) for request_uri in data.splitlines()] + return [Request(method="GET", url=url) for url in data.splitlines()] class HttpRequestParser(RequestParser): - rus_alphas = "йцукеёнгшщзхъфывапролджэячсмитьбюЙЦУКЕЁНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ" - symbols = printables + rus_alphas - delimiter_string = Combine( Suppress("###") + Optional(Suppress(ZeroOrMore(" "))) + - Optional(Word(symbols + " ")) + - Suppress("\n") + restOfLine ).set_name("delimiter string with the format '### comment'").set_results_name("comment") method = Word(string.ascii_uppercase).set_name("http method").set_results_name("method") - request_uri = Combine( - "http" + Optional("s") + - "://{{host}}" + - Word(symbols) - ).set_name("request uri with the format http(s)://{{host}}/...").set_results_name("request_uri") + url = Combine( + Suppress("http" + Optional("s")) + + Suppress("://{{host}}") + + restOfLine + ).set_name("request uri with the format http(s)://{{host}}/...").set_results_name("url") header_name = Word(alphas + "-").set_results_name("header_name") header_value = Word(printables + " ").set_results_name("header_value") @@ -73,20 +72,20 @@ class HttpRequestParser(RequestParser): json_body = Combine( "{" + Optional(Suppress("\n")) + - OneOrMore(Word(symbols + " ") + Optional(Suppress("\n"))) + OneOrMore(CharsNotIn("\n") + Optional(Suppress("\n"))) ).set_results_name("json_body") request = ( delimiter_string + method + - request_uri + + url + Optional(headers) + Optional(json_body) ).set_results_name("request").set_parse_action( lambda t: { "comment": t.comment or "", "method": t.method, - "request_uri": t.request_uri, + "url": t.url, "headers": t.headers or None, "json_body": json.loads(t.json_body) if t.json_body else None } @@ -104,10 +103,12 @@ def parse_requests(requests_file: str) -> List[Request]: with open(requests_file) as f: data = f.read() + file_suffix = PurePosixPath(requests_file).suffix + try: - if ".http" in requests_file: + if file_suffix == ".http": return HttpRequestParser.parse(data=data) - elif ".txt" in requests_file: + elif file_suffix == ".txt": return TxtRequestParser.parse(data=data) else: raise UnsupportedRequestFileFormat(f"File format {requests_file} not supported") diff --git a/vedro_replay/request.py b/vedro_replay/request.py index 06e754d..bb4d327 100644 --- a/vedro_replay/request.py +++ b/vedro_replay/request.py @@ -1,33 +1,25 @@ -import copy import json from typing import Any, Dict, Optional from urllib.parse import urlparse -from jinja2 import Template - class Request: def __init__( - self, method: str, request_uri: str, + self, method: str, url: str, comment: str = "", headers: Optional[Dict[Any, Any]] = None, json_body: Optional[Dict[Any, Any]] = None ) -> None: self.comment = comment self.method = method - self.request_uri = request_uri - self.path = urlparse(request_uri).path + self.url = url + self.path = urlparse(url).path self.headers = headers self.json_body = json_body - def specify_host(self, host: str) -> "Request": - prepared_request = copy.deepcopy(self) - prepared_request.request_uri = Template(prepared_request.request_uri).render(host=host) - return prepared_request - def __repr__(self) -> str: return self.__str__() def __str__(self) -> str: - result = "{method} {request_uri}\n".format(method=self.method, request_uri=self.request_uri) + result = f"{self.method} {self.url}\n" if self.headers: for key, value in self.headers.items(): diff --git a/vedro_replay/templates/contexts.py.j2 b/vedro_replay/templates/contexts.py.j2 index d28409c..73c4b60 100644 --- a/vedro_replay/templates/contexts.py.j2 +++ b/vedro_replay/templates/contexts.py.j2 @@ -9,12 +9,13 @@ from vedro_replay import Request, Response @vedro.context async def golden_response(request: Request, prepare_response_method: Any) -> Response: - response = await Api().do_request(request=request.specify_host(host=Config.GOLDEN_API_URL)) + api = Api(Config.GOLDEN_API_URL) + response = await api.do_request(request=request) return prepare_response_method(response) @vedro.context async def testing_response(request: Request, prepare_response_method) -> Response: - response = await Api().do_request(request=request.specify_host(host=Config.TESTING_API_URL)) + api = Api(Config.TESTING_API_URL) + response = await api.do_request(request=request) return prepare_response_method(response) - diff --git a/vedro_replay/templates/interfaces.py.j2 b/vedro_replay/templates/interfaces.py.j2 index 047894c..a46c269 100644 --- a/vedro_replay/templates/interfaces.py.j2 +++ b/vedro_replay/templates/interfaces.py.j2 @@ -4,11 +4,13 @@ from vedro_replay import Request class Api(AsyncHTTPInterface): + def __init__(self, base_url: str) -> None: + super().__init__(base_url) + async def do_request(self, request: Request) -> Response: return await self._request( method=request.method, - url=request.request_uri, + url=request.url, headers=request.headers, json=request.json_body ) - From 48db577d5e28522c5cf6754017e6afd0b8d86213 Mon Sep 17 00:00:00 2001 From: kvs8 Date: Fri, 22 Sep 2023 16:04:21 +0700 Subject: [PATCH 3/6] Fix by review --- tests/e2e/test_data/get_requests_with_headers.http | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 tests/e2e/test_data/get_requests_with_headers.http diff --git a/tests/e2e/test_data/get_requests_with_headers.http b/tests/e2e/test_data/get_requests_with_headers.http new file mode 100644 index 0000000..b7aecff --- /dev/null +++ b/tests/e2e/test_data/get_requests_with_headers.http @@ -0,0 +1,8 @@ +### Request with the "Authorization" header setting to access a protected resource +GET http://{{host}}/1.0/secure-resource +X-Forwarded-For: 213.87.224.239 +Authorization: Bearer 25b4fe6e-89d1-4b1a-8bd9-05624f7e7488 + +### Request with cookie +GET http://{{host}}/1.0/secure-resource?query=value +Cookie: session_id=25b4fe6e-89d1-4b1a-8bd9-05624f7e7488 From e459974fd33688cd0d9fc7c5e9c4d4b5aaf9cdf2 Mon Sep 17 00:00:00 2001 From: kvs8 Date: Fri, 22 Sep 2023 17:00:19 +0700 Subject: [PATCH 4/6] rename --path-requests in --requests-dir --- Makefile | 2 +- README.md | 6 +++--- tests/e2e/interfaces/cli.py | 6 +++--- ...unch_vedro_replay_tests_by_get_requests.py | 2 +- vedro_replay/command.py | 2 +- vedro_replay/generator.py | 20 +++++++++---------- vedro_replay/templates/scenario.py.j2 | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index e5c63e5..013081f 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: clean clean: - rm -r build dist vedro_replay.egg-info + rm -r build dist vedro_replay.egg-info .mypy_cache .pytest_cache .PHONY: install install: diff --git a/README.md b/README.md index 2f9a980..e4bb5d2 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ options: $ vedro-replay genearate -h ``` ``` -usage: vedro-replay generate [-h] [--path-requests PATH_REQUESTS] [--force] +usage: vedro-replay generate [-h] [--requests-dir REQUESTS_DIR] [--force] [{all,vedro_cfg,config,interfaces,contexts,helpers,helpers_methods,scenarios}] - by default all positional arguments: @@ -52,13 +52,13 @@ positional arguments: options: -h, --help show this help message and exit - --path-requests PATH_REQUESTS + --requests-dir REQUESTS_DIR The path to the directory containing the request files --force Forced regeneration. The files will be overwritten ``` To be able to generate a test, you need to have a directory with files containing requests -(`requests` directory is expected by default, you can specify a specific directory using the `--path_requests` argument). +(`requests` directory is expected by default, you can specify a specific directory using the `--requests-dir` argument). Example: ```shell diff --git a/tests/e2e/interfaces/cli.py b/tests/e2e/interfaces/cli.py index 30457c4..0f3e4b7 100644 --- a/tests/e2e/interfaces/cli.py +++ b/tests/e2e/interfaces/cli.py @@ -26,12 +26,12 @@ def __init__(self, dir_launch: str, dir_requests: str) -> None: async def run(self) -> Tuple[str, str]: return await self._run( - command=f'vedro-replay generate {self.__path_requests()}', + command=f'vedro-replay generate {self.__requests_dir()}', cwd=f'{os.getcwd()}/{self.dir_launch}' ) - def __path_requests(self) -> str: - return '' if self.dir_requests == 'requests' else f'--path-requests={self.dir_requests}' + def __requests_dir(self) -> str: + return '' if self.dir_requests == 'requests' else f'--requests-dir={self.dir_requests}' class VedroTestCLI(AbstractCLI): diff --git a/tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py b/tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py index 21d4cc2..4f948fd 100644 --- a/tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py +++ b/tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py @@ -26,7 +26,7 @@ class Scenario(vedro.Scenario): ), ] ) - @params('.http file. Generate with --path-requests', + @params('.http file. Generate with --requests-dir', 'special_requests', 'get_requests.http', [ diff --git a/vedro_replay/command.py b/vedro_replay/command.py index 6a58772..a982668 100644 --- a/vedro_replay/command.py +++ b/vedro_replay/command.py @@ -12,7 +12,7 @@ def command() -> None: 'option', default='all', nargs='?', choices=MainGenerator.generation_options(), help='Generation option', ) generate_parser.add_argument( - '--path-requests', help='The path to the directory containing the request files', default='requests' + '--requests-dir', help='The path to the directory containing the request files', default='requests' ) generate_parser.add_argument( '--force', help='Forced regeneration. The files will be overwritten', action='store_true' diff --git a/vedro_replay/generator.py b/vedro_replay/generator.py index 894661f..e14a4b9 100644 --- a/vedro_replay/generator.py +++ b/vedro_replay/generator.py @@ -74,9 +74,9 @@ class MainGenerator(Generator): __FILE_VEDRO_CFG = 'vedro.cfg.py' __FILE_CONFIG = 'config.py' - def __init__(self, path_requests: str, force: bool, log: logging.Logger): + def __init__(self, requests_dir: str, force: bool, log: logging.Logger): super().__init__(force=force, log=log) - self.__path_requests = path_requests + self.__requests_dir = requests_dir self.__templates = Environment(loader=FileSystemLoader(self.__PATH_TEMPLATES)) def all(self) -> None: @@ -145,7 +145,7 @@ def _scenario(self, file_requests: str, route: str) -> None: self._generate_by_template( file_path=file_path, template_name=self.__TEMPLATE_SCENARIO, - path_requests=self.__path_requests, + requests_dir=self.__requests_dir, api_route=route, file_requests=file_requests, helper_method_name=self._get_helper_method_name(route) @@ -160,15 +160,15 @@ def _get_scenario_name(file_requests: str) -> str: return file_requests.split('.')[0] def _get_file_with_requests(self) -> List[str]: - if not os.path.exists(self.__path_requests): - raise DirectoryWithRequestsNotFound(f"The directory with requests: {self.__path_requests} was not found") + if not os.path.exists(self.__requests_dir): + raise DirectoryWithRequestsNotFound(f"The directory with requests: {self.__requests_dir} was not found") return [ - file for file in os.listdir(self.__path_requests) - if os.path.isfile(os.path.join(self.__path_requests, file)) + file for file in os.listdir(self.__requests_dir) + if os.path.isfile(os.path.join(self.__requests_dir, file)) ] def _get_route(self, file_path: str) -> str: - requests = parse_requests(f'{self.__path_requests}/{file_path}') + requests = parse_requests(f'{self.__requests_dir}/{file_path}') return requests[0].path def _get_unique_routes(self) -> List[str]: @@ -184,7 +184,7 @@ def generate(args: Any) -> None: log = logging.getLogger("Generator") try: - getattr(MainGenerator(path_requests=args.path_requests, force=args.force, log=log), args.option)() + getattr(MainGenerator(requests_dir=args.requests_dir, force=args.force, log=log), args.option)() except DirectoryWithRequestsNotFound as e: log.critical(f'{e}. By default, the "requests" directory was expected. ' - 'Use --path-requests to specify another') + 'Use --requests-dir to specify another') diff --git a/vedro_replay/templates/scenario.py.j2 b/vedro_replay/templates/scenario.py.j2 index e151d1a..615b8bb 100644 --- a/vedro_replay/templates/scenario.py.j2 +++ b/vedro_replay/templates/scenario.py.j2 @@ -9,7 +9,7 @@ from vedro_replay import Request, replay class Scenario(vedro.Scenario): subject = "do request: {{api_route}}. {subject}" - @replay("{{path_requests}}/{{file_requests}}") + @replay("{{requests_dir}}/{{file_requests}}") def __init__(self, subject: str, request: Request): self.subject = subject self.request = request From 6170a94e4273cf04b5a0c977668ba2d7c920f4b9 Mon Sep 17 00:00:00 2001 From: kvs8 Date: Fri, 22 Sep 2023 17:46:11 +0700 Subject: [PATCH 5/6] rename subject in comment --- .../scenarios/launch_vedro_replay_tests_by_get_requests.py | 2 +- .../launch_vedro_replay_tests_by_requests_with_body.py | 2 +- .../launch_vedro_replay_tests_by_requests_with_headers.py | 2 +- vedro_replay/templates/scenario.py.j2 | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py b/tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py index 4f948fd..599b7b6 100644 --- a/tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py +++ b/tests/e2e/scenarios/launch_vedro_replay_tests_by_get_requests.py @@ -69,7 +69,7 @@ async def when_replay_tests_running(self): def then_test_was_started_with_correct_subject(self): for request in self.requests: - assert f'do request: {request.path}. {request.comment}' in self.stdout_vedro_test + assert f"do request: {request.path} (comment='{request.comment}')" in self.stdout_vedro_test def and_then_number_requests_sent_should_be_correct(self): assert self.api_mock.history == HistorySchema % [ diff --git a/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_body.py b/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_body.py index 8a1f0da..1b87296 100644 --- a/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_body.py +++ b/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_body.py @@ -63,7 +63,7 @@ async def when_replay_tests_running(self): def then_test_was_started_with_correct_subject(self): for request in self.requests: - assert f'do request: {request.path}. {request.comment}' in self.stdout_vedro_test + assert f"do request: {request.path} (comment='{request.comment}')" in self.stdout_vedro_test def and_then_number_requests_sent_should_be_correct(self): assert self.api_mock.history == HistorySchema % [ diff --git a/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_headers.py b/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_headers.py index 0218fbc..8ee914c 100644 --- a/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_headers.py +++ b/tests/e2e/scenarios/launch_vedro_replay_tests_by_requests_with_headers.py @@ -79,7 +79,7 @@ async def when_replay_tests_running(self): def then_test_was_started_with_correct_subject(self): for request in self.requests: - assert f'do request: {request.path}. {request.comment}' in self.stdout_vedro_test + assert f"do request: {request.path} (comment='{request.comment}')" in self.stdout_vedro_test def and_then_number_requests_sent_should_be_correct(self): assert self.api_mock.history == HistorySchema % [ diff --git a/vedro_replay/templates/scenario.py.j2 b/vedro_replay/templates/scenario.py.j2 index 615b8bb..a01e85e 100644 --- a/vedro_replay/templates/scenario.py.j2 +++ b/vedro_replay/templates/scenario.py.j2 @@ -7,11 +7,11 @@ from vedro_replay import Request, replay class Scenario(vedro.Scenario): - subject = "do request: {{api_route}}. {subject}" + subject = "do request: {{api_route}} (comment='{comment}')" @replay("{{requests_dir}}/{{file_requests}}") - def __init__(self, subject: str, request: Request): - self.subject = subject + def __init__(self, comment: str, request: Request): + self.comment = comment self.request = request async def given_golden_response(self): From 606a647f7471af2cf571d1c9fb40f612f1f6e0fe Mon Sep 17 00:00:00 2001 From: kvs8 Date: Fri, 22 Sep 2023 18:52:02 +0700 Subject: [PATCH 6/6] info about running tests after generation --- vedro_replay/generator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/vedro_replay/generator.py b/vedro_replay/generator.py index e14a4b9..34ab350 100644 --- a/vedro_replay/generator.py +++ b/vedro_replay/generator.py @@ -185,6 +185,10 @@ def generate(args: Any) -> None: try: getattr(MainGenerator(requests_dir=args.requests_dir, force=args.force, log=log), args.option)() + log.info("\nThe necessary files have been generated!\n" + "To run the tests, you need to specify two api url to which request will be sent." + "You need to set environment variables in any convenient way, for example:\n" + "export GOLDEN_API_URL=https://golden.app && export TESTING_API_URL=https://test.app && vedro run") except DirectoryWithRequestsNotFound as e: - log.critical(f'{e}. By default, the "requests" directory was expected. ' - 'Use --requests-dir to specify another') + log.critical(f"{e}. By default, the 'requests' directory was expected. " + "Use --requests-dir to specify another")