mockstack 0.10.0__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.10.0 → mockstack-0.11.0}/Dockerfile +1 -1
- {mockstack-0.10.0 → mockstack-0.11.0}/PKG-INFO +1 -1
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/constants.py +11 -0
- mockstack-0.11.0/mockstack/rules.py +112 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/proxyrules.py +22 -108
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/strategies/test_proxyrules.py +15 -136
- mockstack-0.11.0/mockstack/tests/test_rules.py +138 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/PKG-INFO +1 -1
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/SOURCES.txt +2 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/uv.lock +228 -118
- {mockstack-0.10.0 → mockstack-0.11.0}/.dockerignore +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/.env.example +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/.github/workflows/ci.yml +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/.github/workflows/publish-docs.yml +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/.github/workflows/publish-to-dockerhub.yml +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/.gitignore +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/.pre-commit-config.yaml +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/CODE_OF_CONDUCT.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/CONTRIBUTING.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/LICENSE +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/README.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/SECURITY.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/docker-entrypoint.sh +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/docs/assets/favicon.ico +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/docs/assets/logo.png +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/docs/assets/mockstack.png +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/docs/configuration.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/docs/ollama.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/docs/strategies/base.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/docs/strategies/filefixtures.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/docs/strategies/proxyrules.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/filefixtures-with-templates/.env.example +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/filefixtures-with-templates/README.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2 +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/filefixtures-with-templates/templates/servicename2-api-v2-user.533ec889-7c68-45c8-b21e-4a7e455d1234.j2 +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/llm/README.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/llm/mockstack-langchain-example.ipynb +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/llm/templates/ollama-openai-v1-chat-completions.j2 +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/llm/templates/openai-v1-chat-completions.j2 +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/llm/templates/openai-v1-deployments-gpt-4o-chat-completions.j2 +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/.env.example +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/README.md +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/rules.yml +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mkdocs.yml +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/__init__.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/config.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/display.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/exceptions.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/identifiers.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/intent.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/lifespan.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/llm/__init__.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/llm/ollama.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/main.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/middleware.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/routers/__init__.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/routers/catchall.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/routers/homepage.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/__init__.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/base.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/create_mixin.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/factory.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/filefixtures.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/telemetry.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/templating.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/__init__.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/conftest.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/fixtures/__init__.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/fixtures/proxyrules.yml +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/fixtures/templates/__init__.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/llm/__init__.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/llm/test_ollama.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/routers/__init__.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/routers/test_catchall.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/routers/test_homepage.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/strategies/__init__.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/strategies/test_create_mixin.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/strategies/test_filefixtures.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_display.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_identifiers.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_intent.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_middleware.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_telemetry.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_templating.py +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/dependency_links.txt +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/entry_points.txt +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/requires.txt +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/top_level.txt +0 -0
- {mockstack-0.10.0 → mockstack-0.11.0}/pyproject.toml +0 -0
- {mockstack-0.10.0 → 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 --------------------------------
|
|
@@ -12,6 +12,17 @@ PROXYRULES_FILE_TEMPLATE_PREFIX = "file:///"
|
|
|
12
12
|
|
|
13
13
|
SENSITIVE_HEADERS = ["authorization", "cookie", "set-cookie"]
|
|
14
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
|
+
|
|
15
26
|
|
|
16
27
|
class ProxyRulesRedirectVia(StrEnum):
|
|
17
28
|
"""The type of redirect to use for the proxy rules strategy.
|
|
@@ -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
|
+
}
|
|
@@ -1,131 +1,41 @@
|
|
|
1
1
|
"""Strategy for using proxy rules."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
import re
|
|
5
|
-
from abc import ABC, abstractmethod
|
|
6
|
-
from dataclasses import dataclass
|
|
7
4
|
from functools import cached_property
|
|
8
5
|
from pathlib import Path
|
|
9
|
-
from typing import Self
|
|
10
6
|
from urllib.parse import urlparse
|
|
11
7
|
|
|
12
8
|
import httpx
|
|
13
9
|
import yaml
|
|
14
10
|
from fastapi import Request, Response, status
|
|
15
11
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
12
|
+
from httpx import Headers as ResponseHeaders
|
|
16
13
|
from jinja2 import Environment
|
|
17
14
|
from starlette.datastructures import Headers
|
|
18
15
|
|
|
19
16
|
from mockstack.config import Settings
|
|
20
|
-
from mockstack.constants import
|
|
17
|
+
from mockstack.constants import CONTENT_ENCODING_COMPRESSED, ProxyRulesRedirectVia
|
|
21
18
|
from mockstack.intent import looks_like_a_create
|
|
19
|
+
from mockstack.rules import Rule, TemplateRuleResult, URLRuleResult
|
|
22
20
|
from mockstack.strategies.base import BaseStrategy
|
|
23
21
|
from mockstack.strategies.create_mixin import CreateMixin
|
|
24
|
-
from mockstack.templating import
|
|
25
|
-
templates_env_provider,
|
|
26
|
-
parse_template_name_segments_and_identifiers,
|
|
27
|
-
)
|
|
22
|
+
from mockstack.templating import templates_env_provider
|
|
28
23
|
|
|
29
24
|
|
|
30
|
-
|
|
31
|
-
|
|
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
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
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
|
-
}
|
|
38
|
+
return _headers
|
|
129
39
|
|
|
130
40
|
|
|
131
41
|
class ProxyRulesStrategy(BaseStrategy, CreateMixin):
|
|
@@ -299,12 +209,16 @@ class ProxyRulesStrategy(BaseStrategy, CreateMixin):
|
|
|
299
209
|
|
|
300
210
|
resp = await client.send(req, stream=False)
|
|
301
211
|
content = resp.read()
|
|
212
|
+
response_headers = maybe_update_response_headers(
|
|
213
|
+
resp.headers,
|
|
214
|
+
request_headers=request.headers,
|
|
215
|
+
)
|
|
302
216
|
|
|
303
217
|
return Response(
|
|
304
218
|
content=content,
|
|
305
219
|
status_code=resp.status_code,
|
|
306
|
-
headers=
|
|
307
|
-
media_type=
|
|
220
|
+
headers=response_headers,
|
|
221
|
+
media_type=response_headers.get("content-type"),
|
|
308
222
|
)
|
|
309
223
|
|
|
310
224
|
def reverse_proxy_headers(self, headers: Headers, url: str) -> Headers:
|
|
@@ -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
|
|
@@ -12,143 +13,10 @@ from mockstack.constants import ProxyRulesRedirectVia
|
|
|
12
13
|
from mockstack.strategies.proxyrules import (
|
|
13
14
|
ProxyRulesStrategy,
|
|
14
15
|
Rule,
|
|
15
|
-
|
|
16
|
-
TemplateRuleResult,
|
|
16
|
+
maybe_update_response_headers,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def test_rule_from_dict():
|
|
21
|
-
"""Test creating a Rule from a dictionary."""
|
|
22
|
-
data = {
|
|
23
|
-
"pattern": r"/api/v1/projects/(\d+)",
|
|
24
|
-
"replacement": r"/projects/\1",
|
|
25
|
-
"method": "GET",
|
|
26
|
-
}
|
|
27
|
-
rule = Rule.from_dict(data)
|
|
28
|
-
assert rule.pattern == data["pattern"]
|
|
29
|
-
assert rule.replacement == data["replacement"]
|
|
30
|
-
assert rule.method == data["method"]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_rule_from_dict_without_method():
|
|
34
|
-
"""Test creating a Rule from a dictionary without a method."""
|
|
35
|
-
data = {
|
|
36
|
-
"pattern": r"/api/v1/projects/(\d+)",
|
|
37
|
-
"replacement": r"/projects/\1",
|
|
38
|
-
}
|
|
39
|
-
rule = Rule.from_dict(data)
|
|
40
|
-
assert rule.pattern == data["pattern"]
|
|
41
|
-
assert rule.replacement == data["replacement"]
|
|
42
|
-
assert rule.method is None
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
@pytest.mark.parametrize(
|
|
46
|
-
"pattern,path,method,expected",
|
|
47
|
-
[
|
|
48
|
-
(r"/api/v1/projects/\d+", "/api/v1/projects/123", "GET", True),
|
|
49
|
-
(r"/api/v1/projects/\d+", "/api/v1/projects/123", "POST", True),
|
|
50
|
-
(r"/api/v1/projects/\d+", "/api/v1/projects/abc", "GET", False),
|
|
51
|
-
(r"/api/v1/projects/\d+", "/api/v1/users/123", "GET", False),
|
|
52
|
-
(r"/api/v1/projects/\d+", "/api/v1/projects/123", "POST", True),
|
|
53
|
-
],
|
|
54
|
-
)
|
|
55
|
-
def test_rule_matches(pattern, path, method, expected):
|
|
56
|
-
"""Test the rule matching logic."""
|
|
57
|
-
rule = Rule(pattern=pattern, replacement="", method=None)
|
|
58
|
-
request = Request(
|
|
59
|
-
scope={
|
|
60
|
-
"type": "http",
|
|
61
|
-
"method": method,
|
|
62
|
-
"path": path,
|
|
63
|
-
"query_string": b"",
|
|
64
|
-
"headers": [],
|
|
65
|
-
}
|
|
66
|
-
)
|
|
67
|
-
assert rule.matches(request) == expected
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@pytest.mark.parametrize(
|
|
71
|
-
"pattern,path,method,expected",
|
|
72
|
-
[
|
|
73
|
-
(r"/api/v1/projects/\d+", "/api/v1/projects/123", "GET", True),
|
|
74
|
-
(r"/api/v1/projects/\d+", "/api/v1/projects/123", "POST", False),
|
|
75
|
-
],
|
|
76
|
-
)
|
|
77
|
-
def test_rule_matches_with_method(pattern, path, method, expected):
|
|
78
|
-
"""Test the rule matching logic with method restriction."""
|
|
79
|
-
rule = Rule(pattern=pattern, replacement="", method="GET")
|
|
80
|
-
request = Request(
|
|
81
|
-
scope={
|
|
82
|
-
"type": "http",
|
|
83
|
-
"method": method,
|
|
84
|
-
"path": path,
|
|
85
|
-
"query_string": b"",
|
|
86
|
-
"headers": [],
|
|
87
|
-
}
|
|
88
|
-
)
|
|
89
|
-
assert rule.matches(request) == expected
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
@pytest.mark.parametrize(
|
|
93
|
-
"pattern,replacement,path,expected_url",
|
|
94
|
-
[
|
|
95
|
-
(
|
|
96
|
-
r"/api/v1/projects/(\d+)",
|
|
97
|
-
r"/projects/\1",
|
|
98
|
-
"/api/v1/projects/123",
|
|
99
|
-
"/projects/123",
|
|
100
|
-
),
|
|
101
|
-
(
|
|
102
|
-
r"/api/v1/users/([^/]+)",
|
|
103
|
-
r"/users/\1",
|
|
104
|
-
"/api/v1/users/john",
|
|
105
|
-
"/users/john",
|
|
106
|
-
),
|
|
107
|
-
],
|
|
108
|
-
)
|
|
109
|
-
def test_rule_apply(pattern, replacement, path, expected_url):
|
|
110
|
-
"""Test the rule application logic."""
|
|
111
|
-
rule = Rule(pattern=pattern, replacement=replacement)
|
|
112
|
-
request = Request(
|
|
113
|
-
scope={
|
|
114
|
-
"type": "http",
|
|
115
|
-
"method": "GET",
|
|
116
|
-
"path": path,
|
|
117
|
-
"query_string": b"",
|
|
118
|
-
"headers": [],
|
|
119
|
-
}
|
|
120
|
-
)
|
|
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"
|
|
150
|
-
|
|
151
|
-
|
|
152
20
|
def test_proxy_rules_strategy_load_rules(settings):
|
|
153
21
|
"""Test loading rules from the rules file."""
|
|
154
22
|
strategy = ProxyRulesStrategy(settings)
|
|
@@ -271,8 +139,6 @@ def test_proxy_rules_strategy_get_content_type(settings):
|
|
|
271
139
|
"""Test content type detection based on file extension."""
|
|
272
140
|
strategy = ProxyRulesStrategy(settings)
|
|
273
141
|
|
|
274
|
-
from pathlib import Path
|
|
275
|
-
|
|
276
142
|
# Test various file extensions
|
|
277
143
|
assert strategy._get_content_type(Path("file.json")) == "application/json"
|
|
278
144
|
assert strategy._get_content_type(Path("file.xml")) == "application/xml"
|
|
@@ -492,3 +358,16 @@ async def test_proxy_rules_strategy_reverse_proxy(settings_reverse_proxy, span):
|
|
|
492
358
|
assert isinstance(headers, Headers)
|
|
493
359
|
|
|
494
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"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Unit-tests for the rules module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi import Request
|
|
5
|
+
|
|
6
|
+
from mockstack.rules import Rule, TemplateRuleResult, URLRuleResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_rule_from_dict():
|
|
10
|
+
"""Test creating a Rule from a dictionary."""
|
|
11
|
+
data = {
|
|
12
|
+
"pattern": r"/api/v1/projects/(\d+)",
|
|
13
|
+
"replacement": r"/projects/\1",
|
|
14
|
+
"method": "GET",
|
|
15
|
+
}
|
|
16
|
+
rule = Rule.from_dict(data)
|
|
17
|
+
assert rule.pattern == data["pattern"]
|
|
18
|
+
assert rule.replacement == data["replacement"]
|
|
19
|
+
assert rule.method == data["method"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_rule_from_dict_without_method():
|
|
23
|
+
"""Test creating a Rule from a dictionary without a method."""
|
|
24
|
+
data = {
|
|
25
|
+
"pattern": r"/api/v1/projects/(\d+)",
|
|
26
|
+
"replacement": r"/projects/\1",
|
|
27
|
+
}
|
|
28
|
+
rule = Rule.from_dict(data)
|
|
29
|
+
assert rule.pattern == data["pattern"]
|
|
30
|
+
assert rule.replacement == data["replacement"]
|
|
31
|
+
assert rule.method is None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize(
|
|
35
|
+
"pattern,path,method,expected",
|
|
36
|
+
[
|
|
37
|
+
(r"/api/v1/projects/\d+", "/api/v1/projects/123", "GET", True),
|
|
38
|
+
(r"/api/v1/projects/\d+", "/api/v1/projects/123", "POST", True),
|
|
39
|
+
(r"/api/v1/projects/\d+", "/api/v1/projects/abc", "GET", False),
|
|
40
|
+
(r"/api/v1/projects/\d+", "/api/v1/users/123", "GET", False),
|
|
41
|
+
(r"/api/v1/projects/\d+", "/api/v1/projects/123", "POST", True),
|
|
42
|
+
],
|
|
43
|
+
)
|
|
44
|
+
def test_rule_matches(pattern, path, method, expected):
|
|
45
|
+
"""Test the rule matching logic."""
|
|
46
|
+
rule = Rule(pattern=pattern, replacement="", method=None)
|
|
47
|
+
request = Request(
|
|
48
|
+
scope={
|
|
49
|
+
"type": "http",
|
|
50
|
+
"method": method,
|
|
51
|
+
"path": path,
|
|
52
|
+
"query_string": b"",
|
|
53
|
+
"headers": [],
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
assert rule.matches(request) == expected
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize(
|
|
60
|
+
"pattern,path,method,expected",
|
|
61
|
+
[
|
|
62
|
+
(r"/api/v1/projects/\d+", "/api/v1/projects/123", "GET", True),
|
|
63
|
+
(r"/api/v1/projects/\d+", "/api/v1/projects/123", "POST", False),
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
def test_rule_matches_with_method(pattern, path, method, expected):
|
|
67
|
+
"""Test the rule matching logic with method restriction."""
|
|
68
|
+
rule = Rule(pattern=pattern, replacement="", method="GET")
|
|
69
|
+
request = Request(
|
|
70
|
+
scope={
|
|
71
|
+
"type": "http",
|
|
72
|
+
"method": method,
|
|
73
|
+
"path": path,
|
|
74
|
+
"query_string": b"",
|
|
75
|
+
"headers": [],
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
assert rule.matches(request) == expected
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize(
|
|
82
|
+
"pattern,replacement,path,expected_url",
|
|
83
|
+
[
|
|
84
|
+
(
|
|
85
|
+
r"/api/v1/projects/(\d+)",
|
|
86
|
+
r"/projects/\1",
|
|
87
|
+
"/api/v1/projects/123",
|
|
88
|
+
"/projects/123",
|
|
89
|
+
),
|
|
90
|
+
(
|
|
91
|
+
r"/api/v1/users/([^/]+)",
|
|
92
|
+
r"/users/\1",
|
|
93
|
+
"/api/v1/users/john",
|
|
94
|
+
"/users/john",
|
|
95
|
+
),
|
|
96
|
+
],
|
|
97
|
+
)
|
|
98
|
+
def test_rule_apply(pattern, replacement, path, expected_url):
|
|
99
|
+
"""Test the rule application logic."""
|
|
100
|
+
rule = Rule(pattern=pattern, replacement=replacement)
|
|
101
|
+
request = Request(
|
|
102
|
+
scope={
|
|
103
|
+
"type": "http",
|
|
104
|
+
"method": "GET",
|
|
105
|
+
"path": path,
|
|
106
|
+
"query_string": b"",
|
|
107
|
+
"headers": [],
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
result = rule.apply(request)
|
|
111
|
+
assert isinstance(result, URLRuleResult)
|
|
112
|
+
assert result.get_result_type() == "url"
|
|
113
|
+
assert result.url == expected_url
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_rule_apply_template():
|
|
117
|
+
"""Test the rule application logic for template files."""
|
|
118
|
+
rule = Rule(
|
|
119
|
+
pattern=r"/api/v1/projects/(\d+)",
|
|
120
|
+
replacement=r"file:///path/to/template.json",
|
|
121
|
+
)
|
|
122
|
+
request = Request(
|
|
123
|
+
scope={
|
|
124
|
+
"type": "http",
|
|
125
|
+
"method": "GET",
|
|
126
|
+
"path": "/api/v1/projects/1234",
|
|
127
|
+
"query_string": b"",
|
|
128
|
+
"headers": [],
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
result = rule.apply(request)
|
|
132
|
+
assert isinstance(result, TemplateRuleResult)
|
|
133
|
+
assert result.get_result_type() == "template"
|
|
134
|
+
assert result.template_path == "/path/to/template.json"
|
|
135
|
+
assert result.template_context is not None
|
|
136
|
+
# The context should contain the extracted project ID from the path
|
|
137
|
+
assert "projects" in result.template_context
|
|
138
|
+
assert result.template_context["projects"] == "1234"
|
|
@@ -48,6 +48,7 @@ mockstack/intent.py
|
|
|
48
48
|
mockstack/lifespan.py
|
|
49
49
|
mockstack/main.py
|
|
50
50
|
mockstack/middleware.py
|
|
51
|
+
mockstack/rules.py
|
|
51
52
|
mockstack/telemetry.py
|
|
52
53
|
mockstack/templating.py
|
|
53
54
|
mockstack.egg-info/PKG-INFO
|
|
@@ -73,6 +74,7 @@ mockstack/tests/test_display.py
|
|
|
73
74
|
mockstack/tests/test_identifiers.py
|
|
74
75
|
mockstack/tests/test_intent.py
|
|
75
76
|
mockstack/tests/test_middleware.py
|
|
77
|
+
mockstack/tests/test_rules.py
|
|
76
78
|
mockstack/tests/test_telemetry.py
|
|
77
79
|
mockstack/tests/test_templating.py
|
|
78
80
|
mockstack/tests/fixtures/__init__.py
|