mockstack 0.9.3__tar.gz → 0.10.0__tar.gz
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.
- {mockstack-0.9.3 → mockstack-0.10.0}/PKG-INFO +1 -1
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/constants.py +5 -3
- mockstack-0.10.0/mockstack/strategies/proxyrules.py +357 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/strategies/test_proxyrules.py +89 -4
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/PKG-INFO +1 -1
- mockstack-0.9.3/mockstack/strategies/proxyrules.py +0 -200
- {mockstack-0.9.3 → mockstack-0.10.0}/.dockerignore +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/.env.example +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/.github/workflows/ci.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/.github/workflows/publish-docs.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/.github/workflows/publish-to-dockerhub.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/.gitignore +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/.pre-commit-config.yaml +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/CODE_OF_CONDUCT.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/CONTRIBUTING.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/Dockerfile +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/LICENSE +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/README.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/SECURITY.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/docker-entrypoint.sh +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/docs/assets/favicon.ico +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/docs/assets/logo.png +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/docs/assets/mockstack.png +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/docs/configuration.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/docs/ollama.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/docs/strategies/base.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/docs/strategies/filefixtures.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/docs/strategies/proxyrules.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/filefixtures-with-templates/.env.example +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/filefixtures-with-templates/README.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2 +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/filefixtures-with-templates/templates/servicename2-api-v2-user.533ec889-7c68-45c8-b21e-4a7e455d1234.j2 +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/README.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/mockstack-langchain-example.ipynb +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/templates/ollama-openai-v1-chat-completions.j2 +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/templates/openai-v1-chat-completions.j2 +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/templates/openai-v1-deployments-gpt-4o-chat-completions.j2 +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/proxyrules-with-rules-file/.env.example +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/proxyrules-with-rules-file/README.md +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/examples/proxyrules-with-rules-file/rules.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mkdocs.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/config.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/display.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/exceptions.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/identifiers.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/intent.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/lifespan.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/llm/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/llm/ollama.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/main.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/middleware.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/routers/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/routers/catchall.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/routers/homepage.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/strategies/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/strategies/base.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/strategies/create_mixin.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/strategies/factory.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/strategies/filefixtures.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/telemetry.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/templating.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/conftest.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/fixtures/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/fixtures/proxyrules.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/fixtures/templates/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/llm/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/llm/test_ollama.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/routers/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/routers/test_catchall.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/routers/test_homepage.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/strategies/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/strategies/test_create_mixin.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/strategies/test_filefixtures.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_display.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_identifiers.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_intent.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_middleware.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_telemetry.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_templating.py +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/SOURCES.txt +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/dependency_links.txt +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/entry_points.txt +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/requires.txt +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/top_level.txt +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/pyproject.toml +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/setup.cfg +0 -0
- {mockstack-0.9.3 → mockstack-0.10.0}/uv.lock +0 -0
|
@@ -7,6 +7,11 @@ ENV_PREFIX = "mockstack__"
|
|
|
7
7
|
ENV_FILE = ".env"
|
|
8
8
|
ENV_NESTED_DELIMITER = "__"
|
|
9
9
|
|
|
10
|
+
# Template files are identified by a file:// URL prefix.
|
|
11
|
+
PROXYRULES_FILE_TEMPLATE_PREFIX = "file:///"
|
|
12
|
+
|
|
13
|
+
SENSITIVE_HEADERS = ["authorization", "cookie", "set-cookie"]
|
|
14
|
+
|
|
10
15
|
|
|
11
16
|
class ProxyRulesRedirectVia(StrEnum):
|
|
12
17
|
"""The type of redirect to use for the proxy rules strategy.
|
|
@@ -20,6 +25,3 @@ class ProxyRulesRedirectVia(StrEnum):
|
|
|
20
25
|
HTTP_TEMPORARY_REDIRECT = "http_307_temporary"
|
|
21
26
|
HTTP_PERMANENT_REDIRECT = "http_301_permanent"
|
|
22
27
|
REVERSE_PROXY = "reverse_proxy"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
SENSITIVE_HEADERS = ["authorization", "cookie", "set-cookie"]
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Strategy for using proxy rules."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Self
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import yaml
|
|
14
|
+
from fastapi import Request, Response, status
|
|
15
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
16
|
+
from jinja2 import Environment
|
|
17
|
+
from starlette.datastructures import Headers
|
|
18
|
+
|
|
19
|
+
from mockstack.config import Settings
|
|
20
|
+
from mockstack.constants import PROXYRULES_FILE_TEMPLATE_PREFIX, ProxyRulesRedirectVia
|
|
21
|
+
from mockstack.intent import looks_like_a_create
|
|
22
|
+
from mockstack.strategies.base import BaseStrategy
|
|
23
|
+
from mockstack.strategies.create_mixin import CreateMixin
|
|
24
|
+
from mockstack.templating import (
|
|
25
|
+
templates_env_provider,
|
|
26
|
+
parse_template_name_segments_and_identifiers,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RuleResult(ABC):
|
|
31
|
+
"""Base class for rule application results."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def get_result_type(self) -> str:
|
|
35
|
+
"""Return the type of result."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class URLRuleResult(RuleResult):
|
|
41
|
+
"""Result for URL-based rules."""
|
|
42
|
+
|
|
43
|
+
url: str
|
|
44
|
+
|
|
45
|
+
def get_result_type(self) -> str:
|
|
46
|
+
return "url"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class TemplateRuleResult(RuleResult):
|
|
51
|
+
"""Result for template-based rules."""
|
|
52
|
+
|
|
53
|
+
template_path: str
|
|
54
|
+
template_context: dict
|
|
55
|
+
|
|
56
|
+
def get_result_type(self) -> str:
|
|
57
|
+
return "template"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Rule:
|
|
61
|
+
"""A rule for the proxy rules strategy."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
pattern: str,
|
|
66
|
+
replacement: str,
|
|
67
|
+
method: str | None = None,
|
|
68
|
+
name: str | None = None,
|
|
69
|
+
):
|
|
70
|
+
self.pattern = pattern
|
|
71
|
+
self.replacement = replacement
|
|
72
|
+
self.method = method
|
|
73
|
+
self.name = name
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_dict(cls, data: dict[str, str]) -> Self:
|
|
77
|
+
return cls(
|
|
78
|
+
pattern=data["pattern"],
|
|
79
|
+
replacement=data["replacement"],
|
|
80
|
+
method=data.get("method", None),
|
|
81
|
+
name=data.get("name", None),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def matches(self, request: Request) -> bool:
|
|
85
|
+
"""Check if the rule matches the request."""
|
|
86
|
+
if self.method is not None and request.method.lower() != self.method.lower():
|
|
87
|
+
# if rule is limited to a specific HTTP method, validate first.
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
return re.match(self.pattern, request.url.path) is not None
|
|
91
|
+
|
|
92
|
+
def apply(self, request: Request) -> RuleResult:
|
|
93
|
+
"""Apply the rule to the request."""
|
|
94
|
+
path = request.url.path
|
|
95
|
+
result = self._url_for(path)
|
|
96
|
+
|
|
97
|
+
# Check if the replacement is a file template
|
|
98
|
+
if result.startswith(PROXYRULES_FILE_TEMPLATE_PREFIX):
|
|
99
|
+
# Extract the file path from the file:/// URL
|
|
100
|
+
file_path = result[len(PROXYRULES_FILE_TEMPLATE_PREFIX) - 1 :]
|
|
101
|
+
|
|
102
|
+
# Create template context from request
|
|
103
|
+
template_context = self._create_template_context(request)
|
|
104
|
+
|
|
105
|
+
return TemplateRuleResult(
|
|
106
|
+
template_path=file_path,
|
|
107
|
+
template_context=template_context,
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
# Regular URL replacement
|
|
111
|
+
return URLRuleResult(url=result)
|
|
112
|
+
|
|
113
|
+
def _url_for(self, path: str) -> str:
|
|
114
|
+
return re.sub(self.pattern, self.replacement, path)
|
|
115
|
+
|
|
116
|
+
def _create_template_context(self, request: Request) -> dict:
|
|
117
|
+
"""Create template context from the request, using the same logic as templating.py."""
|
|
118
|
+
path = request.url.path
|
|
119
|
+
name_segments, identifiers = parse_template_name_segments_and_identifiers(
|
|
120
|
+
path, default_identifier_key="id"
|
|
121
|
+
)
|
|
122
|
+
return {
|
|
123
|
+
"query": dict(request.query_params),
|
|
124
|
+
"headers": dict(request.headers),
|
|
125
|
+
"path": request.url.path,
|
|
126
|
+
"method": request.method,
|
|
127
|
+
**identifiers,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ProxyRulesStrategy(BaseStrategy, CreateMixin):
|
|
132
|
+
"""Strategy for using proxy rules."""
|
|
133
|
+
|
|
134
|
+
logger = logging.getLogger("ProxyRulesStrategy")
|
|
135
|
+
|
|
136
|
+
def __init__(self, settings: Settings, *args, **kwargs):
|
|
137
|
+
super().__init__(settings, *args, **kwargs)
|
|
138
|
+
self.created_resource_metadata = settings.created_resource_metadata
|
|
139
|
+
self.missing_resource_fields = settings.missing_resource_fields
|
|
140
|
+
self.redirect_via = settings.proxyrules_redirect_via
|
|
141
|
+
self.reverse_proxy_timeout = settings.proxyrules_reverse_proxy_timeout
|
|
142
|
+
self.rules_filename = settings.proxyrules_rules_filename
|
|
143
|
+
self.simulate_create_on_missing = settings.proxyrules_simulate_create_on_missing
|
|
144
|
+
self.verify_ssl_certificates = settings.proxyrules_verify_ssl_certificates
|
|
145
|
+
|
|
146
|
+
def __str__(self) -> str:
|
|
147
|
+
return (
|
|
148
|
+
f"[medium_purple]proxyrules[/medium_purple]\n "
|
|
149
|
+
f"rules_filename: {self.rules_filename}.\n "
|
|
150
|
+
f"redirect_via: [medium_purple]{self.redirect_via}[/medium_purple].\n "
|
|
151
|
+
f"simulate_create_on_missing: {self.simulate_create_on_missing}.\n "
|
|
152
|
+
f"reverse_proxy_timeout: {self.reverse_proxy_timeout}\n "
|
|
153
|
+
f"verify_ssl_certificates: {self.verify_ssl_certificates}\n "
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@cached_property
|
|
157
|
+
def env(self) -> Environment:
|
|
158
|
+
"""Jinja2 environment for the proxy rules strategy."""
|
|
159
|
+
return templates_env_provider()
|
|
160
|
+
|
|
161
|
+
@cached_property
|
|
162
|
+
def rules(self) -> list[Rule]:
|
|
163
|
+
return self.load_rules()
|
|
164
|
+
|
|
165
|
+
def load_rules(self) -> list[Rule]:
|
|
166
|
+
if self.rules_filename is None:
|
|
167
|
+
raise ValueError("rules_filename is not set")
|
|
168
|
+
|
|
169
|
+
with open(self.rules_filename, "r") as file:
|
|
170
|
+
data = yaml.safe_load(file)
|
|
171
|
+
return [Rule.from_dict(rule) for rule in data["rules"]]
|
|
172
|
+
|
|
173
|
+
def rule_for(self, request: Request) -> Rule | None:
|
|
174
|
+
try:
|
|
175
|
+
return next(rule for rule in self.rules if rule.matches(request))
|
|
176
|
+
except StopIteration:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
async def apply(self, request: Request) -> Response:
|
|
180
|
+
rule = self.rule_for(request)
|
|
181
|
+
if rule is None:
|
|
182
|
+
return await self.handle_missing_rule(request)
|
|
183
|
+
|
|
184
|
+
result = rule.apply(request)
|
|
185
|
+
self.logger.info(f"[rule:{rule.name}] Result: {result}")
|
|
186
|
+
|
|
187
|
+
# Handle template results
|
|
188
|
+
if isinstance(result, TemplateRuleResult):
|
|
189
|
+
return await self.handle_template_result(request, rule, result)
|
|
190
|
+
elif isinstance(result, URLRuleResult):
|
|
191
|
+
return await self.handle_url_result(request, rule, result)
|
|
192
|
+
else:
|
|
193
|
+
raise ValueError(f"Unknown result type: {type(result)}")
|
|
194
|
+
|
|
195
|
+
async def handle_missing_rule(self, request: Request) -> Response:
|
|
196
|
+
"""Handle a missing rule."""
|
|
197
|
+
self.logger.warning(
|
|
198
|
+
f"No rule found for request: {request.method} {request.url.path}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if self.simulate_create_on_missing and looks_like_a_create(request):
|
|
202
|
+
self.logger.info(
|
|
203
|
+
f"Simulating resource creation for missing rule for {request.method} {request.url.path}"
|
|
204
|
+
)
|
|
205
|
+
return await self._create(
|
|
206
|
+
request,
|
|
207
|
+
env=self.env,
|
|
208
|
+
created_resource_metadata=self.created_resource_metadata,
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
return JSONResponse(
|
|
212
|
+
content=self.missing_resource_fields,
|
|
213
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def handle_url_result(
|
|
217
|
+
self, request: Request, rule: Rule, result: URLRuleResult
|
|
218
|
+
) -> Response:
|
|
219
|
+
"""Handle URL results by redirecting to the target URL."""
|
|
220
|
+
self.update_opentelemetry(request, rule, result.url)
|
|
221
|
+
|
|
222
|
+
match self.redirect_via:
|
|
223
|
+
case ProxyRulesRedirectVia.HTTP_TEMPORARY_REDIRECT:
|
|
224
|
+
return RedirectResponse(
|
|
225
|
+
url=result.url, status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
case ProxyRulesRedirectVia.HTTP_PERMANENT_REDIRECT:
|
|
229
|
+
return RedirectResponse(
|
|
230
|
+
url=result.url, status_code=status.HTTP_301_MOVED_PERMANENTLY
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
case ProxyRulesRedirectVia.REVERSE_PROXY:
|
|
234
|
+
response = await self.reverse_proxy(request, result.url)
|
|
235
|
+
return response
|
|
236
|
+
|
|
237
|
+
case _:
|
|
238
|
+
raise ValueError(f"Invalid redirect via value: {self.redirect_via=}")
|
|
239
|
+
|
|
240
|
+
async def handle_template_result(
|
|
241
|
+
self, request: Request, rule: Rule, result: TemplateRuleResult
|
|
242
|
+
) -> Response:
|
|
243
|
+
"""Handle template results by rendering the template file."""
|
|
244
|
+
template_path = Path(result.template_path)
|
|
245
|
+
|
|
246
|
+
if not template_path.exists():
|
|
247
|
+
self.logger.error(f"Template file not found: {template_path}")
|
|
248
|
+
return JSONResponse(
|
|
249
|
+
content={"error": f"Template file not found: {template_path}"},
|
|
250
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
# Read the template file content
|
|
255
|
+
with open(template_path, "r") as f:
|
|
256
|
+
template_content = f.read()
|
|
257
|
+
|
|
258
|
+
# Create a template from the content
|
|
259
|
+
template = self.env.from_string(template_content)
|
|
260
|
+
|
|
261
|
+
# Render the template with context
|
|
262
|
+
rendered_content = template.render(**result.template_context)
|
|
263
|
+
|
|
264
|
+
# Determine content type based on file extension
|
|
265
|
+
content_type = self._get_content_type(template_path)
|
|
266
|
+
|
|
267
|
+
# Update opentelemetry with template info
|
|
268
|
+
self.update_opentelemetry_template(request, rule, result)
|
|
269
|
+
|
|
270
|
+
return Response(
|
|
271
|
+
content=rendered_content,
|
|
272
|
+
media_type=content_type,
|
|
273
|
+
status_code=status.HTTP_200_OK,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
self.logger.error(f"Error rendering template {template_path}: {e}")
|
|
278
|
+
return JSONResponse(
|
|
279
|
+
content={
|
|
280
|
+
"error": "An internal error occurred while rendering the template."
|
|
281
|
+
},
|
|
282
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
async def reverse_proxy(self, request: Request, url: str) -> Response:
|
|
286
|
+
"""Reverse proxy the request to the target URL."""
|
|
287
|
+
async with httpx.AsyncClient(
|
|
288
|
+
timeout=self.reverse_proxy_timeout, verify=self.verify_ssl_certificates
|
|
289
|
+
) as client:
|
|
290
|
+
request_content = await request.body()
|
|
291
|
+
request_headers = self.reverse_proxy_headers(request.headers, url=url)
|
|
292
|
+
req = client.build_request(
|
|
293
|
+
request.method,
|
|
294
|
+
url,
|
|
295
|
+
content=request_content,
|
|
296
|
+
headers=request_headers,
|
|
297
|
+
params=request.url.query,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
resp = await client.send(req, stream=False)
|
|
301
|
+
content = resp.read()
|
|
302
|
+
|
|
303
|
+
return Response(
|
|
304
|
+
content=content,
|
|
305
|
+
status_code=resp.status_code,
|
|
306
|
+
headers=resp.headers,
|
|
307
|
+
media_type=resp.headers.get("content-type"),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def reverse_proxy_headers(self, headers: Headers, url: str) -> Headers:
|
|
311
|
+
"""Mutate the request headers for the reverse proxy mode."""
|
|
312
|
+
_headers = headers.mutablecopy()
|
|
313
|
+
|
|
314
|
+
# When reverse proxying, we must alter the Host header to the target URL.
|
|
315
|
+
_headers["host"] = urlparse(url).netloc
|
|
316
|
+
|
|
317
|
+
return _headers
|
|
318
|
+
|
|
319
|
+
def _get_content_type(self, template_path: Path) -> str:
|
|
320
|
+
"""Determine content type based on file extension."""
|
|
321
|
+
suffix = template_path.suffix.lower()
|
|
322
|
+
content_types = {
|
|
323
|
+
".json": "application/json",
|
|
324
|
+
".xml": "application/xml",
|
|
325
|
+
".html": "text/html",
|
|
326
|
+
".txt": "text/plain",
|
|
327
|
+
".yaml": "application/x-yaml",
|
|
328
|
+
".yml": "application/x-yaml",
|
|
329
|
+
}
|
|
330
|
+
return content_types.get(suffix, "text/plain")
|
|
331
|
+
|
|
332
|
+
def update_opentelemetry_template(
|
|
333
|
+
self, request: Request, rule: Rule, result: TemplateRuleResult
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Update the opentelemetry span with template-specific details."""
|
|
336
|
+
span = request.state.span
|
|
337
|
+
if rule.name is not None:
|
|
338
|
+
span.set_attribute("mockstack.proxyrules.rule_name", rule.name)
|
|
339
|
+
if rule.method is not None:
|
|
340
|
+
span.set_attribute("mockstack.proxyrules.rule_method", rule.method)
|
|
341
|
+
|
|
342
|
+
span.set_attribute("mockstack.proxyrules.rule_pattern", rule.pattern)
|
|
343
|
+
span.set_attribute("mockstack.proxyrules.rule_replacement", rule.replacement)
|
|
344
|
+
span.set_attribute("mockstack.proxyrules.template_path", result.template_path)
|
|
345
|
+
span.set_attribute("mockstack.proxyrules.result_type", "template")
|
|
346
|
+
|
|
347
|
+
def update_opentelemetry(self, request: Request, rule: Rule, url: str) -> None:
|
|
348
|
+
"""Update the opentelemetry span with the proxy rules rule details."""
|
|
349
|
+
span = request.state.span
|
|
350
|
+
if rule.name is not None:
|
|
351
|
+
span.set_attribute("mockstack.proxyrules.rule_name", rule.name)
|
|
352
|
+
if rule.method is not None:
|
|
353
|
+
span.set_attribute("mockstack.proxyrules.rule_method", rule.method)
|
|
354
|
+
|
|
355
|
+
span.set_attribute("mockstack.proxyrules.rule_pattern", rule.pattern)
|
|
356
|
+
span.set_attribute("mockstack.proxyrules.rule_replacement", rule.replacement)
|
|
357
|
+
span.set_attribute("mockstack.proxyrules.rewritten_url", url)
|
|
@@ -9,7 +9,12 @@ from fastapi.responses import RedirectResponse
|
|
|
9
9
|
from starlette.datastructures import Headers
|
|
10
10
|
|
|
11
11
|
from mockstack.constants import ProxyRulesRedirectVia
|
|
12
|
-
from mockstack.strategies.proxyrules import
|
|
12
|
+
from mockstack.strategies.proxyrules import (
|
|
13
|
+
ProxyRulesStrategy,
|
|
14
|
+
Rule,
|
|
15
|
+
URLRuleResult,
|
|
16
|
+
TemplateRuleResult,
|
|
17
|
+
)
|
|
13
18
|
|
|
14
19
|
|
|
15
20
|
def test_rule_from_dict():
|
|
@@ -113,9 +118,35 @@ def test_rule_apply(pattern, replacement, path, expected_url):
|
|
|
113
118
|
"headers": [],
|
|
114
119
|
}
|
|
115
120
|
)
|
|
116
|
-
|
|
117
|
-
assert isinstance(
|
|
118
|
-
assert
|
|
121
|
+
result = rule.apply(request)
|
|
122
|
+
assert isinstance(result, URLRuleResult)
|
|
123
|
+
assert result.get_result_type() == "url"
|
|
124
|
+
assert result.url == expected_url
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_rule_apply_template():
|
|
128
|
+
"""Test the rule application logic for template files."""
|
|
129
|
+
rule = Rule(
|
|
130
|
+
pattern=r"/api/v1/projects/(\d+)",
|
|
131
|
+
replacement=r"file:///path/to/template.json",
|
|
132
|
+
)
|
|
133
|
+
request = Request(
|
|
134
|
+
scope={
|
|
135
|
+
"type": "http",
|
|
136
|
+
"method": "GET",
|
|
137
|
+
"path": "/api/v1/projects/1234",
|
|
138
|
+
"query_string": b"",
|
|
139
|
+
"headers": [],
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
result = rule.apply(request)
|
|
143
|
+
assert isinstance(result, TemplateRuleResult)
|
|
144
|
+
assert result.get_result_type() == "template"
|
|
145
|
+
assert result.template_path == "/path/to/template.json"
|
|
146
|
+
assert result.template_context is not None
|
|
147
|
+
# The context should contain the extracted project ID from the path
|
|
148
|
+
assert "projects" in result.template_context
|
|
149
|
+
assert result.template_context["projects"] == "1234"
|
|
119
150
|
|
|
120
151
|
|
|
121
152
|
def test_proxy_rules_strategy_load_rules(settings):
|
|
@@ -198,6 +229,60 @@ async def test_proxy_rules_strategy_apply_no_match(settings, span):
|
|
|
198
229
|
assert response.status_code == 404
|
|
199
230
|
|
|
200
231
|
|
|
232
|
+
@pytest.mark.asyncio
|
|
233
|
+
async def test_proxy_rules_strategy_apply_template(settings, span, tmp_path):
|
|
234
|
+
"""Test applying strategy with template rendering."""
|
|
235
|
+
# Create a template file
|
|
236
|
+
template_file = tmp_path / "template.json"
|
|
237
|
+
template_file.write_text(
|
|
238
|
+
'{"projects": "{{ projects }}", "name": "Project {{ projects }}"}'
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Create a rule that points to the template file
|
|
242
|
+
rule_data = {
|
|
243
|
+
"pattern": r"/api/v1/projects/(\d+)",
|
|
244
|
+
"replacement": f"file:///{template_file}",
|
|
245
|
+
"name": "test_template_rule",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Mock the rule_for method to return our test rule
|
|
249
|
+
strategy = ProxyRulesStrategy(settings)
|
|
250
|
+
test_rule = Rule.from_dict(rule_data)
|
|
251
|
+
|
|
252
|
+
with patch.object(strategy, "rule_for", return_value=test_rule):
|
|
253
|
+
request = Request(
|
|
254
|
+
scope={
|
|
255
|
+
"type": "http",
|
|
256
|
+
"method": "GET",
|
|
257
|
+
"path": "/api/v1/projects/1234",
|
|
258
|
+
"query_string": b"",
|
|
259
|
+
"headers": [],
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
request.state.span = span
|
|
263
|
+
response = await strategy.apply(request)
|
|
264
|
+
|
|
265
|
+
assert response.status_code == 200
|
|
266
|
+
assert response.media_type == "application/json"
|
|
267
|
+
assert response.body.decode() == '{"projects": "1234", "name": "Project 1234"}'
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_proxy_rules_strategy_get_content_type(settings):
|
|
271
|
+
"""Test content type detection based on file extension."""
|
|
272
|
+
strategy = ProxyRulesStrategy(settings)
|
|
273
|
+
|
|
274
|
+
from pathlib import Path
|
|
275
|
+
|
|
276
|
+
# Test various file extensions
|
|
277
|
+
assert strategy._get_content_type(Path("file.json")) == "application/json"
|
|
278
|
+
assert strategy._get_content_type(Path("file.xml")) == "application/xml"
|
|
279
|
+
assert strategy._get_content_type(Path("file.html")) == "text/html"
|
|
280
|
+
assert strategy._get_content_type(Path("file.txt")) == "text/plain"
|
|
281
|
+
assert strategy._get_content_type(Path("file.yaml")) == "application/x-yaml"
|
|
282
|
+
assert strategy._get_content_type(Path("file.yml")) == "application/x-yaml"
|
|
283
|
+
assert strategy._get_content_type(Path("file.unknown")) == "text/plain"
|
|
284
|
+
|
|
285
|
+
|
|
201
286
|
@pytest.mark.asyncio
|
|
202
287
|
@pytest.mark.skip(reason="TODO: Fix this test")
|
|
203
288
|
async def test_proxy_rules_strategy_apply_reverse_proxy(settings_reverse_proxy, span):
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
"""Strategy for using proxy rules."""
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
import re
|
|
5
|
-
from functools import cached_property
|
|
6
|
-
from typing import Self
|
|
7
|
-
from urllib.parse import urlparse
|
|
8
|
-
|
|
9
|
-
import httpx
|
|
10
|
-
import yaml
|
|
11
|
-
from fastapi import Request, Response, status
|
|
12
|
-
from fastapi.responses import JSONResponse, RedirectResponse
|
|
13
|
-
from jinja2 import Environment
|
|
14
|
-
from starlette.datastructures import Headers
|
|
15
|
-
|
|
16
|
-
from mockstack.config import Settings
|
|
17
|
-
from mockstack.constants import ProxyRulesRedirectVia
|
|
18
|
-
from mockstack.intent import looks_like_a_create
|
|
19
|
-
from mockstack.strategies.base import BaseStrategy
|
|
20
|
-
from mockstack.strategies.create_mixin import CreateMixin
|
|
21
|
-
from mockstack.templating import templates_env_provider
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class Rule:
|
|
25
|
-
"""A rule for the proxy rules strategy."""
|
|
26
|
-
|
|
27
|
-
def __init__(
|
|
28
|
-
self,
|
|
29
|
-
pattern: str,
|
|
30
|
-
replacement: str,
|
|
31
|
-
method: str | None = None,
|
|
32
|
-
name: str | None = None,
|
|
33
|
-
):
|
|
34
|
-
self.pattern = pattern
|
|
35
|
-
self.replacement = replacement
|
|
36
|
-
self.method = method
|
|
37
|
-
self.name = name
|
|
38
|
-
|
|
39
|
-
@classmethod
|
|
40
|
-
def from_dict(cls, data: dict[str, str]) -> Self:
|
|
41
|
-
return cls(
|
|
42
|
-
pattern=data["pattern"],
|
|
43
|
-
replacement=data["replacement"],
|
|
44
|
-
method=data.get("method", None),
|
|
45
|
-
name=data.get("name", None),
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
def matches(self, request: Request) -> bool:
|
|
49
|
-
"""Check if the rule matches the request."""
|
|
50
|
-
if self.method is not None and request.method.lower() != self.method.lower():
|
|
51
|
-
# if rule is limited to a specific HTTP method, validate first.
|
|
52
|
-
return False
|
|
53
|
-
|
|
54
|
-
return re.match(self.pattern, request.url.path) is not None
|
|
55
|
-
|
|
56
|
-
def apply(self, request: Request) -> str:
|
|
57
|
-
"""Apply the rule to the request."""
|
|
58
|
-
return self._url_for(request.url.path)
|
|
59
|
-
|
|
60
|
-
def _url_for(self, path: str) -> str:
|
|
61
|
-
return re.sub(self.pattern, self.replacement, path)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class ProxyRulesStrategy(BaseStrategy, CreateMixin):
|
|
65
|
-
"""Strategy for using proxy rules."""
|
|
66
|
-
|
|
67
|
-
logger = logging.getLogger("ProxyRulesStrategy")
|
|
68
|
-
|
|
69
|
-
def __init__(self, settings: Settings, *args, **kwargs):
|
|
70
|
-
super().__init__(settings, *args, **kwargs)
|
|
71
|
-
self.created_resource_metadata = settings.created_resource_metadata
|
|
72
|
-
self.missing_resource_fields = settings.missing_resource_fields
|
|
73
|
-
self.redirect_via = settings.proxyrules_redirect_via
|
|
74
|
-
self.reverse_proxy_timeout = settings.proxyrules_reverse_proxy_timeout
|
|
75
|
-
self.rules_filename = settings.proxyrules_rules_filename
|
|
76
|
-
self.simulate_create_on_missing = settings.proxyrules_simulate_create_on_missing
|
|
77
|
-
self.verify_ssl_certificates = settings.proxyrules_verify_ssl_certificates
|
|
78
|
-
|
|
79
|
-
def __str__(self) -> str:
|
|
80
|
-
return (
|
|
81
|
-
f"[medium_purple]proxyrules[/medium_purple]\n "
|
|
82
|
-
f"rules_filename: {self.rules_filename}.\n "
|
|
83
|
-
f"redirect_via: [medium_purple]{self.redirect_via}[/medium_purple].\n "
|
|
84
|
-
f"simulate_create_on_missing: {self.simulate_create_on_missing}.\n "
|
|
85
|
-
f"reverse_proxy_timeout: {self.reverse_proxy_timeout}\n "
|
|
86
|
-
f"verify_ssl_certificates: {self.verify_ssl_certificates}\n "
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
@cached_property
|
|
90
|
-
def env(self) -> Environment:
|
|
91
|
-
"""Jinja2 environment for the proxy rules strategy."""
|
|
92
|
-
return templates_env_provider()
|
|
93
|
-
|
|
94
|
-
@cached_property
|
|
95
|
-
def rules(self) -> list[Rule]:
|
|
96
|
-
return self.load_rules()
|
|
97
|
-
|
|
98
|
-
def load_rules(self) -> list[Rule]:
|
|
99
|
-
if self.rules_filename is None:
|
|
100
|
-
raise ValueError("rules_filename is not set")
|
|
101
|
-
|
|
102
|
-
with open(self.rules_filename, "r") as file:
|
|
103
|
-
data = yaml.safe_load(file)
|
|
104
|
-
return [Rule.from_dict(rule) for rule in data["rules"]]
|
|
105
|
-
|
|
106
|
-
def rule_for(self, request: Request) -> Rule | None:
|
|
107
|
-
try:
|
|
108
|
-
return next(rule for rule in self.rules if rule.matches(request))
|
|
109
|
-
except StopIteration:
|
|
110
|
-
return None
|
|
111
|
-
|
|
112
|
-
async def apply(self, request: Request) -> Response:
|
|
113
|
-
rule = self.rule_for(request)
|
|
114
|
-
if rule is None:
|
|
115
|
-
self.logger.warning(
|
|
116
|
-
f"No rule found for request: {request.method} {request.url.path}"
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
if self.simulate_create_on_missing and looks_like_a_create(request):
|
|
120
|
-
self.logger.info(
|
|
121
|
-
f"Simulating resource creation for missing rule for {request.method} {request.url.path}"
|
|
122
|
-
)
|
|
123
|
-
return await self._create(
|
|
124
|
-
request,
|
|
125
|
-
env=self.env,
|
|
126
|
-
created_resource_metadata=self.created_resource_metadata,
|
|
127
|
-
)
|
|
128
|
-
else:
|
|
129
|
-
return JSONResponse(
|
|
130
|
-
content=self.missing_resource_fields,
|
|
131
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
url = rule.apply(request)
|
|
135
|
-
self.logger.info(f"[rule:{rule.name}] Redirecting to: {url}")
|
|
136
|
-
self.update_opentelemetry(request, rule, url)
|
|
137
|
-
|
|
138
|
-
match self.redirect_via:
|
|
139
|
-
case ProxyRulesRedirectVia.HTTP_TEMPORARY_REDIRECT:
|
|
140
|
-
return RedirectResponse(
|
|
141
|
-
url=url, status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
case ProxyRulesRedirectVia.HTTP_PERMANENT_REDIRECT:
|
|
145
|
-
return RedirectResponse(
|
|
146
|
-
url=url, status_code=status.HTTP_301_MOVED_PERMANENTLY
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
case ProxyRulesRedirectVia.REVERSE_PROXY:
|
|
150
|
-
response = await self.reverse_proxy(request, url)
|
|
151
|
-
return response
|
|
152
|
-
|
|
153
|
-
case _:
|
|
154
|
-
raise ValueError(f"Invalid redirect via value: {self.redirect_via=}")
|
|
155
|
-
|
|
156
|
-
async def reverse_proxy(self, request: Request, url: str) -> Response:
|
|
157
|
-
"""Reverse proxy the request to the target URL."""
|
|
158
|
-
async with httpx.AsyncClient(
|
|
159
|
-
timeout=self.reverse_proxy_timeout, verify=self.verify_ssl_certificates
|
|
160
|
-
) as client:
|
|
161
|
-
request_content = await request.body()
|
|
162
|
-
request_headers = self.reverse_proxy_headers(request.headers, url=url)
|
|
163
|
-
req = client.build_request(
|
|
164
|
-
request.method,
|
|
165
|
-
url,
|
|
166
|
-
content=request_content,
|
|
167
|
-
headers=request_headers,
|
|
168
|
-
params=request.url.query,
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
resp = await client.send(req, stream=False)
|
|
172
|
-
content = resp.read()
|
|
173
|
-
|
|
174
|
-
return Response(
|
|
175
|
-
content=content,
|
|
176
|
-
status_code=resp.status_code,
|
|
177
|
-
headers=resp.headers,
|
|
178
|
-
media_type=resp.headers.get("content-type"),
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
def reverse_proxy_headers(self, headers: Headers, url: str) -> Headers:
|
|
182
|
-
"""Mutate the request headers for the reverse proxy mode."""
|
|
183
|
-
_headers = headers.mutablecopy()
|
|
184
|
-
|
|
185
|
-
# When reverse proxying, we must alter the Host header to the target URL.
|
|
186
|
-
_headers["host"] = urlparse(url).netloc
|
|
187
|
-
|
|
188
|
-
return _headers
|
|
189
|
-
|
|
190
|
-
def update_opentelemetry(self, request: Request, rule: Rule, url: str) -> None:
|
|
191
|
-
"""Update the opentelemetry span with the proxy rules rule details."""
|
|
192
|
-
span = request.state.span
|
|
193
|
-
if rule.name is not None:
|
|
194
|
-
span.set_attribute("mockstack.proxyrules.rule_name", rule.name)
|
|
195
|
-
if rule.method is not None:
|
|
196
|
-
span.set_attribute("mockstack.proxyrules.rule_method", rule.method)
|
|
197
|
-
|
|
198
|
-
span.set_attribute("mockstack.proxyrules.rule_pattern", rule.pattern)
|
|
199
|
-
span.set_attribute("mockstack.proxyrules.rule_replacement", rule.replacement)
|
|
200
|
-
span.set_attribute("mockstack.proxyrules.rewritten_url", url)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/templates/ollama-openai-v1-chat-completions.j2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|