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.
Files changed (94) hide show
  1. {mockstack-0.10.0 → mockstack-0.11.0}/Dockerfile +1 -1
  2. {mockstack-0.10.0 → mockstack-0.11.0}/PKG-INFO +1 -1
  3. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/constants.py +11 -0
  4. mockstack-0.11.0/mockstack/rules.py +112 -0
  5. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/proxyrules.py +22 -108
  6. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/strategies/test_proxyrules.py +15 -136
  7. mockstack-0.11.0/mockstack/tests/test_rules.py +138 -0
  8. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/PKG-INFO +1 -1
  9. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/SOURCES.txt +2 -0
  10. {mockstack-0.10.0 → mockstack-0.11.0}/uv.lock +228 -118
  11. {mockstack-0.10.0 → mockstack-0.11.0}/.dockerignore +0 -0
  12. {mockstack-0.10.0 → mockstack-0.11.0}/.env.example +0 -0
  13. {mockstack-0.10.0 → mockstack-0.11.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {mockstack-0.10.0 → mockstack-0.11.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  15. {mockstack-0.10.0 → mockstack-0.11.0}/.github/workflows/ci.yml +0 -0
  16. {mockstack-0.10.0 → mockstack-0.11.0}/.github/workflows/publish-docs.yml +0 -0
  17. {mockstack-0.10.0 → mockstack-0.11.0}/.github/workflows/publish-to-dockerhub.yml +0 -0
  18. {mockstack-0.10.0 → mockstack-0.11.0}/.github/workflows/publish-to-pypi.yml +0 -0
  19. {mockstack-0.10.0 → mockstack-0.11.0}/.gitignore +0 -0
  20. {mockstack-0.10.0 → mockstack-0.11.0}/.pre-commit-config.yaml +0 -0
  21. {mockstack-0.10.0 → mockstack-0.11.0}/CODE_OF_CONDUCT.md +0 -0
  22. {mockstack-0.10.0 → mockstack-0.11.0}/CONTRIBUTING.md +0 -0
  23. {mockstack-0.10.0 → mockstack-0.11.0}/LICENSE +0 -0
  24. {mockstack-0.10.0 → mockstack-0.11.0}/README.md +0 -0
  25. {mockstack-0.10.0 → mockstack-0.11.0}/SECURITY.md +0 -0
  26. {mockstack-0.10.0 → mockstack-0.11.0}/docker-entrypoint.sh +0 -0
  27. {mockstack-0.10.0 → mockstack-0.11.0}/docs/assets/favicon.ico +0 -0
  28. {mockstack-0.10.0 → mockstack-0.11.0}/docs/assets/logo.png +0 -0
  29. {mockstack-0.10.0 → mockstack-0.11.0}/docs/assets/mockstack.png +0 -0
  30. {mockstack-0.10.0 → mockstack-0.11.0}/docs/configuration.md +0 -0
  31. {mockstack-0.10.0 → mockstack-0.11.0}/docs/ollama.md +0 -0
  32. {mockstack-0.10.0 → mockstack-0.11.0}/docs/strategies/base.md +0 -0
  33. {mockstack-0.10.0 → mockstack-0.11.0}/docs/strategies/filefixtures.md +0 -0
  34. {mockstack-0.10.0 → mockstack-0.11.0}/docs/strategies/proxyrules.md +0 -0
  35. {mockstack-0.10.0 → mockstack-0.11.0}/examples/filefixtures-with-templates/.env.example +0 -0
  36. {mockstack-0.10.0 → mockstack-0.11.0}/examples/filefixtures-with-templates/README.md +0 -0
  37. {mockstack-0.10.0 → mockstack-0.11.0}/examples/filefixtures-with-templates/templates/servicename-api-v1-items.j2 +0 -0
  38. {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
  39. {mockstack-0.10.0 → mockstack-0.11.0}/examples/llm/README.md +0 -0
  40. {mockstack-0.10.0 → mockstack-0.11.0}/examples/llm/mockstack-langchain-example.ipynb +0 -0
  41. {mockstack-0.10.0 → mockstack-0.11.0}/examples/llm/templates/ollama-openai-v1-chat-completions.j2 +0 -0
  42. {mockstack-0.10.0 → mockstack-0.11.0}/examples/llm/templates/openai-v1-chat-completions.j2 +0 -0
  43. {mockstack-0.10.0 → mockstack-0.11.0}/examples/llm/templates/openai-v1-deployments-gpt-4o-chat-completions.j2 +0 -0
  44. {mockstack-0.10.0 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/.env.example +0 -0
  45. {mockstack-0.10.0 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/README.md +0 -0
  46. {mockstack-0.10.0 → mockstack-0.11.0}/examples/proxyrules-with-rules-file/rules.yml +0 -0
  47. {mockstack-0.10.0 → mockstack-0.11.0}/mkdocs.yml +0 -0
  48. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/__init__.py +0 -0
  49. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/config.py +0 -0
  50. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/display.py +0 -0
  51. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/exceptions.py +0 -0
  52. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/identifiers.py +0 -0
  53. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/intent.py +0 -0
  54. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/lifespan.py +0 -0
  55. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/llm/__init__.py +0 -0
  56. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/llm/ollama.py +0 -0
  57. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/main.py +0 -0
  58. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/middleware.py +0 -0
  59. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/routers/__init__.py +0 -0
  60. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/routers/catchall.py +0 -0
  61. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/routers/homepage.py +0 -0
  62. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/__init__.py +0 -0
  63. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/base.py +0 -0
  64. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/create_mixin.py +0 -0
  65. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/factory.py +0 -0
  66. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/strategies/filefixtures.py +0 -0
  67. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/telemetry.py +0 -0
  68. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/templating.py +0 -0
  69. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/__init__.py +0 -0
  70. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/conftest.py +0 -0
  71. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/fixtures/__init__.py +0 -0
  72. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/fixtures/proxyrules.yml +0 -0
  73. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/fixtures/templates/__init__.py +0 -0
  74. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/fixtures/templates/example-template.j2 +0 -0
  75. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/llm/__init__.py +0 -0
  76. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/llm/test_ollama.py +0 -0
  77. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/routers/__init__.py +0 -0
  78. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/routers/test_catchall.py +0 -0
  79. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/routers/test_homepage.py +0 -0
  80. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/strategies/__init__.py +0 -0
  81. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/strategies/test_create_mixin.py +0 -0
  82. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/strategies/test_filefixtures.py +0 -0
  83. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_display.py +0 -0
  84. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_identifiers.py +0 -0
  85. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_intent.py +0 -0
  86. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_middleware.py +0 -0
  87. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_telemetry.py +0 -0
  88. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack/tests/test_templating.py +0 -0
  89. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/dependency_links.txt +0 -0
  90. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/entry_points.txt +0 -0
  91. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/requires.txt +0 -0
  92. {mockstack-0.10.0 → mockstack-0.11.0}/mockstack.egg-info/top_level.txt +0 -0
  93. {mockstack-0.10.0 → mockstack-0.11.0}/pyproject.toml +0 -0
  94. {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.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.10.0
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
@@ -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 PROXYRULES_FILE_TEMPLATE_PREFIX, ProxyRulesRedirectVia
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
- class RuleResult(ABC):
31
- """Base class for rule application results."""
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
- @abstractmethod
34
- def get_result_type(self) -> str:
35
- """Return the type of result."""
36
- pass
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=resp.headers,
307
- media_type=resp.headers.get("content-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
- URLRuleResult,
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mockstack
3
- Version: 0.10.0
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
@@ -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