proxy-mock 2.10.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proxy_mock/__init__.py +10 -0
- proxy_mock/any_catcher.py +8 -0
- proxy_mock/api/__init__.py +0 -0
- proxy_mock/api/deps.py +5 -0
- proxy_mock/api/routes/__init__.py +0 -0
- proxy_mock/api/routes/configure.py +50 -0
- proxy_mock/api/routes/service.py +24 -0
- proxy_mock/api/routes/storage.py +51 -0
- proxy_mock/api/routes/traffic.py +79 -0
- proxy_mock/api/schemas.py +10 -0
- proxy_mock/app/__init__.py +3 -0
- proxy_mock/app/factory.py +97 -0
- proxy_mock/app/state.py +0 -0
- proxy_mock/client/__init__.py +4 -0
- proxy_mock/client/async_client.py +237 -0
- proxy_mock/client/client.py +174 -0
- proxy_mock/client/route.py +93 -0
- proxy_mock/client/service_endpoints.py +14 -0
- proxy_mock/core/__init__.py +0 -0
- proxy_mock/core/logging.py +16 -0
- proxy_mock/core/serializers.py +32 -0
- proxy_mock/core/settings.py +50 -0
- proxy_mock/core/time.py +6 -0
- proxy_mock/domain/__init__.py +20 -0
- proxy_mock/domain/constants.py +16 -0
- proxy_mock/domain/models.py +94 -0
- proxy_mock/repositories/__init__.py +0 -0
- proxy_mock/repositories/mock_storage.py +76 -0
- proxy_mock/repositories/traffic_store.py +69 -0
- proxy_mock/services/__init__.py +0 -0
- proxy_mock/services/cache_service.py +46 -0
- proxy_mock/services/mock_service.py +102 -0
- proxy_mock/services/proxy_service.py +104 -0
- proxy_mock/services/request_parser.py +30 -0
- proxy_mock/services/response_factory.py +25 -0
- proxy_mock/services/rule_engine.py +84 -0
- proxy_mock/services/traffic_service.py +26 -0
- proxy_mock/utils.py +145 -0
- proxy_mock-2.10.1.dist-info/METADATA +352 -0
- proxy_mock-2.10.1.dist-info/RECORD +42 -0
- proxy_mock-2.10.1.dist-info/WHEEL +4 -0
- proxy_mock-2.10.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from http import HTTPMethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import msgpack
|
|
5
|
+
from yarl import URL
|
|
6
|
+
|
|
7
|
+
from proxy_mock.client.route import Route
|
|
8
|
+
from proxy_mock.client.service_endpoints import Endpoints
|
|
9
|
+
|
|
10
|
+
CONFIGURE_CONTENT_TYPE = "application/octet-stream"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _build_traffic_settings_payload(record_unknown_traffic: bool | None, max_items: int | None) -> dict:
|
|
14
|
+
"""The endpoint accepts a partial update, so fields that were not supplied are left out."""
|
|
15
|
+
payload = {}
|
|
16
|
+
if record_unknown_traffic is not None:
|
|
17
|
+
payload["record_unknown_traffic"] = record_unknown_traffic
|
|
18
|
+
if max_items is not None:
|
|
19
|
+
payload["max_items"] = max_items
|
|
20
|
+
return payload
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProxyMock(Route):
|
|
24
|
+
def get_proxy_mock(self):
|
|
25
|
+
return super().execute_request_and_get_response_body(HTTPMethod.GET, Endpoints.PROXY_MOCK)
|
|
26
|
+
|
|
27
|
+
def _build_mock_request_data(
|
|
28
|
+
self,
|
|
29
|
+
path: str,
|
|
30
|
+
body: Any = None,
|
|
31
|
+
headers: dict | None = None,
|
|
32
|
+
status_code: int | None = None,
|
|
33
|
+
extra_info: dict | None = None,
|
|
34
|
+
proxy_host: str | None = None,
|
|
35
|
+
timeout: float | None = None,
|
|
36
|
+
rules: list[dict] | None = None,
|
|
37
|
+
methods: list[str] | None = None,
|
|
38
|
+
cache_time: int | None = None,
|
|
39
|
+
include_body_if_none: bool = True,
|
|
40
|
+
**kwargs,
|
|
41
|
+
) -> dict:
|
|
42
|
+
mock_data = {}
|
|
43
|
+
if include_body_if_none or body is not None:
|
|
44
|
+
mock_data["body"] = body
|
|
45
|
+
if status_code is not None:
|
|
46
|
+
mock_data["status_code"] = status_code
|
|
47
|
+
if headers is not None:
|
|
48
|
+
mock_data["headers"] = headers
|
|
49
|
+
|
|
50
|
+
request_data = {"path": path}
|
|
51
|
+
if methods is not None:
|
|
52
|
+
request_data["methods"] = methods
|
|
53
|
+
if mock_data:
|
|
54
|
+
request_data["mock_data"] = mock_data
|
|
55
|
+
if extra_info is not None:
|
|
56
|
+
request_data["extra_info"] = extra_info
|
|
57
|
+
if proxy_host is not None:
|
|
58
|
+
request_data["proxy_host"] = proxy_host
|
|
59
|
+
if timeout is not None:
|
|
60
|
+
request_data["timeout"] = timeout
|
|
61
|
+
if rules is not None:
|
|
62
|
+
request_data["rules"] = rules
|
|
63
|
+
if cache_time is not None:
|
|
64
|
+
request_data["cache_time"] = cache_time
|
|
65
|
+
|
|
66
|
+
request_data.update(kwargs)
|
|
67
|
+
return request_data
|
|
68
|
+
|
|
69
|
+
def _send_configure(self, method: HTTPMethod, payload: dict):
|
|
70
|
+
return super().execute_request_and_get_response_body(
|
|
71
|
+
method=method,
|
|
72
|
+
route=Endpoints.CONFIGURE_MOCK,
|
|
73
|
+
data=msgpack.packb(payload),
|
|
74
|
+
headers={"Content-Type": CONFIGURE_CONTENT_TYPE},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def configure_mock(
|
|
78
|
+
self,
|
|
79
|
+
path: str,
|
|
80
|
+
body: Any = None,
|
|
81
|
+
headers: dict | None = None,
|
|
82
|
+
status_code: int | None = None,
|
|
83
|
+
extra_info: dict | None = None,
|
|
84
|
+
proxy_host: str | None = None,
|
|
85
|
+
timeout: float | None = None,
|
|
86
|
+
rules: list[dict] | None = None,
|
|
87
|
+
methods: list[str] | None = None,
|
|
88
|
+
cache_time: int | None = None,
|
|
89
|
+
**kwargs,
|
|
90
|
+
):
|
|
91
|
+
payload = self._build_mock_request_data(
|
|
92
|
+
path=path,
|
|
93
|
+
body=body,
|
|
94
|
+
headers=headers,
|
|
95
|
+
status_code=status_code,
|
|
96
|
+
extra_info=extra_info,
|
|
97
|
+
proxy_host=proxy_host,
|
|
98
|
+
timeout=timeout,
|
|
99
|
+
rules=rules,
|
|
100
|
+
methods=methods,
|
|
101
|
+
cache_time=cache_time,
|
|
102
|
+
include_body_if_none=True, # for configure the body may explicitly be None
|
|
103
|
+
**kwargs,
|
|
104
|
+
)
|
|
105
|
+
return self._send_configure(HTTPMethod.POST, payload)
|
|
106
|
+
|
|
107
|
+
def patch_mock(
|
|
108
|
+
self,
|
|
109
|
+
path: str,
|
|
110
|
+
body: Any = None,
|
|
111
|
+
headers: dict | None = None,
|
|
112
|
+
status_code: int | None = None,
|
|
113
|
+
extra_info: dict | None = None,
|
|
114
|
+
proxy_host: str | None = None,
|
|
115
|
+
timeout: float | None = None,
|
|
116
|
+
rules: list[dict] | None = None,
|
|
117
|
+
methods: list[str] | None = None,
|
|
118
|
+
cache_time: int | None = None,
|
|
119
|
+
**kwargs,
|
|
120
|
+
):
|
|
121
|
+
payload = self._build_mock_request_data(
|
|
122
|
+
path=path,
|
|
123
|
+
body=body,
|
|
124
|
+
headers=headers,
|
|
125
|
+
status_code=status_code,
|
|
126
|
+
extra_info=extra_info,
|
|
127
|
+
proxy_host=proxy_host,
|
|
128
|
+
timeout=timeout,
|
|
129
|
+
rules=rules,
|
|
130
|
+
methods=methods,
|
|
131
|
+
cache_time=cache_time,
|
|
132
|
+
include_body_if_none=False, # for patch we do not send a body unless one is given
|
|
133
|
+
**kwargs,
|
|
134
|
+
)
|
|
135
|
+
return self._send_configure(HTTPMethod.PATCH, payload)
|
|
136
|
+
|
|
137
|
+
def get_traffic(self, path: str | None = None, method: str | None = None, limit: int | None = None):
|
|
138
|
+
query_params = {
|
|
139
|
+
**({"path": path} if path else {}),
|
|
140
|
+
**({"method": method} if method else {}),
|
|
141
|
+
**({"limit": limit} if limit is not None else {}),
|
|
142
|
+
}
|
|
143
|
+
full_path = URL().with_path(Endpoints.TRAFFIC).with_query(query_params).human_repr()
|
|
144
|
+
return super().execute_request_and_get_response_body(HTTPMethod.GET, full_path)
|
|
145
|
+
|
|
146
|
+
def get_storage(self, path: str | None = None):
|
|
147
|
+
query_params = {**({"path": path} if path else {})}
|
|
148
|
+
full_path = URL().with_path(Endpoints.STORAGE).with_query(query_params).human_repr()
|
|
149
|
+
return super().execute_request_and_get_response_body(HTTPMethod.GET, full_path)
|
|
150
|
+
|
|
151
|
+
def clean_storage(self, path: str | None = None):
|
|
152
|
+
query_params = {**({"path": path} if path else {})}
|
|
153
|
+
full_path = URL().with_path(Endpoints.STORAGE_CLEAN).with_query(query_params).human_repr()
|
|
154
|
+
return super().execute_request_and_get_response_body(HTTPMethod.POST, full_path)
|
|
155
|
+
|
|
156
|
+
def delete_mock(self, path: str):
|
|
157
|
+
full_path = URL().with_path(Endpoints.STORAGE).with_query({"path": path}).human_repr()
|
|
158
|
+
return super().execute_request_and_get_response_body(HTTPMethod.DELETE, full_path)
|
|
159
|
+
|
|
160
|
+
def clean_traffic(self):
|
|
161
|
+
return super().execute_request_and_get_response_body(HTTPMethod.POST, Endpoints.TRAFFIC_CLEAN)
|
|
162
|
+
|
|
163
|
+
def get_traffic_settings(self):
|
|
164
|
+
return super().execute_request_and_get_response_body(HTTPMethod.GET, Endpoints.TRAFFIC_SETTINGS)
|
|
165
|
+
|
|
166
|
+
def set_traffic_settings(self, record_unknown_traffic: bool | None = None, max_items: int | None = None):
|
|
167
|
+
return super().execute_request_and_get_response_body(
|
|
168
|
+
HTTPMethod.POST,
|
|
169
|
+
Endpoints.TRAFFIC_SETTINGS,
|
|
170
|
+
json=_build_traffic_settings_payload(record_unknown_traffic, max_items),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def clean_cache(self):
|
|
174
|
+
return super().execute_request_and_get_response_body(HTTPMethod.POST, Endpoints.CACHE_CLEAN)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ProxyMockRequestError(Exception):
|
|
8
|
+
"""Error while performing a request to the proxy-mock server."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProxyMockResponseError(Exception):
|
|
12
|
+
"""HTTP error returned by the proxy-mock server."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Route:
|
|
16
|
+
def __init__(self, host: str, timeout: float = 10.0) -> None:
|
|
17
|
+
self.host = host.rstrip("/")
|
|
18
|
+
self.timeout = timeout
|
|
19
|
+
self.session = requests.Session()
|
|
20
|
+
|
|
21
|
+
def _build_url(self, command: str) -> str:
|
|
22
|
+
command = command.strip("/")
|
|
23
|
+
return f"{self.host}/{command}"
|
|
24
|
+
|
|
25
|
+
def _request(
|
|
26
|
+
self,
|
|
27
|
+
method: str,
|
|
28
|
+
url: str,
|
|
29
|
+
params: dict[str, Any] | None = None,
|
|
30
|
+
data: bytes | str | None = None,
|
|
31
|
+
json_data: dict[str, Any] | None = None,
|
|
32
|
+
headers: dict[str, str] | None = None,
|
|
33
|
+
**kwargs,
|
|
34
|
+
) -> requests.Response:
|
|
35
|
+
try:
|
|
36
|
+
return self.session.request(
|
|
37
|
+
method=method,
|
|
38
|
+
url=url,
|
|
39
|
+
params=params,
|
|
40
|
+
data=data,
|
|
41
|
+
json=json_data,
|
|
42
|
+
headers=headers,
|
|
43
|
+
timeout=kwargs.pop("timeout", self.timeout),
|
|
44
|
+
**kwargs,
|
|
45
|
+
)
|
|
46
|
+
except (requests.ConnectionError, requests.Timeout) as err:
|
|
47
|
+
raise ProxyMockRequestError(f"Proxy-mock request error:\n{err}") from err
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _parse_response_body(response: requests.Response) -> dict | str | list | bytes | None:
|
|
51
|
+
if not response.content:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
content_type = response.headers.get("Content-Type", "")
|
|
55
|
+
try:
|
|
56
|
+
if "json" in content_type:
|
|
57
|
+
return response.json()
|
|
58
|
+
if "text" in content_type:
|
|
59
|
+
return response.text
|
|
60
|
+
return response.content
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
return response.text
|
|
63
|
+
|
|
64
|
+
def execute_request(self, method: str, route: str, raise_for_status: bool = False, **kwargs) -> requests.Response:
|
|
65
|
+
url = self._build_url(route)
|
|
66
|
+
|
|
67
|
+
# Normalise the json argument: kwargs["json"] is accepted but passed to _request as json_data
|
|
68
|
+
json_payload = kwargs.pop("json", None)
|
|
69
|
+
|
|
70
|
+
response = self._request(method=method, url=url, json_data=json_payload, **kwargs)
|
|
71
|
+
|
|
72
|
+
if raise_for_status and response.status_code >= 400:
|
|
73
|
+
raise ProxyMockResponseError(f"Proxy-mock returned HTTP {response.status_code}: {response.text}")
|
|
74
|
+
|
|
75
|
+
return response
|
|
76
|
+
|
|
77
|
+
def execute_request_and_get_response_body(
|
|
78
|
+
self,
|
|
79
|
+
method: str,
|
|
80
|
+
route: str,
|
|
81
|
+
**kwargs,
|
|
82
|
+
) -> dict | str | list | bytes | None:
|
|
83
|
+
response = self.execute_request(method=method, route=route, **kwargs)
|
|
84
|
+
return self._parse_response_body(response)
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
self.session.close()
|
|
88
|
+
|
|
89
|
+
def __enter__(self):
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def __exit__(self, exc_type, exc, tb):
|
|
93
|
+
self.close()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Routes of the proxy-mock API."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Endpoints(StrEnum):
|
|
7
|
+
PROXY_MOCK = "/proxy_mock"
|
|
8
|
+
CONFIGURE_MOCK = "/configure_mock"
|
|
9
|
+
STORAGE_CLEAN = "/storage/clean"
|
|
10
|
+
TRAFFIC_CLEAN = "/traffic/clean"
|
|
11
|
+
CACHE_CLEAN = "/cache/clean"
|
|
12
|
+
TRAFFIC = "/traffic"
|
|
13
|
+
TRAFFIC_SETTINGS = "/traffic/settings"
|
|
14
|
+
STORAGE = "/storage"
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def configure_logger():
|
|
5
|
+
uvicorn_logger = logging.getLogger("uvicorn")
|
|
6
|
+
logger = uvicorn_logger.getChild("proxy_mock")
|
|
7
|
+
|
|
8
|
+
formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s")
|
|
9
|
+
for handler in uvicorn_logger.handlers:
|
|
10
|
+
handler.setFormatter(formatter)
|
|
11
|
+
|
|
12
|
+
logger.setLevel(logging.DEBUG)
|
|
13
|
+
return logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
app_logger = configure_logger()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def formatted_data(data: str | bytes):
|
|
6
|
+
if not data:
|
|
7
|
+
return None
|
|
8
|
+
|
|
9
|
+
if isinstance(data, str):
|
|
10
|
+
try:
|
|
11
|
+
return json.loads(data)
|
|
12
|
+
except json.JSONDecodeError:
|
|
13
|
+
return data
|
|
14
|
+
|
|
15
|
+
if isinstance(data, bytes):
|
|
16
|
+
try:
|
|
17
|
+
return json.loads(data)
|
|
18
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
19
|
+
# latin-1 decodes any byte sequence, so no further fallback is needed.
|
|
20
|
+
return data.decode("latin-1")
|
|
21
|
+
|
|
22
|
+
return data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def convert_bytes_to_str(data: Any):
|
|
26
|
+
if isinstance(data, dict):
|
|
27
|
+
return {key: convert_bytes_to_str(value) for key, value in data.items()}
|
|
28
|
+
if isinstance(data, list):
|
|
29
|
+
return [convert_bytes_to_str(item) for item in data]
|
|
30
|
+
if isinstance(data, bytes):
|
|
31
|
+
return formatted_data(data)
|
|
32
|
+
return data
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
PACKAGE_NAME = "proxy_mock"
|
|
6
|
+
|
|
7
|
+
RECORD_UNKNOWN_TRAFFIC_ENV = "PROXY_MOCK_RECORD_UNKNOWN_TRAFFIC"
|
|
8
|
+
DEFAULT_RECORD_UNKNOWN_TRAFFIC = True
|
|
9
|
+
|
|
10
|
+
_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
11
|
+
_FALSE_VALUES = frozenset({"0", "false", "no", "off"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def record_unknown_traffic_default() -> bool:
|
|
15
|
+
"""Whether to record requests that matched no mock (the 404 response) in traffic.
|
|
16
|
+
|
|
17
|
+
An unrecognised value falls back to the default, so a typo cannot silently change behaviour.
|
|
18
|
+
"""
|
|
19
|
+
raw = os.getenv(RECORD_UNKNOWN_TRAFFIC_ENV, "").strip().lower()
|
|
20
|
+
if raw in _TRUE_VALUES:
|
|
21
|
+
return True
|
|
22
|
+
if raw in _FALSE_VALUES:
|
|
23
|
+
return False
|
|
24
|
+
return DEFAULT_RECORD_UNKNOWN_TRAFFIC
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_version_from_pyproject(logger) -> str:
|
|
28
|
+
# Dev checkout or Docker image: the working directory holds proxy_mock's own pyproject.toml.
|
|
29
|
+
# We check the project name so that another project's pyproject.toml is not picked up
|
|
30
|
+
# when proxy_mock is installed as a library.
|
|
31
|
+
try:
|
|
32
|
+
with open("pyproject.toml", "rb") as file:
|
|
33
|
+
pyproject_data = tomllib.load(file)
|
|
34
|
+
project = pyproject_data.get("project") or {}
|
|
35
|
+
if project.get("name") == PACKAGE_NAME:
|
|
36
|
+
version = project["version"]
|
|
37
|
+
logger.info(f"Proxy-Mock version is {version}")
|
|
38
|
+
return version
|
|
39
|
+
except (OSError, tomllib.TOMLDecodeError, KeyError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
# Installed as a package: take the version from the distribution metadata
|
|
43
|
+
try:
|
|
44
|
+
version = metadata.version(PACKAGE_NAME)
|
|
45
|
+
except metadata.PackageNotFoundError:
|
|
46
|
+
logger.warning("Could not determine the proxy_mock version")
|
|
47
|
+
return "unknown"
|
|
48
|
+
|
|
49
|
+
logger.info(f"Proxy-Mock version is {version}")
|
|
50
|
+
return version
|
proxy_mock/core/time.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from proxy_mock.domain.constants import ConfigureContentTypes, ParseError
|
|
2
|
+
from proxy_mock.domain.models import (
|
|
3
|
+
ConfigureMockRequestSchema,
|
|
4
|
+
InputRequestSchema,
|
|
5
|
+
MockDataSchema,
|
|
6
|
+
MockPathSchema,
|
|
7
|
+
MockRulesSchema,
|
|
8
|
+
RulesInputDataSchema,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"MockDataSchema",
|
|
13
|
+
"RulesInputDataSchema",
|
|
14
|
+
"MockRulesSchema",
|
|
15
|
+
"MockPathSchema",
|
|
16
|
+
"ConfigureMockRequestSchema",
|
|
17
|
+
"InputRequestSchema",
|
|
18
|
+
"ConfigureContentTypes",
|
|
19
|
+
"ParseError",
|
|
20
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ConfigureContentTypes(str, Enum):
|
|
5
|
+
JSON = "application/json"
|
|
6
|
+
BINARY = "application/octet-stream"
|
|
7
|
+
|
|
8
|
+
@classmethod
|
|
9
|
+
def array(cls) -> list[str]:
|
|
10
|
+
return [item.value for item in cls]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ParseError(Exception):
|
|
14
|
+
def __init__(self, detail: str, code: int):
|
|
15
|
+
self.detail = detail
|
|
16
|
+
self.code = code
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from http import HTTPMethod
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field, field_serializer, field_validator
|
|
5
|
+
from yarl import URL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MockDataSchema(BaseModel):
|
|
9
|
+
body: dict | str | bytes | list | None = Field(None)
|
|
10
|
+
status_code: int = Field(200)
|
|
11
|
+
headers: dict | None = Field(None)
|
|
12
|
+
|
|
13
|
+
@field_serializer("headers")
|
|
14
|
+
def serialize_headers(self, headers: dict) -> dict:
|
|
15
|
+
return {str(key): str(value) for key, value in headers.items()} if headers else {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RulesInputDataSchema(BaseModel):
|
|
19
|
+
methods: list[HTTPMethod] | None = Field(None)
|
|
20
|
+
body: dict | bytes | None = Field(None)
|
|
21
|
+
headers: dict | None = Field(None)
|
|
22
|
+
query: dict | None = Field(None)
|
|
23
|
+
proxy_host: str | None = Field(None)
|
|
24
|
+
timeout: float | None = Field(None)
|
|
25
|
+
|
|
26
|
+
@field_serializer("headers")
|
|
27
|
+
def serialize_headers(self, headers: dict) -> dict:
|
|
28
|
+
return {str(key).lower(): str(value) for key, value in headers.items()} if headers else {}
|
|
29
|
+
|
|
30
|
+
@field_serializer("query")
|
|
31
|
+
def serialize_query(self, query: dict) -> dict:
|
|
32
|
+
return {str(key): str(value) for key, value in query.items()} if query else {}
|
|
33
|
+
|
|
34
|
+
@field_validator("proxy_host")
|
|
35
|
+
def check_proxy_host(cls, value):
|
|
36
|
+
if value and not URL(value).is_absolute():
|
|
37
|
+
raise ValueError("Parameter 'proxy_host' must be an absolute URL")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MockRulesSchema(BaseModel):
|
|
42
|
+
input_data: RulesInputDataSchema | dict = Field(RulesInputDataSchema().model_dump())
|
|
43
|
+
output_data: MockDataSchema | dict = Field(MockDataSchema().model_dump())
|
|
44
|
+
extra_info: dict | None = Field(None)
|
|
45
|
+
priority: int = Field(0)
|
|
46
|
+
timestamp: float = Field(default_factory=lambda: time.time())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MockPathSchema(BaseModel):
|
|
50
|
+
methods: list[HTTPMethod] | None = Field(None)
|
|
51
|
+
mock_data: MockDataSchema | dict = Field(MockDataSchema().model_dump())
|
|
52
|
+
extra_info: dict | None = Field(None)
|
|
53
|
+
proxy_host: str | None = Field(None)
|
|
54
|
+
timeout: float | None = Field(None)
|
|
55
|
+
rules: list[MockRulesSchema] | None = Field(None)
|
|
56
|
+
cache_time: int | None = Field(None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConfigureMockRequestSchema(MockPathSchema):
|
|
60
|
+
path: str = Field(...)
|
|
61
|
+
|
|
62
|
+
@field_validator("proxy_host")
|
|
63
|
+
def check_proxy_host(cls, value):
|
|
64
|
+
if value and not URL(value).is_absolute():
|
|
65
|
+
raise ValueError("Parameter 'proxy_host' must be an absolute URL")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
@field_serializer("path")
|
|
69
|
+
def serialize_path(self, path: str):
|
|
70
|
+
path = path.split("?")[0]
|
|
71
|
+
path = path.replace(" ", "")
|
|
72
|
+
return f"/{path}" if path and path[0] != "/" else path
|
|
73
|
+
|
|
74
|
+
@field_serializer("methods")
|
|
75
|
+
def serialize_methods(self, methods: list[str]):
|
|
76
|
+
if not methods:
|
|
77
|
+
return [m.value for m in HTTPMethod]
|
|
78
|
+
return methods
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TrafficSettingsSchema(BaseModel):
|
|
82
|
+
"""Partial update: only the explicitly supplied fields are applied."""
|
|
83
|
+
|
|
84
|
+
record_unknown_traffic: bool | None = Field(None)
|
|
85
|
+
max_items: int | None = Field(None, gt=0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class InputRequestSchema(BaseModel):
|
|
89
|
+
request_method: str = Field(...)
|
|
90
|
+
request_body: dict | list | str | None = Field(None)
|
|
91
|
+
request_headers: dict | None = Field(None)
|
|
92
|
+
request_path: str | None = Field(None)
|
|
93
|
+
request_query: dict | None = Field(None)
|
|
94
|
+
extra_info: dict | None = Field(None)
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
|
|
6
|
+
from proxy_mock.domain.models import MockDataSchema, MockPathSchema
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _normalize_path(path: str) -> str:
|
|
10
|
+
path = (path or "").split("?")[0].strip()
|
|
11
|
+
if not path.startswith("/"):
|
|
12
|
+
path = "/" + path
|
|
13
|
+
if path != "/" and path.endswith("/"):
|
|
14
|
+
path = path.rstrip("/")
|
|
15
|
+
return path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MockStorage:
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._storage: dict[str, dict] = {}
|
|
21
|
+
self._lock = asyncio.Lock()
|
|
22
|
+
|
|
23
|
+
async def set_mock_data(
|
|
24
|
+
self,
|
|
25
|
+
path: str,
|
|
26
|
+
methods: list[str],
|
|
27
|
+
mock_data: dict,
|
|
28
|
+
extra_info: dict,
|
|
29
|
+
proxy_host: str | None,
|
|
30
|
+
timeout: float,
|
|
31
|
+
rules: list[dict],
|
|
32
|
+
cache_time: int,
|
|
33
|
+
) -> dict:
|
|
34
|
+
normalized_path = _normalize_path(path)
|
|
35
|
+
|
|
36
|
+
payload = MockPathSchema(
|
|
37
|
+
methods=methods,
|
|
38
|
+
mock_data=MockDataSchema(**mock_data).model_dump(),
|
|
39
|
+
extra_info=extra_info,
|
|
40
|
+
proxy_host=proxy_host,
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
rules=rules,
|
|
43
|
+
cache_time=cache_time,
|
|
44
|
+
).model_dump()
|
|
45
|
+
payload["path"] = normalized_path
|
|
46
|
+
|
|
47
|
+
async with self._lock:
|
|
48
|
+
self._storage[normalized_path] = payload
|
|
49
|
+
return deepcopy(payload)
|
|
50
|
+
|
|
51
|
+
async def get_mock_data(self, path: str) -> dict | None:
|
|
52
|
+
normalized_path = _normalize_path(path)
|
|
53
|
+
async with self._lock:
|
|
54
|
+
data = self._storage.get(normalized_path)
|
|
55
|
+
return deepcopy(data) if data else None
|
|
56
|
+
|
|
57
|
+
async def get_storage(self) -> dict:
|
|
58
|
+
async with self._lock:
|
|
59
|
+
return deepcopy(self._storage)
|
|
60
|
+
|
|
61
|
+
async def count(self) -> int:
|
|
62
|
+
async with self._lock:
|
|
63
|
+
return len(self._storage)
|
|
64
|
+
|
|
65
|
+
async def clean_storage(self) -> bool:
|
|
66
|
+
async with self._lock:
|
|
67
|
+
self._storage.clear()
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
async def delete_mock_data(self, path: str) -> bool:
|
|
71
|
+
normalized_path = _normalize_path(path)
|
|
72
|
+
async with self._lock:
|
|
73
|
+
return self._storage.pop(normalized_path, None) is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
mock_storage = MockStorage()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from collections import deque
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
DEFAULT_TRAFFIC_MAX = 1000
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _default_max_items() -> int:
|
|
11
|
+
raw = os.getenv("PROXY_MOCK_TRAFFIC_MAX", "").strip()
|
|
12
|
+
if raw.isdigit() and int(raw) > 0:
|
|
13
|
+
return int(raw)
|
|
14
|
+
return DEFAULT_TRAFFIC_MAX
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TrafficStore:
|
|
18
|
+
def __init__(self, max_items: int | None = None):
|
|
19
|
+
self._data = deque(maxlen=max_items or _default_max_items())
|
|
20
|
+
self._lock = asyncio.Lock()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def max_items(self) -> int:
|
|
24
|
+
return self._data.maxlen
|
|
25
|
+
|
|
26
|
+
async def set_max_items(self, value: int) -> None:
|
|
27
|
+
"""Change the storage limit. Excess records are dropped, the most recent ones are kept."""
|
|
28
|
+
async with self._lock:
|
|
29
|
+
if value != self._data.maxlen:
|
|
30
|
+
self._data = deque(self._data, maxlen=value)
|
|
31
|
+
|
|
32
|
+
async def count(self) -> int:
|
|
33
|
+
async with self._lock:
|
|
34
|
+
return len(self._data)
|
|
35
|
+
|
|
36
|
+
async def add(self, item: dict[str, Any]) -> None:
|
|
37
|
+
async with self._lock:
|
|
38
|
+
self._data.append(item)
|
|
39
|
+
|
|
40
|
+
async def patch_last(self, patch: dict[str, Any]) -> None:
|
|
41
|
+
async with self._lock:
|
|
42
|
+
if not self._data:
|
|
43
|
+
return
|
|
44
|
+
last = dict(self._data[-1])
|
|
45
|
+
last.update(patch)
|
|
46
|
+
self._data[-1] = last
|
|
47
|
+
|
|
48
|
+
async def list(
|
|
49
|
+
self,
|
|
50
|
+
path: str | None = None,
|
|
51
|
+
method: str | None = None,
|
|
52
|
+
limit: int | None = None,
|
|
53
|
+
) -> list[dict[str, Any]]:
|
|
54
|
+
async with self._lock:
|
|
55
|
+
items = list(self._data)
|
|
56
|
+
|
|
57
|
+
if path:
|
|
58
|
+
items = [x for x in items if x.get("request_path") == path]
|
|
59
|
+
if method:
|
|
60
|
+
method_u = method.upper()
|
|
61
|
+
items = [x for x in items if str(x.get("request_method", "")).upper() == method_u]
|
|
62
|
+
if limit is not None:
|
|
63
|
+
items = items[-limit:]
|
|
64
|
+
|
|
65
|
+
return deepcopy(items)
|
|
66
|
+
|
|
67
|
+
async def clear(self) -> None:
|
|
68
|
+
async with self._lock:
|
|
69
|
+
self._data.clear()
|
|
File without changes
|