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.
Files changed (93) hide show
  1. {mockstack-0.9.3 → mockstack-0.10.0}/PKG-INFO +1 -1
  2. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/constants.py +5 -3
  3. mockstack-0.10.0/mockstack/strategies/proxyrules.py +357 -0
  4. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/strategies/test_proxyrules.py +89 -4
  5. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/PKG-INFO +1 -1
  6. mockstack-0.9.3/mockstack/strategies/proxyrules.py +0 -200
  7. {mockstack-0.9.3 → mockstack-0.10.0}/.dockerignore +0 -0
  8. {mockstack-0.9.3 → mockstack-0.10.0}/.env.example +0 -0
  9. {mockstack-0.9.3 → mockstack-0.10.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  10. {mockstack-0.9.3 → mockstack-0.10.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  11. {mockstack-0.9.3 → mockstack-0.10.0}/.github/workflows/ci.yml +0 -0
  12. {mockstack-0.9.3 → mockstack-0.10.0}/.github/workflows/publish-docs.yml +0 -0
  13. {mockstack-0.9.3 → mockstack-0.10.0}/.github/workflows/publish-to-dockerhub.yml +0 -0
  14. {mockstack-0.9.3 → mockstack-0.10.0}/.github/workflows/publish-to-pypi.yml +0 -0
  15. {mockstack-0.9.3 → mockstack-0.10.0}/.gitignore +0 -0
  16. {mockstack-0.9.3 → mockstack-0.10.0}/.pre-commit-config.yaml +0 -0
  17. {mockstack-0.9.3 → mockstack-0.10.0}/CODE_OF_CONDUCT.md +0 -0
  18. {mockstack-0.9.3 → mockstack-0.10.0}/CONTRIBUTING.md +0 -0
  19. {mockstack-0.9.3 → mockstack-0.10.0}/Dockerfile +0 -0
  20. {mockstack-0.9.3 → mockstack-0.10.0}/LICENSE +0 -0
  21. {mockstack-0.9.3 → mockstack-0.10.0}/README.md +0 -0
  22. {mockstack-0.9.3 → mockstack-0.10.0}/SECURITY.md +0 -0
  23. {mockstack-0.9.3 → mockstack-0.10.0}/docker-entrypoint.sh +0 -0
  24. {mockstack-0.9.3 → mockstack-0.10.0}/docs/assets/favicon.ico +0 -0
  25. {mockstack-0.9.3 → mockstack-0.10.0}/docs/assets/logo.png +0 -0
  26. {mockstack-0.9.3 → mockstack-0.10.0}/docs/assets/mockstack.png +0 -0
  27. {mockstack-0.9.3 → mockstack-0.10.0}/docs/configuration.md +0 -0
  28. {mockstack-0.9.3 → mockstack-0.10.0}/docs/ollama.md +0 -0
  29. {mockstack-0.9.3 → mockstack-0.10.0}/docs/strategies/base.md +0 -0
  30. {mockstack-0.9.3 → mockstack-0.10.0}/docs/strategies/filefixtures.md +0 -0
  31. {mockstack-0.9.3 → mockstack-0.10.0}/docs/strategies/proxyrules.md +0 -0
  32. {mockstack-0.9.3 → mockstack-0.10.0}/examples/filefixtures-with-templates/.env.example +0 -0
  33. {mockstack-0.9.3 → mockstack-0.10.0}/examples/filefixtures-with-templates/README.md +0 -0
  34. {mockstack-0.9.3 → mockstack-0.10.0}/examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2 +0 -0
  35. {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
  36. {mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/README.md +0 -0
  37. {mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/mockstack-langchain-example.ipynb +0 -0
  38. {mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/templates/ollama-openai-v1-chat-completions.j2 +0 -0
  39. {mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/templates/openai-v1-chat-completions.j2 +0 -0
  40. {mockstack-0.9.3 → mockstack-0.10.0}/examples/llm/templates/openai-v1-deployments-gpt-4o-chat-completions.j2 +0 -0
  41. {mockstack-0.9.3 → mockstack-0.10.0}/examples/proxyrules-with-rules-file/.env.example +0 -0
  42. {mockstack-0.9.3 → mockstack-0.10.0}/examples/proxyrules-with-rules-file/README.md +0 -0
  43. {mockstack-0.9.3 → mockstack-0.10.0}/examples/proxyrules-with-rules-file/rules.yml +0 -0
  44. {mockstack-0.9.3 → mockstack-0.10.0}/mkdocs.yml +0 -0
  45. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/__init__.py +0 -0
  46. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/config.py +0 -0
  47. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/display.py +0 -0
  48. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/exceptions.py +0 -0
  49. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/identifiers.py +0 -0
  50. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/intent.py +0 -0
  51. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/lifespan.py +0 -0
  52. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/llm/__init__.py +0 -0
  53. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/llm/ollama.py +0 -0
  54. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/main.py +0 -0
  55. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/middleware.py +0 -0
  56. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/routers/__init__.py +0 -0
  57. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/routers/catchall.py +0 -0
  58. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/routers/homepage.py +0 -0
  59. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/strategies/__init__.py +0 -0
  60. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/strategies/base.py +0 -0
  61. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/strategies/create_mixin.py +0 -0
  62. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/strategies/factory.py +0 -0
  63. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/strategies/filefixtures.py +0 -0
  64. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/telemetry.py +0 -0
  65. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/templating.py +0 -0
  66. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/__init__.py +0 -0
  67. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/conftest.py +0 -0
  68. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/fixtures/__init__.py +0 -0
  69. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/fixtures/proxyrules.yml +0 -0
  70. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/fixtures/templates/__init__.py +0 -0
  71. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
  72. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/llm/__init__.py +0 -0
  73. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/llm/test_ollama.py +0 -0
  74. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/routers/__init__.py +0 -0
  75. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/routers/test_catchall.py +0 -0
  76. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/routers/test_homepage.py +0 -0
  77. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/strategies/__init__.py +0 -0
  78. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/strategies/test_create_mixin.py +0 -0
  79. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/strategies/test_filefixtures.py +0 -0
  80. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_display.py +0 -0
  81. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_identifiers.py +0 -0
  82. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_intent.py +0 -0
  83. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_middleware.py +0 -0
  84. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_telemetry.py +0 -0
  85. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack/tests/test_templating.py +0 -0
  86. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/SOURCES.txt +0 -0
  87. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/dependency_links.txt +0 -0
  88. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/entry_points.txt +0 -0
  89. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/requires.txt +0 -0
  90. {mockstack-0.9.3 → mockstack-0.10.0}/mockstack.egg-info/top_level.txt +0 -0
  91. {mockstack-0.9.3 → mockstack-0.10.0}/pyproject.toml +0 -0
  92. {mockstack-0.9.3 → mockstack-0.10.0}/setup.cfg +0 -0
  93. {mockstack-0.9.3 → mockstack-0.10.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mockstack
3
- Version: 0.9.3
3
+ Version: 0.10.0
4
4
  Summary: An API mocking workhorse
5
5
  Author-email: Adam Ever-Hadani <mockstack.contact@gmail.com>
6
6
  License-Expression: MIT
@@ -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 ProxyRulesStrategy, Rule
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
- url = rule.apply(request)
117
- assert isinstance(url, str)
118
- assert url == expected_url
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mockstack
3
- Version: 0.9.3
3
+ Version: 0.10.0
4
4
  Summary: An API mocking workhorse
5
5
  Author-email: Adam Ever-Hadani <mockstack.contact@gmail.com>
6
6
  License-Expression: MIT
@@ -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