mockstack 0.9.3__tar.gz → 0.11.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 (95) hide show
  1. {mockstack-0.9.3 → mockstack-0.11.0}/Dockerfile +1 -1
  2. {mockstack-0.9.3 → mockstack-0.11.0}/PKG-INFO +1 -1
  3. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/constants.py +16 -3
  4. mockstack-0.11.0/mockstack/rules.py +112 -0
  5. mockstack-0.11.0/mockstack/strategies/proxyrules.py +271 -0
  6. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/strategies/test_proxyrules.py +70 -106
  7. mockstack-0.11.0/mockstack/tests/test_rules.py +138 -0
  8. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/PKG-INFO +1 -1
  9. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/SOURCES.txt +2 -0
  10. {mockstack-0.9.3 → mockstack-0.11.0}/uv.lock +228 -118
  11. mockstack-0.9.3/mockstack/strategies/proxyrules.py +0 -200
  12. {mockstack-0.9.3 → mockstack-0.11.0}/.dockerignore +0 -0
  13. {mockstack-0.9.3 → mockstack-0.11.0}/.env.example +0 -0
  14. {mockstack-0.9.3 → mockstack-0.11.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {mockstack-0.9.3 → mockstack-0.11.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {mockstack-0.9.3 → mockstack-0.11.0}/.github/workflows/ci.yml +0 -0
  17. {mockstack-0.9.3 → mockstack-0.11.0}/.github/workflows/publish-docs.yml +0 -0
  18. {mockstack-0.9.3 → mockstack-0.11.0}/.github/workflows/publish-to-dockerhub.yml +0 -0
  19. {mockstack-0.9.3 → mockstack-0.11.0}/.github/workflows/publish-to-pypi.yml +0 -0
  20. {mockstack-0.9.3 → mockstack-0.11.0}/.gitignore +0 -0
  21. {mockstack-0.9.3 → mockstack-0.11.0}/.pre-commit-config.yaml +0 -0
  22. {mockstack-0.9.3 → mockstack-0.11.0}/CODE_OF_CONDUCT.md +0 -0
  23. {mockstack-0.9.3 → mockstack-0.11.0}/CONTRIBUTING.md +0 -0
  24. {mockstack-0.9.3 → mockstack-0.11.0}/LICENSE +0 -0
  25. {mockstack-0.9.3 → mockstack-0.11.0}/README.md +0 -0
  26. {mockstack-0.9.3 → mockstack-0.11.0}/SECURITY.md +0 -0
  27. {mockstack-0.9.3 → mockstack-0.11.0}/docker-entrypoint.sh +0 -0
  28. {mockstack-0.9.3 → mockstack-0.11.0}/docs/assets/favicon.ico +0 -0
  29. {mockstack-0.9.3 → mockstack-0.11.0}/docs/assets/logo.png +0 -0
  30. {mockstack-0.9.3 → mockstack-0.11.0}/docs/assets/mockstack.png +0 -0
  31. {mockstack-0.9.3 → mockstack-0.11.0}/docs/configuration.md +0 -0
  32. {mockstack-0.9.3 → mockstack-0.11.0}/docs/ollama.md +0 -0
  33. {mockstack-0.9.3 → mockstack-0.11.0}/docs/strategies/base.md +0 -0
  34. {mockstack-0.9.3 → mockstack-0.11.0}/docs/strategies/filefixtures.md +0 -0
  35. {mockstack-0.9.3 → mockstack-0.11.0}/docs/strategies/proxyrules.md +0 -0
  36. {mockstack-0.9.3 → mockstack-0.11.0}/examples/filefixtures-with-templates/.env.example +0 -0
  37. {mockstack-0.9.3 → mockstack-0.11.0}/examples/filefixtures-with-templates/README.md +0 -0
  38. {mockstack-0.9.3 → mockstack-0.11.0}/examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2 +0 -0
  39. {mockstack-0.9.3 → mockstack-0.11.0}/examples/filefixtures-with-templates/templates/servicename2-api-v2-user.533ec889-7c68-45c8-b21e-4a7e455d1234.j2 +0 -0
  40. {mockstack-0.9.3 → mockstack-0.11.0}/examples/llm/README.md +0 -0
  41. {mockstack-0.9.3 → mockstack-0.11.0}/examples/llm/mockstack-langchain-example.ipynb +0 -0
  42. {mockstack-0.9.3 → mockstack-0.11.0}/examples/llm/templates/ollama-openai-v1-chat-completions.j2 +0 -0
  43. {mockstack-0.9.3 → mockstack-0.11.0}/examples/llm/templates/openai-v1-chat-completions.j2 +0 -0
  44. {mockstack-0.9.3 → mockstack-0.11.0}/examples/llm/templates/openai-v1-deployments-gpt-4o-chat-completions.j2 +0 -0
  45. {mockstack-0.9.3 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/.env.example +0 -0
  46. {mockstack-0.9.3 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/README.md +0 -0
  47. {mockstack-0.9.3 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/rules.yml +0 -0
  48. {mockstack-0.9.3 → mockstack-0.11.0}/mkdocs.yml +0 -0
  49. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/__init__.py +0 -0
  50. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/config.py +0 -0
  51. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/display.py +0 -0
  52. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/exceptions.py +0 -0
  53. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/identifiers.py +0 -0
  54. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/intent.py +0 -0
  55. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/lifespan.py +0 -0
  56. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/llm/__init__.py +0 -0
  57. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/llm/ollama.py +0 -0
  58. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/main.py +0 -0
  59. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/middleware.py +0 -0
  60. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/routers/__init__.py +0 -0
  61. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/routers/catchall.py +0 -0
  62. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/routers/homepage.py +0 -0
  63. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/strategies/__init__.py +0 -0
  64. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/strategies/base.py +0 -0
  65. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/strategies/create_mixin.py +0 -0
  66. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/strategies/factory.py +0 -0
  67. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/strategies/filefixtures.py +0 -0
  68. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/telemetry.py +0 -0
  69. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/templating.py +0 -0
  70. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/__init__.py +0 -0
  71. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/conftest.py +0 -0
  72. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/fixtures/__init__.py +0 -0
  73. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/fixtures/proxyrules.yml +0 -0
  74. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/fixtures/templates/__init__.py +0 -0
  75. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
  76. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/llm/__init__.py +0 -0
  77. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/llm/test_ollama.py +0 -0
  78. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/routers/__init__.py +0 -0
  79. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/routers/test_catchall.py +0 -0
  80. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/routers/test_homepage.py +0 -0
  81. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/strategies/__init__.py +0 -0
  82. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/strategies/test_create_mixin.py +0 -0
  83. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/strategies/test_filefixtures.py +0 -0
  84. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_display.py +0 -0
  85. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_identifiers.py +0 -0
  86. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_intent.py +0 -0
  87. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_middleware.py +0 -0
  88. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_telemetry.py +0 -0
  89. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_templating.py +0 -0
  90. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/dependency_links.txt +0 -0
  91. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/entry_points.txt +0 -0
  92. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/requires.txt +0 -0
  93. {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/top_level.txt +0 -0
  94. {mockstack-0.9.3 → mockstack-0.11.0}/pyproject.toml +0 -0
  95. {mockstack-0.9.3 → mockstack-0.11.0}/setup.cfg +0 -0
@@ -1,7 +1,7 @@
1
1
  # ---------------------------- Build Time Arguments ----------------------------
2
2
 
3
3
  # Define build argument for version
4
- ARG PYTHON_IMAGE_VERSION=3.13.3-slim
4
+ ARG PYTHON_IMAGE_VERSION=3.13-slim
5
5
  ARG MOCKSTACK_VERSION=0.8.0
6
6
 
7
7
  # ---------------------------- Base Image --------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mockstack
3
- Version: 0.9.3
3
+ Version: 0.11.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,22 @@ 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
+
15
+ # See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
16
+ CONTENT_ENCODING_COMPRESSED = (
17
+ "gzip",
18
+ "compress",
19
+ "deflate",
20
+ "br",
21
+ "zstd",
22
+ "dcb",
23
+ "dcz",
24
+ )
25
+
10
26
 
11
27
  class ProxyRulesRedirectVia(StrEnum):
12
28
  """The type of redirect to use for the proxy rules strategy.
@@ -20,6 +36,3 @@ class ProxyRulesRedirectVia(StrEnum):
20
36
  HTTP_TEMPORARY_REDIRECT = "http_307_temporary"
21
37
  HTTP_PERMANENT_REDIRECT = "http_301_permanent"
22
38
  REVERSE_PROXY = "reverse_proxy"
23
-
24
-
25
- SENSITIVE_HEADERS = ["authorization", "cookie", "set-cookie"]
@@ -0,0 +1,112 @@
1
+ """Rules for the proxy rules strategy."""
2
+
3
+ import re
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+ from typing import Self
7
+
8
+ from fastapi import Request
9
+
10
+ from mockstack.constants import PROXYRULES_FILE_TEMPLATE_PREFIX
11
+ from mockstack.templating import parse_template_name_segments_and_identifiers
12
+
13
+
14
+ class RuleResult(ABC):
15
+ """Base class for rule application results."""
16
+
17
+ @abstractmethod
18
+ def get_result_type(self) -> str:
19
+ """Return the type of result."""
20
+ pass
21
+
22
+
23
+ @dataclass
24
+ class URLRuleResult(RuleResult):
25
+ """Result for URL-based rules."""
26
+
27
+ url: str
28
+
29
+ def get_result_type(self) -> str:
30
+ return "url"
31
+
32
+
33
+ @dataclass
34
+ class TemplateRuleResult(RuleResult):
35
+ """Result for template-based rules."""
36
+
37
+ template_path: str
38
+ template_context: dict
39
+
40
+ def get_result_type(self) -> str:
41
+ return "template"
42
+
43
+
44
+ class Rule:
45
+ """A rule for the proxy rules strategy."""
46
+
47
+ def __init__(
48
+ self,
49
+ pattern: str,
50
+ replacement: str,
51
+ method: str | None = None,
52
+ name: str | None = None,
53
+ ):
54
+ self.pattern = pattern
55
+ self.replacement = replacement
56
+ self.method = method
57
+ self.name = name
58
+
59
+ @classmethod
60
+ def from_dict(cls, data: dict[str, str]) -> Self:
61
+ return cls(
62
+ pattern=data["pattern"],
63
+ replacement=data["replacement"],
64
+ method=data.get("method", None),
65
+ name=data.get("name", None),
66
+ )
67
+
68
+ def matches(self, request: Request) -> bool:
69
+ """Check if the rule matches the request."""
70
+ if self.method is not None and request.method.lower() != self.method.lower():
71
+ # if rule is limited to a specific HTTP method, validate first.
72
+ return False
73
+
74
+ return re.match(self.pattern, request.url.path) is not None
75
+
76
+ def apply(self, request: Request) -> RuleResult:
77
+ """Apply the rule to the request."""
78
+ path = request.url.path
79
+ result = self._url_for(path)
80
+
81
+ # Check if the replacement is a file template
82
+ if result.startswith(PROXYRULES_FILE_TEMPLATE_PREFIX):
83
+ # Extract the file path from the file:/// URL
84
+ file_path = result[len(PROXYRULES_FILE_TEMPLATE_PREFIX) - 1 :]
85
+
86
+ # Create template context from request
87
+ template_context = self._create_template_context(request)
88
+
89
+ return TemplateRuleResult(
90
+ template_path=file_path,
91
+ template_context=template_context,
92
+ )
93
+ else:
94
+ # Regular URL replacement
95
+ return URLRuleResult(url=result)
96
+
97
+ def _url_for(self, path: str) -> str:
98
+ return re.sub(self.pattern, self.replacement, path)
99
+
100
+ def _create_template_context(self, request: Request) -> dict:
101
+ """Create template context from the request, using the same logic as templating.py."""
102
+ path = request.url.path
103
+ name_segments, identifiers = parse_template_name_segments_and_identifiers(
104
+ path, default_identifier_key="id"
105
+ )
106
+ return {
107
+ "query": dict(request.query_params),
108
+ "headers": dict(request.headers),
109
+ "path": request.url.path,
110
+ "method": request.method,
111
+ **identifiers,
112
+ }
@@ -0,0 +1,271 @@
1
+ """Strategy for using proxy rules."""
2
+
3
+ import logging
4
+ from functools import cached_property
5
+ from pathlib import Path
6
+ from urllib.parse import urlparse
7
+
8
+ import httpx
9
+ import yaml
10
+ from fastapi import Request, Response, status
11
+ from fastapi.responses import JSONResponse, RedirectResponse
12
+ from httpx import Headers as ResponseHeaders
13
+ from jinja2 import Environment
14
+ from starlette.datastructures import Headers
15
+
16
+ from mockstack.config import Settings
17
+ from mockstack.constants import CONTENT_ENCODING_COMPRESSED, ProxyRulesRedirectVia
18
+ from mockstack.intent import looks_like_a_create
19
+ from mockstack.rules import Rule, TemplateRuleResult, URLRuleResult
20
+ from mockstack.strategies.base import BaseStrategy
21
+ from mockstack.strategies.create_mixin import CreateMixin
22
+ from mockstack.templating import templates_env_provider
23
+
24
+
25
+ def maybe_update_response_headers(
26
+ response_headers: ResponseHeaders,
27
+ *,
28
+ request_headers: Headers,
29
+ ) -> ResponseHeaders:
30
+ """Update the response headers if needed, e.g. to adjust for compression etc."""
31
+ _headers = response_headers.copy()
32
+
33
+ if _headers.get("content-encoding") in CONTENT_ENCODING_COMPRESSED:
34
+ # If the response is compressed, we need to remove the content-encoding header
35
+ # since httpx will automatically decompress the response while proxying.
36
+ _headers["content-encoding"] = "identity"
37
+
38
+ return _headers
39
+
40
+
41
+ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
42
+ """Strategy for using proxy rules."""
43
+
44
+ logger = logging.getLogger("ProxyRulesStrategy")
45
+
46
+ def __init__(self, settings: Settings, *args, **kwargs):
47
+ super().__init__(settings, *args, **kwargs)
48
+ self.created_resource_metadata = settings.created_resource_metadata
49
+ self.missing_resource_fields = settings.missing_resource_fields
50
+ self.redirect_via = settings.proxyrules_redirect_via
51
+ self.reverse_proxy_timeout = settings.proxyrules_reverse_proxy_timeout
52
+ self.rules_filename = settings.proxyrules_rules_filename
53
+ self.simulate_create_on_missing = settings.proxyrules_simulate_create_on_missing
54
+ self.verify_ssl_certificates = settings.proxyrules_verify_ssl_certificates
55
+
56
+ def __str__(self) -> str:
57
+ return (
58
+ f"[medium_purple]proxyrules[/medium_purple]\n "
59
+ f"rules_filename: {self.rules_filename}.\n "
60
+ f"redirect_via: [medium_purple]{self.redirect_via}[/medium_purple].\n "
61
+ f"simulate_create_on_missing: {self.simulate_create_on_missing}.\n "
62
+ f"reverse_proxy_timeout: {self.reverse_proxy_timeout}\n "
63
+ f"verify_ssl_certificates: {self.verify_ssl_certificates}\n "
64
+ )
65
+
66
+ @cached_property
67
+ def env(self) -> Environment:
68
+ """Jinja2 environment for the proxy rules strategy."""
69
+ return templates_env_provider()
70
+
71
+ @cached_property
72
+ def rules(self) -> list[Rule]:
73
+ return self.load_rules()
74
+
75
+ def load_rules(self) -> list[Rule]:
76
+ if self.rules_filename is None:
77
+ raise ValueError("rules_filename is not set")
78
+
79
+ with open(self.rules_filename, "r") as file:
80
+ data = yaml.safe_load(file)
81
+ return [Rule.from_dict(rule) for rule in data["rules"]]
82
+
83
+ def rule_for(self, request: Request) -> Rule | None:
84
+ try:
85
+ return next(rule for rule in self.rules if rule.matches(request))
86
+ except StopIteration:
87
+ return None
88
+
89
+ async def apply(self, request: Request) -> Response:
90
+ rule = self.rule_for(request)
91
+ if rule is None:
92
+ return await self.handle_missing_rule(request)
93
+
94
+ result = rule.apply(request)
95
+ self.logger.info(f"[rule:{rule.name}] Result: {result}")
96
+
97
+ # Handle template results
98
+ if isinstance(result, TemplateRuleResult):
99
+ return await self.handle_template_result(request, rule, result)
100
+ elif isinstance(result, URLRuleResult):
101
+ return await self.handle_url_result(request, rule, result)
102
+ else:
103
+ raise ValueError(f"Unknown result type: {type(result)}")
104
+
105
+ async def handle_missing_rule(self, request: Request) -> Response:
106
+ """Handle a missing rule."""
107
+ self.logger.warning(
108
+ f"No rule found for request: {request.method} {request.url.path}"
109
+ )
110
+
111
+ if self.simulate_create_on_missing and looks_like_a_create(request):
112
+ self.logger.info(
113
+ f"Simulating resource creation for missing rule for {request.method} {request.url.path}"
114
+ )
115
+ return await self._create(
116
+ request,
117
+ env=self.env,
118
+ created_resource_metadata=self.created_resource_metadata,
119
+ )
120
+ else:
121
+ return JSONResponse(
122
+ content=self.missing_resource_fields,
123
+ status_code=status.HTTP_404_NOT_FOUND,
124
+ )
125
+
126
+ async def handle_url_result(
127
+ self, request: Request, rule: Rule, result: URLRuleResult
128
+ ) -> Response:
129
+ """Handle URL results by redirecting to the target URL."""
130
+ self.update_opentelemetry(request, rule, result.url)
131
+
132
+ match self.redirect_via:
133
+ case ProxyRulesRedirectVia.HTTP_TEMPORARY_REDIRECT:
134
+ return RedirectResponse(
135
+ url=result.url, status_code=status.HTTP_307_TEMPORARY_REDIRECT
136
+ )
137
+
138
+ case ProxyRulesRedirectVia.HTTP_PERMANENT_REDIRECT:
139
+ return RedirectResponse(
140
+ url=result.url, status_code=status.HTTP_301_MOVED_PERMANENTLY
141
+ )
142
+
143
+ case ProxyRulesRedirectVia.REVERSE_PROXY:
144
+ response = await self.reverse_proxy(request, result.url)
145
+ return response
146
+
147
+ case _:
148
+ raise ValueError(f"Invalid redirect via value: {self.redirect_via=}")
149
+
150
+ async def handle_template_result(
151
+ self, request: Request, rule: Rule, result: TemplateRuleResult
152
+ ) -> Response:
153
+ """Handle template results by rendering the template file."""
154
+ template_path = Path(result.template_path)
155
+
156
+ if not template_path.exists():
157
+ self.logger.error(f"Template file not found: {template_path}")
158
+ return JSONResponse(
159
+ content={"error": f"Template file not found: {template_path}"},
160
+ status_code=status.HTTP_404_NOT_FOUND,
161
+ )
162
+
163
+ try:
164
+ # Read the template file content
165
+ with open(template_path, "r") as f:
166
+ template_content = f.read()
167
+
168
+ # Create a template from the content
169
+ template = self.env.from_string(template_content)
170
+
171
+ # Render the template with context
172
+ rendered_content = template.render(**result.template_context)
173
+
174
+ # Determine content type based on file extension
175
+ content_type = self._get_content_type(template_path)
176
+
177
+ # Update opentelemetry with template info
178
+ self.update_opentelemetry_template(request, rule, result)
179
+
180
+ return Response(
181
+ content=rendered_content,
182
+ media_type=content_type,
183
+ status_code=status.HTTP_200_OK,
184
+ )
185
+
186
+ except Exception as e:
187
+ self.logger.error(f"Error rendering template {template_path}: {e}")
188
+ return JSONResponse(
189
+ content={
190
+ "error": "An internal error occurred while rendering the template."
191
+ },
192
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
193
+ )
194
+
195
+ async def reverse_proxy(self, request: Request, url: str) -> Response:
196
+ """Reverse proxy the request to the target URL."""
197
+ async with httpx.AsyncClient(
198
+ timeout=self.reverse_proxy_timeout, verify=self.verify_ssl_certificates
199
+ ) as client:
200
+ request_content = await request.body()
201
+ request_headers = self.reverse_proxy_headers(request.headers, url=url)
202
+ req = client.build_request(
203
+ request.method,
204
+ url,
205
+ content=request_content,
206
+ headers=request_headers,
207
+ params=request.url.query,
208
+ )
209
+
210
+ resp = await client.send(req, stream=False)
211
+ content = resp.read()
212
+ response_headers = maybe_update_response_headers(
213
+ resp.headers,
214
+ request_headers=request.headers,
215
+ )
216
+
217
+ return Response(
218
+ content=content,
219
+ status_code=resp.status_code,
220
+ headers=response_headers,
221
+ media_type=response_headers.get("content-type"),
222
+ )
223
+
224
+ def reverse_proxy_headers(self, headers: Headers, url: str) -> Headers:
225
+ """Mutate the request headers for the reverse proxy mode."""
226
+ _headers = headers.mutablecopy()
227
+
228
+ # When reverse proxying, we must alter the Host header to the target URL.
229
+ _headers["host"] = urlparse(url).netloc
230
+
231
+ return _headers
232
+
233
+ def _get_content_type(self, template_path: Path) -> str:
234
+ """Determine content type based on file extension."""
235
+ suffix = template_path.suffix.lower()
236
+ content_types = {
237
+ ".json": "application/json",
238
+ ".xml": "application/xml",
239
+ ".html": "text/html",
240
+ ".txt": "text/plain",
241
+ ".yaml": "application/x-yaml",
242
+ ".yml": "application/x-yaml",
243
+ }
244
+ return content_types.get(suffix, "text/plain")
245
+
246
+ def update_opentelemetry_template(
247
+ self, request: Request, rule: Rule, result: TemplateRuleResult
248
+ ) -> None:
249
+ """Update the opentelemetry span with template-specific details."""
250
+ span = request.state.span
251
+ if rule.name is not None:
252
+ span.set_attribute("mockstack.proxyrules.rule_name", rule.name)
253
+ if rule.method is not None:
254
+ span.set_attribute("mockstack.proxyrules.rule_method", rule.method)
255
+
256
+ span.set_attribute("mockstack.proxyrules.rule_pattern", rule.pattern)
257
+ span.set_attribute("mockstack.proxyrules.rule_replacement", rule.replacement)
258
+ span.set_attribute("mockstack.proxyrules.template_path", result.template_path)
259
+ span.set_attribute("mockstack.proxyrules.result_type", "template")
260
+
261
+ def update_opentelemetry(self, request: Request, rule: Rule, url: str) -> None:
262
+ """Update the opentelemetry span with the proxy rules rule details."""
263
+ span = request.state.span
264
+ if rule.name is not None:
265
+ span.set_attribute("mockstack.proxyrules.rule_name", rule.name)
266
+ if rule.method is not None:
267
+ span.set_attribute("mockstack.proxyrules.rule_method", rule.method)
268
+
269
+ span.set_attribute("mockstack.proxyrules.rule_pattern", rule.pattern)
270
+ span.set_attribute("mockstack.proxyrules.rule_replacement", rule.replacement)
271
+ span.set_attribute("mockstack.proxyrules.rewritten_url", url)
@@ -1,5 +1,6 @@
1
1
  """Unit tests for the proxyrules module."""
2
2
 
3
+ from pathlib import Path
3
4
  from unittest.mock import AsyncMock, MagicMock, patch
4
5
 
5
6
  import httpx
@@ -9,113 +10,11 @@ from fastapi.responses import RedirectResponse
9
10
  from starlette.datastructures import Headers
10
11
 
11
12
  from mockstack.constants import ProxyRulesRedirectVia
12
- from mockstack.strategies.proxyrules import ProxyRulesStrategy, Rule
13
-
14
-
15
- def test_rule_from_dict():
16
- """Test creating a Rule from a dictionary."""
17
- data = {
18
- "pattern": r"/api/v1/projects/(\d+)",
19
- "replacement": r"/projects/\1",
20
- "method": "GET",
21
- }
22
- rule = Rule.from_dict(data)
23
- assert rule.pattern == data["pattern"]
24
- assert rule.replacement == data["replacement"]
25
- assert rule.method == data["method"]
26
-
27
-
28
- def test_rule_from_dict_without_method():
29
- """Test creating a Rule from a dictionary without a method."""
30
- data = {
31
- "pattern": r"/api/v1/projects/(\d+)",
32
- "replacement": r"/projects/\1",
33
- }
34
- rule = Rule.from_dict(data)
35
- assert rule.pattern == data["pattern"]
36
- assert rule.replacement == data["replacement"]
37
- assert rule.method is None
38
-
39
-
40
- @pytest.mark.parametrize(
41
- "pattern,path,method,expected",
42
- [
43
- (r"/api/v1/projects/\d+", "/api/v1/projects/123", "GET", True),
44
- (r"/api/v1/projects/\d+", "/api/v1/projects/123", "POST", True),
45
- (r"/api/v1/projects/\d+", "/api/v1/projects/abc", "GET", False),
46
- (r"/api/v1/projects/\d+", "/api/v1/users/123", "GET", False),
47
- (r"/api/v1/projects/\d+", "/api/v1/projects/123", "POST", True),
48
- ],
13
+ from mockstack.strategies.proxyrules import (
14
+ ProxyRulesStrategy,
15
+ Rule,
16
+ maybe_update_response_headers,
49
17
  )
50
- def test_rule_matches(pattern, path, method, expected):
51
- """Test the rule matching logic."""
52
- rule = Rule(pattern=pattern, replacement="", method=None)
53
- request = Request(
54
- scope={
55
- "type": "http",
56
- "method": method,
57
- "path": path,
58
- "query_string": b"",
59
- "headers": [],
60
- }
61
- )
62
- assert rule.matches(request) == expected
63
-
64
-
65
- @pytest.mark.parametrize(
66
- "pattern,path,method,expected",
67
- [
68
- (r"/api/v1/projects/\d+", "/api/v1/projects/123", "GET", True),
69
- (r"/api/v1/projects/\d+", "/api/v1/projects/123", "POST", False),
70
- ],
71
- )
72
- def test_rule_matches_with_method(pattern, path, method, expected):
73
- """Test the rule matching logic with method restriction."""
74
- rule = Rule(pattern=pattern, replacement="", method="GET")
75
- request = Request(
76
- scope={
77
- "type": "http",
78
- "method": method,
79
- "path": path,
80
- "query_string": b"",
81
- "headers": [],
82
- }
83
- )
84
- assert rule.matches(request) == expected
85
-
86
-
87
- @pytest.mark.parametrize(
88
- "pattern,replacement,path,expected_url",
89
- [
90
- (
91
- r"/api/v1/projects/(\d+)",
92
- r"/projects/\1",
93
- "/api/v1/projects/123",
94
- "/projects/123",
95
- ),
96
- (
97
- r"/api/v1/users/([^/]+)",
98
- r"/users/\1",
99
- "/api/v1/users/john",
100
- "/users/john",
101
- ),
102
- ],
103
- )
104
- def test_rule_apply(pattern, replacement, path, expected_url):
105
- """Test the rule application logic."""
106
- rule = Rule(pattern=pattern, replacement=replacement)
107
- request = Request(
108
- scope={
109
- "type": "http",
110
- "method": "GET",
111
- "path": path,
112
- "query_string": b"",
113
- "headers": [],
114
- }
115
- )
116
- url = rule.apply(request)
117
- assert isinstance(url, str)
118
- assert url == expected_url
119
18
 
120
19
 
121
20
  def test_proxy_rules_strategy_load_rules(settings):
@@ -198,6 +97,58 @@ async def test_proxy_rules_strategy_apply_no_match(settings, span):
198
97
  assert response.status_code == 404
199
98
 
200
99
 
100
+ @pytest.mark.asyncio
101
+ async def test_proxy_rules_strategy_apply_template(settings, span, tmp_path):
102
+ """Test applying strategy with template rendering."""
103
+ # Create a template file
104
+ template_file = tmp_path / "template.json"
105
+ template_file.write_text(
106
+ '{"projects": "{{ projects }}", "name": "Project {{ projects }}"}'
107
+ )
108
+
109
+ # Create a rule that points to the template file
110
+ rule_data = {
111
+ "pattern": r"/api/v1/projects/(\d+)",
112
+ "replacement": f"file:///{template_file}",
113
+ "name": "test_template_rule",
114
+ }
115
+
116
+ # Mock the rule_for method to return our test rule
117
+ strategy = ProxyRulesStrategy(settings)
118
+ test_rule = Rule.from_dict(rule_data)
119
+
120
+ with patch.object(strategy, "rule_for", return_value=test_rule):
121
+ request = Request(
122
+ scope={
123
+ "type": "http",
124
+ "method": "GET",
125
+ "path": "/api/v1/projects/1234",
126
+ "query_string": b"",
127
+ "headers": [],
128
+ }
129
+ )
130
+ request.state.span = span
131
+ response = await strategy.apply(request)
132
+
133
+ assert response.status_code == 200
134
+ assert response.media_type == "application/json"
135
+ assert response.body.decode() == '{"projects": "1234", "name": "Project 1234"}'
136
+
137
+
138
+ def test_proxy_rules_strategy_get_content_type(settings):
139
+ """Test content type detection based on file extension."""
140
+ strategy = ProxyRulesStrategy(settings)
141
+
142
+ # Test various file extensions
143
+ assert strategy._get_content_type(Path("file.json")) == "application/json"
144
+ assert strategy._get_content_type(Path("file.xml")) == "application/xml"
145
+ assert strategy._get_content_type(Path("file.html")) == "text/html"
146
+ assert strategy._get_content_type(Path("file.txt")) == "text/plain"
147
+ assert strategy._get_content_type(Path("file.yaml")) == "application/x-yaml"
148
+ assert strategy._get_content_type(Path("file.yml")) == "application/x-yaml"
149
+ assert strategy._get_content_type(Path("file.unknown")) == "text/plain"
150
+
151
+
201
152
  @pytest.mark.asyncio
202
153
  @pytest.mark.skip(reason="TODO: Fix this test")
203
154
  async def test_proxy_rules_strategy_apply_reverse_proxy(settings_reverse_proxy, span):
@@ -407,3 +358,16 @@ async def test_proxy_rules_strategy_reverse_proxy(settings_reverse_proxy, span):
407
358
  assert isinstance(headers, Headers)
408
359
 
409
360
  mock_client.send.assert_called_once()
361
+
362
+ def test_maybe_update_response_headers_updates_content_encoding():
363
+ """Test maybe_update_response_headers updates content-encoding."""
364
+ headers = Headers(
365
+ {"content-encoding": "gzip", "content-type": "application/json"}
366
+ )
367
+ request_headers = Headers({"accept-encoding": "gzip"})
368
+
369
+ updated_headers = maybe_update_response_headers(headers, request_headers)
370
+
371
+ assert updated_headers["content-encoding"] == "identity"
372
+ assert updated_headers["content-type"] == "application/json"
373
+ assert updated_headers["accept-encoding"] == "gzip"