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.
- {mockstack-0.9.3 → mockstack-0.11.0}/Dockerfile +1 -1
- {mockstack-0.9.3 → mockstack-0.11.0}/PKG-INFO +1 -1
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/constants.py +16 -3
- mockstack-0.11.0/mockstack/rules.py +112 -0
- mockstack-0.11.0/mockstack/strategies/proxyrules.py +271 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/strategies/test_proxyrules.py +70 -106
- mockstack-0.11.0/mockstack/tests/test_rules.py +138 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/PKG-INFO +1 -1
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/SOURCES.txt +2 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/uv.lock +228 -118
- mockstack-0.9.3/mockstack/strategies/proxyrules.py +0 -200
- {mockstack-0.9.3 → mockstack-0.11.0}/.dockerignore +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/.env.example +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/.github/workflows/ci.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/.github/workflows/publish-docs.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/.github/workflows/publish-to-dockerhub.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/.gitignore +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/.pre-commit-config.yaml +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/CODE_OF_CONDUCT.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/CONTRIBUTING.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/LICENSE +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/README.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/SECURITY.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/docker-entrypoint.sh +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/docs/assets/favicon.ico +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/docs/assets/logo.png +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/docs/assets/mockstack.png +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/docs/configuration.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/docs/ollama.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/docs/strategies/base.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/docs/strategies/filefixtures.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/docs/strategies/proxyrules.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/filefixtures-with-templates/.env.example +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/filefixtures-with-templates/README.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2 +0 -0
- {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
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/llm/README.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/llm/mockstack-langchain-example.ipynb +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/llm/templates/ollama-openai-v1-chat-completions.j2 +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/llm/templates/openai-v1-chat-completions.j2 +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/llm/templates/openai-v1-deployments-gpt-4o-chat-completions.j2 +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/.env.example +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/README.md +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/rules.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mkdocs.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/config.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/display.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/exceptions.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/identifiers.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/intent.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/lifespan.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/llm/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/llm/ollama.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/main.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/middleware.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/routers/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/routers/catchall.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/routers/homepage.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/strategies/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/strategies/base.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/strategies/create_mixin.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/strategies/factory.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/strategies/filefixtures.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/telemetry.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/templating.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/conftest.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/fixtures/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/fixtures/proxyrules.yml +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/fixtures/templates/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/llm/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/llm/test_ollama.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/routers/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/routers/test_catchall.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/routers/test_homepage.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/strategies/__init__.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/strategies/test_create_mixin.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/strategies/test_filefixtures.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_display.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_identifiers.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_intent.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_middleware.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_telemetry.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack/tests/test_templating.py +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/dependency_links.txt +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/entry_points.txt +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/requires.txt +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/mockstack.egg-info/top_level.txt +0 -0
- {mockstack-0.9.3 → mockstack-0.11.0}/pyproject.toml +0 -0
- {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
|
|
4
|
+
ARG PYTHON_IMAGE_VERSION=3.13-slim
|
|
5
5
|
ARG MOCKSTACK_VERSION=0.8.0
|
|
6
6
|
|
|
7
7
|
# ---------------------------- Base Image --------------------------------
|
|
@@ -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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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"
|