mockstack 0.9.2__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 (94) hide show
  1. {mockstack-0.9.2 → mockstack-0.10.0}/.env.example +1 -0
  2. {mockstack-0.9.2 → mockstack-0.10.0}/PKG-INFO +1 -1
  3. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/config.py +6 -1
  4. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/constants.py +5 -3
  5. mockstack-0.10.0/mockstack/strategies/proxyrules.py +357 -0
  6. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/strategies/test_proxyrules.py +89 -4
  7. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack.egg-info/PKG-INFO +1 -1
  8. mockstack-0.10.0/uv.lock +1425 -0
  9. mockstack-0.9.2/mockstack/strategies/proxyrules.py +0 -196
  10. mockstack-0.9.2/uv.lock +0 -1386
  11. {mockstack-0.9.2 → mockstack-0.10.0}/.dockerignore +0 -0
  12. {mockstack-0.9.2 → mockstack-0.10.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  13. {mockstack-0.9.2 → mockstack-0.10.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  14. {mockstack-0.9.2 → mockstack-0.10.0}/.github/workflows/ci.yml +0 -0
  15. {mockstack-0.9.2 → mockstack-0.10.0}/.github/workflows/publish-docs.yml +0 -0
  16. {mockstack-0.9.2 → mockstack-0.10.0}/.github/workflows/publish-to-dockerhub.yml +0 -0
  17. {mockstack-0.9.2 → mockstack-0.10.0}/.github/workflows/publish-to-pypi.yml +0 -0
  18. {mockstack-0.9.2 → mockstack-0.10.0}/.gitignore +0 -0
  19. {mockstack-0.9.2 → mockstack-0.10.0}/.pre-commit-config.yaml +0 -0
  20. {mockstack-0.9.2 → mockstack-0.10.0}/CODE_OF_CONDUCT.md +0 -0
  21. {mockstack-0.9.2 → mockstack-0.10.0}/CONTRIBUTING.md +0 -0
  22. {mockstack-0.9.2 → mockstack-0.10.0}/Dockerfile +0 -0
  23. {mockstack-0.9.2 → mockstack-0.10.0}/LICENSE +0 -0
  24. {mockstack-0.9.2 → mockstack-0.10.0}/README.md +0 -0
  25. {mockstack-0.9.2 → mockstack-0.10.0}/SECURITY.md +0 -0
  26. {mockstack-0.9.2 → mockstack-0.10.0}/docker-entrypoint.sh +0 -0
  27. {mockstack-0.9.2 → mockstack-0.10.0}/docs/assets/favicon.ico +0 -0
  28. {mockstack-0.9.2 → mockstack-0.10.0}/docs/assets/logo.png +0 -0
  29. {mockstack-0.9.2 → mockstack-0.10.0}/docs/assets/mockstack.png +0 -0
  30. {mockstack-0.9.2 → mockstack-0.10.0}/docs/configuration.md +0 -0
  31. {mockstack-0.9.2 → mockstack-0.10.0}/docs/ollama.md +0 -0
  32. {mockstack-0.9.2 → mockstack-0.10.0}/docs/strategies/base.md +0 -0
  33. {mockstack-0.9.2 → mockstack-0.10.0}/docs/strategies/filefixtures.md +0 -0
  34. {mockstack-0.9.2 → mockstack-0.10.0}/docs/strategies/proxyrules.md +0 -0
  35. {mockstack-0.9.2 → mockstack-0.10.0}/examples/filefixtures-with-templates/.env.example +0 -0
  36. {mockstack-0.9.2 → mockstack-0.10.0}/examples/filefixtures-with-templates/README.md +0 -0
  37. {mockstack-0.9.2 → mockstack-0.10.0}/examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2 +0 -0
  38. {mockstack-0.9.2 → mockstack-0.10.0}/examples/filefixtures-with-templates/templates/servicename2-api-v2-user.533ec889-7c68-45c8-b21e-4a7e455d1234.j2 +0 -0
  39. {mockstack-0.9.2 → mockstack-0.10.0}/examples/llm/README.md +0 -0
  40. {mockstack-0.9.2 → mockstack-0.10.0}/examples/llm/mockstack-langchain-example.ipynb +0 -0
  41. {mockstack-0.9.2 → mockstack-0.10.0}/examples/llm/templates/ollama-openai-v1-chat-completions.j2 +0 -0
  42. {mockstack-0.9.2 → mockstack-0.10.0}/examples/llm/templates/openai-v1-chat-completions.j2 +0 -0
  43. {mockstack-0.9.2 → mockstack-0.10.0}/examples/llm/templates/openai-v1-deployments-gpt-4o-chat-completions.j2 +0 -0
  44. {mockstack-0.9.2 → mockstack-0.10.0}/examples/proxyrules-with-rules-file/.env.example +0 -0
  45. {mockstack-0.9.2 → mockstack-0.10.0}/examples/proxyrules-with-rules-file/README.md +0 -0
  46. {mockstack-0.9.2 → mockstack-0.10.0}/examples/proxyrules-with-rules-file/rules.yml +0 -0
  47. {mockstack-0.9.2 → mockstack-0.10.0}/mkdocs.yml +0 -0
  48. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/__init__.py +0 -0
  49. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/display.py +0 -0
  50. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/exceptions.py +0 -0
  51. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/identifiers.py +0 -0
  52. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/intent.py +0 -0
  53. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/lifespan.py +0 -0
  54. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/llm/__init__.py +0 -0
  55. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/llm/ollama.py +0 -0
  56. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/main.py +0 -0
  57. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/middleware.py +0 -0
  58. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/routers/__init__.py +0 -0
  59. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/routers/catchall.py +0 -0
  60. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/routers/homepage.py +0 -0
  61. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/strategies/__init__.py +0 -0
  62. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/strategies/base.py +0 -0
  63. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/strategies/create_mixin.py +0 -0
  64. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/strategies/factory.py +0 -0
  65. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/strategies/filefixtures.py +0 -0
  66. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/telemetry.py +0 -0
  67. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/templating.py +0 -0
  68. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/__init__.py +0 -0
  69. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/conftest.py +0 -0
  70. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/fixtures/__init__.py +0 -0
  71. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/fixtures/proxyrules.yml +0 -0
  72. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/fixtures/templates/__init__.py +0 -0
  73. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
  74. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/llm/__init__.py +0 -0
  75. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/llm/test_ollama.py +0 -0
  76. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/routers/__init__.py +0 -0
  77. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/routers/test_catchall.py +0 -0
  78. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/routers/test_homepage.py +0 -0
  79. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/strategies/__init__.py +0 -0
  80. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/strategies/test_create_mixin.py +0 -0
  81. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/strategies/test_filefixtures.py +0 -0
  82. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/test_display.py +0 -0
  83. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/test_identifiers.py +0 -0
  84. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/test_intent.py +0 -0
  85. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/test_middleware.py +0 -0
  86. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/test_telemetry.py +0 -0
  87. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack/tests/test_templating.py +0 -0
  88. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack.egg-info/SOURCES.txt +0 -0
  89. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack.egg-info/dependency_links.txt +0 -0
  90. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack.egg-info/entry_points.txt +0 -0
  91. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack.egg-info/requires.txt +0 -0
  92. {mockstack-0.9.2 → mockstack-0.10.0}/mockstack.egg-info/top_level.txt +0 -0
  93. {mockstack-0.9.2 → mockstack-0.10.0}/pyproject.toml +0 -0
  94. {mockstack-0.9.2 → mockstack-0.10.0}/setup.cfg +0 -0
@@ -18,6 +18,7 @@ FILEFIXTURES_ENABLE_TEMPLATES_FOR_POST = false
18
18
  MOCKSTACK__PROXYRULES_RULES_FILENAME="/some/path/proxyrules.yml"
19
19
 
20
20
  MOCKSTACK__PROXYRULES_SIMULATE_CREATE_ON_MISSING = true
21
+ MOCKSTACK__PROXYRULES_VERIFY_SSL_CERTIFICATES = true
21
22
 
22
23
  # OpenTelemetry settings
23
24
  MOCKSTACK__OPENTELEMETRY__ENABLED = true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mockstack
3
- Version: 0.9.2
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
@@ -75,13 +75,18 @@ class Settings(BaseSettings):
75
75
  # or reverse proxy the request to the target URL "silently".
76
76
  proxyrules_redirect_via: ProxyRulesRedirectVia = ProxyRulesRedirectVia.REVERSE_PROXY
77
77
 
78
- # Default timeout for reverse proxy requests. given in seconds. None disables timeouts.
78
+ # default timeout for reverse proxy requests. given in seconds. None disables timeouts.
79
79
  proxyrules_reverse_proxy_timeout: float | None = 10.0
80
80
 
81
81
  # controls behavior of proxying. Whether to simulate creation of resources
82
82
  # when a POST request is made to a resource that doesn't match any rules..
83
83
  proxyrules_simulate_create_on_missing: CliImplicitFlag[bool] = False
84
84
 
85
+ # controls behavior of proxying. Whether to verify SSL certificates.
86
+ # this is useful for testing against services that use self-signed certificates.
87
+ # disable with caution!
88
+ proxyrules_verify_ssl_certificates: CliImplicitFlag[bool] = True
89
+
85
90
  # metadata fields to inject into created resources.
86
91
  # A few template fields are available. See documentation for more details.
87
92
  created_resource_metadata: CliSuppress[dict[str, Any]] = {
@@ -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.2
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