mimicker 2.1.3__tar.gz → 2.2.3__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.
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: mimicker
3
+ Version: 2.2.3
4
+ Summary: A lightweight HTTP mocking server for Python
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: http,mocking,testing,mock-server,stubbing,ci-cd,http-server,testing-tools,stub-server,purepython
8
+ Author: Amazia Gur
9
+ Author-email: amaziagur@gmail.com
10
+ Requires-Python: >=3.7,<4.0
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.7
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Requires-Dist: colorlog (>=6.9.0,<7.0.0)
22
+ Requires-Dist: pyyaml (>=6.0,<7.0)
23
+ Project-URL: Homepage, https://github.com/mimickerhq/mimicker
24
+ Project-URL: Repository, https://github.com/mimickerhq/mimicker
25
+ Description-Content-Type: text/markdown
26
+
27
+ <p align="center">
28
+ <img src="https://raw.githubusercontent.com/mimickerhq/mimicker/main/mimicker.jpg" alt="Mimicker logo"
29
+ style="width: 200px; height: auto; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); border: 2px solid black;">
30
+ </p>
31
+
32
+ <div align="center">
33
+
34
+ > **Mimicker** – Your lightweight, Python-native HTTP mocking server.
35
+
36
+ </div>
37
+
38
+ <div align="center">
39
+
40
+ [![Mimicker Tests](https://github.com/mimickerhq/mimicker/actions/workflows/test.yml/badge.svg)](https://github.com/mimickerhq/mimicker/actions/workflows/test.yml)
41
+ [![PyPI Version](https://img.shields.io/pypi/v/mimicker.svg)](https://pypi.org/project/mimicker/)
42
+ [![Downloads](https://pepy.tech/badge/mimicker)](https://pepy.tech/project/mimicker)
43
+ [![Last Commit](https://img.shields.io/github/last-commit/mimickerhq/mimicker.svg)](https://github.com/mimickerhq/mimicker/commits/main)
44
+ [![Codecov Coverage](https://codecov.io/gh/mimickerhq/mimicker/branch/main/graph/badge.svg?token=YOUR_CODECOV_TOKEN)](https://codecov.io/gh/mimickerhq/mimicker)
45
+ [![License](http://img.shields.io/:license-apache2.0-red.svg)](http://doge.mit-license.org)
46
+ ![Poetry](https://img.shields.io/badge/managed%20with-poetry-blue)
47
+
48
+ </div>
49
+
50
+ ---
51
+
52
+ Mimicker is a Python-native HTTP mocking server — no third-party runtime dependencies, ideal for integration tests and CI.
53
+
54
+ ## Quick example
55
+
56
+ ```python
57
+ from mimicker.mimicker import mimicker, get
58
+
59
+ mimicker(8080).routes(
60
+ get("/hello").status(200).body({"message": "Hello, World!"})
61
+ )
62
+ ```
63
+
64
+ Or use a YAML config file — no Python needed:
65
+
66
+ ```yaml
67
+ routes:
68
+ - method: GET
69
+ path: /hello
70
+ status: 200
71
+ body:
72
+ message: Hello, World!
73
+ ```
74
+
75
+ ```bash
76
+ mimicker serve --config stubs.yaml
77
+ ```
78
+
79
+ ## Install
80
+
81
+ ```bash
82
+ pip install mimicker
83
+ ```
84
+
85
+ ## Documentation
86
+
87
+ Full docs at **[mimickerhq.github.io/mimicker](https://mimickerhq.github.io/mimicker)**
88
+
89
+ - [Quickstart](https://mimickerhq.github.io/mimicker/getting-started/quickstart/)
90
+ - [Stubbing Guide](https://mimickerhq.github.io/mimicker/guides/stubbing-guide/) — YAML, Python, and CLI side-by-side
91
+ - [Path & Query Params](https://mimickerhq.github.io/mimicker/guides/path-and-query-params/)
92
+ - [Dynamic Responses](https://mimickerhq.github.io/mimicker/guides/dynamic-responses/)
93
+ - [Docker](https://mimickerhq.github.io/mimicker/ci-cd/docker/)
94
+ - [GitHub Actions](https://mimickerhq.github.io/mimicker/ci-cd/github-actions/)
95
+ - [CLI Reference](https://mimickerhq.github.io/mimicker/reference/cli-reference/)
96
+ - [Python API](https://mimickerhq.github.io/mimicker/reference/python-api/)
97
+
98
+ ## Community
99
+
100
+ - [Slack](https://join.slack.com/t/mimicker/shared_invite/zt-2yr7vubw4-8Y09YyxZ5j~G2tlQ5uOXKw)
101
+ - [Issues](https://github.com/mimickerhq/mimicker/issues)
102
+
@@ -0,0 +1,75 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/mimickerhq/mimicker/main/mimicker.jpg" alt="Mimicker logo"
3
+ style="width: 200px; height: auto; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); border: 2px solid black;">
4
+ </p>
5
+
6
+ <div align="center">
7
+
8
+ > **Mimicker** – Your lightweight, Python-native HTTP mocking server.
9
+
10
+ </div>
11
+
12
+ <div align="center">
13
+
14
+ [![Mimicker Tests](https://github.com/mimickerhq/mimicker/actions/workflows/test.yml/badge.svg)](https://github.com/mimickerhq/mimicker/actions/workflows/test.yml)
15
+ [![PyPI Version](https://img.shields.io/pypi/v/mimicker.svg)](https://pypi.org/project/mimicker/)
16
+ [![Downloads](https://pepy.tech/badge/mimicker)](https://pepy.tech/project/mimicker)
17
+ [![Last Commit](https://img.shields.io/github/last-commit/mimickerhq/mimicker.svg)](https://github.com/mimickerhq/mimicker/commits/main)
18
+ [![Codecov Coverage](https://codecov.io/gh/mimickerhq/mimicker/branch/main/graph/badge.svg?token=YOUR_CODECOV_TOKEN)](https://codecov.io/gh/mimickerhq/mimicker)
19
+ [![License](http://img.shields.io/:license-apache2.0-red.svg)](http://doge.mit-license.org)
20
+ ![Poetry](https://img.shields.io/badge/managed%20with-poetry-blue)
21
+
22
+ </div>
23
+
24
+ ---
25
+
26
+ Mimicker is a Python-native HTTP mocking server — no third-party runtime dependencies, ideal for integration tests and CI.
27
+
28
+ ## Quick example
29
+
30
+ ```python
31
+ from mimicker.mimicker import mimicker, get
32
+
33
+ mimicker(8080).routes(
34
+ get("/hello").status(200).body({"message": "Hello, World!"})
35
+ )
36
+ ```
37
+
38
+ Or use a YAML config file — no Python needed:
39
+
40
+ ```yaml
41
+ routes:
42
+ - method: GET
43
+ path: /hello
44
+ status: 200
45
+ body:
46
+ message: Hello, World!
47
+ ```
48
+
49
+ ```bash
50
+ mimicker serve --config stubs.yaml
51
+ ```
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install mimicker
57
+ ```
58
+
59
+ ## Documentation
60
+
61
+ Full docs at **[mimickerhq.github.io/mimicker](https://mimickerhq.github.io/mimicker)**
62
+
63
+ - [Quickstart](https://mimickerhq.github.io/mimicker/getting-started/quickstart/)
64
+ - [Stubbing Guide](https://mimickerhq.github.io/mimicker/guides/stubbing-guide/) — YAML, Python, and CLI side-by-side
65
+ - [Path & Query Params](https://mimickerhq.github.io/mimicker/guides/path-and-query-params/)
66
+ - [Dynamic Responses](https://mimickerhq.github.io/mimicker/guides/dynamic-responses/)
67
+ - [Docker](https://mimickerhq.github.io/mimicker/ci-cd/docker/)
68
+ - [GitHub Actions](https://mimickerhq.github.io/mimicker/ci-cd/github-actions/)
69
+ - [CLI Reference](https://mimickerhq.github.io/mimicker/reference/cli-reference/)
70
+ - [Python API](https://mimickerhq.github.io/mimicker/reference/python-api/)
71
+
72
+ ## Community
73
+
74
+ - [Slack](https://join.slack.com/t/mimicker/shared_invite/zt-2yr7vubw4-8Y09YyxZ5j~G2tlQ5uOXKw)
75
+ - [Issues](https://github.com/mimickerhq/mimicker/issues)
@@ -0,0 +1,274 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import re
5
+ import signal
6
+ import sys
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ from typing import Optional
11
+
12
+ from mimicker.config import build_routes, load_config, validate_config
13
+ from mimicker.logger import get_logger
14
+ from mimicker.mimicker import mimicker
15
+ from mimicker.route import Route
16
+
17
+ _HEALTH_PATH = "/__mimicker__/health"
18
+ _REPORT_PATH = "/__mimicker__/report"
19
+ _AUTO_CONFIG_PATH = "/config/stubs.yaml"
20
+
21
+
22
+ def cmd_serve(args):
23
+ port = args.port
24
+ routes = []
25
+
26
+ # Auto-detect config if not specified
27
+ config_path = args.config
28
+ if not config_path and os.path.exists(_AUTO_CONFIG_PATH):
29
+ config_path = _AUTO_CONFIG_PATH
30
+ get_logger().info("Auto-loading config from %s", _AUTO_CONFIG_PATH)
31
+
32
+ if config_path:
33
+ try:
34
+ data = load_config(config_path)
35
+ except FileNotFoundError:
36
+ print(f"[ERROR] Config file not found: {config_path}", file=sys.stderr)
37
+ sys.exit(1)
38
+ except Exception as e:
39
+ print(f"[ERROR] Failed to parse config file: {e}", file=sys.stderr)
40
+ sys.exit(1)
41
+
42
+ errors = validate_config(data)
43
+ if errors:
44
+ for e in errors:
45
+ print(f"[ERROR] {e}", file=sys.stderr)
46
+ sys.exit(1)
47
+
48
+ routes = build_routes(data)
49
+ if port is None:
50
+ port = int(data.get("port", 8080))
51
+
52
+ if port is None:
53
+ port = 8080
54
+
55
+ if args.stub:
56
+ route = _parse_inline_stub(args.stub)
57
+ routes.append(route)
58
+
59
+ server = mimicker(port)
60
+ if routes:
61
+ server.routes(*routes)
62
+
63
+ get_logger().info("Serving on port %d. Press Ctrl+C to stop.", port)
64
+
65
+ def _shutdown(*_):
66
+ server.shutdown()
67
+ sys.exit(0)
68
+
69
+ signal.signal(signal.SIGTERM, _shutdown)
70
+ try:
71
+ server._thread.join()
72
+ except KeyboardInterrupt:
73
+ server.shutdown()
74
+
75
+
76
+ def cmd_wait(args):
77
+ url = args.url.rstrip("/") + _HEALTH_PATH
78
+ deadline = time.monotonic() + args.timeout
79
+ last_error: Optional[Exception] = None
80
+
81
+ while time.monotonic() < deadline:
82
+ try:
83
+ with urllib.request.urlopen(url, timeout=1) as resp:
84
+ if resp.status == 200:
85
+ print(f"[OK] Mimicker is ready at {args.url}")
86
+ sys.exit(0)
87
+ except Exception as exc:
88
+ last_error = exc
89
+ time.sleep(0.2)
90
+
91
+ print(
92
+ f"[ERROR] Mimicker not ready after {args.timeout}s at {args.url}: {last_error}",
93
+ file=sys.stderr,
94
+ )
95
+ sys.exit(1)
96
+
97
+
98
+ def cmd_validate(args):
99
+ try:
100
+ data = load_config(args.file)
101
+ except FileNotFoundError:
102
+ print(f"[ERROR] File not found: {args.file}", file=sys.stderr)
103
+ sys.exit(1)
104
+ except Exception as e:
105
+ print(f"[ERROR] Failed to parse config: {e}", file=sys.stderr)
106
+ sys.exit(1)
107
+
108
+ errors = validate_config(data)
109
+ if errors:
110
+ for e in errors:
111
+ print(f"[ERROR] {e}", file=sys.stderr)
112
+ print(f"\nValidation failed with {len(errors)} error(s).", file=sys.stderr)
113
+ sys.exit(1)
114
+
115
+ route_count = len(data.get("routes", []))
116
+ print(f"[OK] {args.file} is valid ({route_count} route(s) defined)")
117
+
118
+
119
+ def cmd_report(args):
120
+ url = args.url.rstrip("/") + _REPORT_PATH
121
+ try:
122
+ with urllib.request.urlopen(url, timeout=5) as resp:
123
+ data = json.loads(resp.read())
124
+ except Exception as e:
125
+ print(f"[ERROR] Could not reach Mimicker at {args.url}: {e}", file=sys.stderr)
126
+ sys.exit(1)
127
+
128
+ fmt = args.format
129
+ if fmt == "json":
130
+ print(json.dumps(data, indent=2))
131
+ elif fmt == "github-summary":
132
+ _print_github_summary(data)
133
+ else:
134
+ _print_text_report(data)
135
+
136
+ if args.fail_on_unmatched and data["summary"]["unmatched_requests"] > 0:
137
+ sys.exit(1)
138
+
139
+
140
+ # ── formatters ────────────────────────────────────────────────────────────────
141
+
142
+ def _print_text_report(data: dict):
143
+ s = data["summary"]
144
+ print(f"\nMimicker Report")
145
+ print(f" Stubs : {s['matched_stubs']}/{s['total_stubs']} exercised")
146
+ print(f" Unmatched : {s['unmatched_requests']} request(s)")
147
+
148
+ if data["unused_stubs"]:
149
+ print("\nUnused stubs (never hit):")
150
+ for stub in data["unused_stubs"]:
151
+ print(f" - {stub['method']} {stub['path']}")
152
+
153
+ if data["unmatched_requests"]:
154
+ print("\nUnmatched requests (contract drift):")
155
+ for req in data["unmatched_requests"]:
156
+ print(f" - {req['method']} {req['path']} [{req['timestamp']}]")
157
+
158
+
159
+ def _print_github_summary(data: dict):
160
+ s = data["summary"]
161
+ print("## Mimicker Stub Coverage\n")
162
+ status_icon = "✅" if s["unmatched_requests"] == 0 else "⚠️"
163
+ print(
164
+ f"{status_icon} **{s['matched_stubs']}/{s['total_stubs']}** stubs exercised"
165
+ f" &nbsp;|&nbsp; **{s['unmatched_requests']}** unmatched request(s)\n"
166
+ )
167
+
168
+ print("### Stub Coverage\n")
169
+ print("| Method | Path | Hits |")
170
+ print("|--------|------|-----:|")
171
+ for stub in data["stubs"]:
172
+ icon = "✅" if stub["hit_count"] > 0 else "❌"
173
+ print(f"| `{stub['method']}` | `{stub['path']}` | {icon} {stub['hit_count']} |")
174
+
175
+ if data["unmatched_requests"]:
176
+ print("\n### Unmatched Requests (Contract Drift)\n")
177
+ print("| Method | Path | Timestamp |")
178
+ print("|--------|------|-----------|")
179
+ for req in data["unmatched_requests"]:
180
+ print(f"| `{req['method']}` | `{req['path']}` | {req['timestamp']} |")
181
+
182
+
183
+ # ── inline stub parser ────────────────────────────────────────────────────────
184
+
185
+ def _parse_inline_stub(stub_str: str) -> Route:
186
+ """Parse inline stub syntax: 'METHOD /path -> STATUS {json_body}'"""
187
+ m = re.match(r'^(\w+)\s+(/\S*)\s+->\s+(\d+)(?:\s+(.+))?$', stub_str.strip())
188
+ if not m:
189
+ print(
190
+ "[ERROR] Invalid --stub format. Expected: 'METHOD /path -> STATUS {json}'",
191
+ file=sys.stderr,
192
+ )
193
+ sys.exit(1)
194
+
195
+ method, path, status, body_str = (
196
+ m.group(1).upper(), m.group(2), int(m.group(3)), m.group(4)
197
+ )
198
+ route = Route(method, path).status(status)
199
+ if body_str:
200
+ try:
201
+ route.body(json.loads(body_str))
202
+ except json.JSONDecodeError:
203
+ route.body(body_str)
204
+ return route
205
+
206
+
207
+ # ── entry point ───────────────────────────────────────────────────────────────
208
+
209
+ def main():
210
+ parser = argparse.ArgumentParser(
211
+ prog="mimicker",
212
+ description="Mimicker — lightweight HTTP mock server",
213
+ )
214
+ sub = parser.add_subparsers(dest="command", required=True)
215
+
216
+ # serve
217
+ p_serve = sub.add_parser("serve", help="Start the mock server")
218
+ p_serve.add_argument(
219
+ "--port", type=int, default=None,
220
+ help="Port to listen on (default: 8080, or value from config file)"
221
+ )
222
+ p_serve.add_argument("--config", metavar="FILE", help="YAML or JSON stub config file")
223
+ p_serve.add_argument(
224
+ "--stub", metavar="STUB",
225
+ help="Inline stub: 'METHOD /path -> STATUS {json}'"
226
+ )
227
+
228
+ # wait
229
+ p_wait = sub.add_parser(
230
+ "wait", help="Poll the health endpoint until the server is ready"
231
+ )
232
+ p_wait.add_argument(
233
+ "--url", default="http://localhost:8080",
234
+ help="Server base URL (default: http://localhost:8080)"
235
+ )
236
+ p_wait.add_argument(
237
+ "--timeout", type=float, default=10.0,
238
+ help="Maximum seconds to wait (default: 10)"
239
+ )
240
+
241
+ # validate
242
+ p_val = sub.add_parser(
243
+ "validate", help="Validate a stub config file without starting a server"
244
+ )
245
+ p_val.add_argument("file", help="YAML or JSON stub config file to validate")
246
+
247
+ # report
248
+ p_rep = sub.add_parser(
249
+ "report", help="Fetch and display the stub coverage/drift report"
250
+ )
251
+ p_rep.add_argument(
252
+ "--url", default="http://localhost:8080",
253
+ help="Server base URL (default: http://localhost:8080)"
254
+ )
255
+ p_rep.add_argument(
256
+ "--format", choices=["text", "json", "github-summary"], default="text",
257
+ help="Output format (default: text)"
258
+ )
259
+ p_rep.add_argument(
260
+ "--fail-on-unmatched", action="store_true",
261
+ help="Exit non-zero if any requests were unmatched (for CI gates)"
262
+ )
263
+
264
+ args = parser.parse_args()
265
+ {
266
+ "serve": cmd_serve,
267
+ "wait": cmd_wait,
268
+ "validate": cmd_validate,
269
+ "report": cmd_report,
270
+ }[args.command](args)
271
+
272
+
273
+ if __name__ == "__main__":
274
+ main()
@@ -0,0 +1,101 @@
1
+ import json
2
+ import os
3
+ from typing import Any, Dict, List
4
+
5
+ from mimicker.route import Route
6
+ from mimicker.sequence import SequenceStep
7
+
8
+
9
+ _VALID_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH"}
10
+
11
+
12
+ def load_config(path: str) -> Dict[str, Any]:
13
+ """Load and parse a YAML or JSON stub config file."""
14
+ _, ext = os.path.splitext(path.lower())
15
+ with open(path) as f:
16
+ if ext in (".yaml", ".yml"):
17
+ try:
18
+ import yaml
19
+ except ImportError:
20
+ raise ImportError(
21
+ "PyYAML is required to load YAML config files. "
22
+ "Install it with: pip install pyyaml"
23
+ )
24
+ data = yaml.safe_load(f)
25
+ else:
26
+ data = json.load(f)
27
+ return data or {}
28
+
29
+
30
+ def validate_config(data: Dict[str, Any]) -> List[str]:
31
+ """Validate config data. Returns a list of human-readable error messages."""
32
+ errors = []
33
+ routes = data.get("routes", [])
34
+ if not isinstance(routes, list):
35
+ errors.append("'routes' must be a list")
36
+ return errors
37
+
38
+ for i, route in enumerate(routes):
39
+ prefix = f"routes[{i}]"
40
+ if not isinstance(route, dict):
41
+ errors.append(f"{prefix}: must be a mapping")
42
+ continue
43
+ method = str(route.get("method", "")).upper()
44
+ if method not in _VALID_METHODS:
45
+ errors.append(
46
+ f"{prefix}: invalid method {route.get('method')!r}. "
47
+ f"Must be one of {sorted(_VALID_METHODS)}"
48
+ )
49
+ if not route.get("path"):
50
+ errors.append(f"{prefix}: 'path' is required")
51
+ status = route.get("status", 200)
52
+ if not isinstance(status, int) or not (100 <= status <= 599):
53
+ errors.append(f"{prefix}: 'status' must be an HTTP status code integer")
54
+ if "sequence" in route and not isinstance(route["sequence"], list):
55
+ errors.append(f"{prefix}: 'sequence' must be a list of step objects")
56
+
57
+ return errors
58
+
59
+
60
+ def build_routes(data: Dict[str, Any]) -> List[Route]:
61
+ """Convert validated config data into Route objects."""
62
+ routes = []
63
+ for r in data.get("routes", []):
64
+ method = str(r.get("method", "GET")).upper()
65
+ path = r.get("path", "/")
66
+
67
+ # Append explicit query_params to the path so the existing regex engine handles them
68
+ if "query_params" in r and isinstance(r["query_params"], dict):
69
+ qp_str = "&".join(f"{k}={v}" for k, v in r["query_params"].items())
70
+ path = f"{path}?{qp_str}"
71
+
72
+ route = Route(method, path)
73
+
74
+ if "status" in r:
75
+ route.status(int(r["status"]))
76
+ if "body" in r:
77
+ route.body(r["body"])
78
+ if "headers" in r:
79
+ h = r["headers"]
80
+ route.headers(list(h.items()) if isinstance(h, dict) else h)
81
+ if "delay_ms" in r:
82
+ route.delay(float(r["delay_ms"]) / 1000.0)
83
+
84
+ if "sequence" in r:
85
+ steps = []
86
+ for step_data in r["sequence"]:
87
+ s = SequenceStep()
88
+ if "status" in step_data:
89
+ s.status(int(step_data["status"]))
90
+ if "body" in step_data:
91
+ s.body(step_data["body"])
92
+ if "headers" in step_data:
93
+ h = step_data["headers"]
94
+ s.headers(list(h.items()) if isinstance(h, dict) else h)
95
+ if "delay_ms" in step_data:
96
+ s.delay(float(step_data["delay_ms"]) / 1000.0)
97
+ steps.append(s)
98
+ route.sequence(*steps, cycle=bool(r.get("cycle", False)))
99
+
100
+ routes.append(route)
101
+ return routes
@@ -1,12 +1,16 @@
1
1
  import http.server
2
2
  import json
3
3
  from time import sleep
4
- from typing import Any, Tuple, Optional, Dict, List
4
+ from typing import Any, Dict, List, Optional, Tuple
5
5
  from urllib.parse import parse_qs, urlparse
6
6
 
7
7
  from mimicker.logger import get_logger
8
8
  from mimicker.stub_group import Stub, StubGroup
9
9
 
10
+ _HEALTH_PATH = "/__mimicker__/health"
11
+ _REPORT_PATH = "/__mimicker__/report"
12
+ _ADMIN_PREFIX = "/__mimicker__/"
13
+
10
14
 
11
15
  class MimickerHandler(http.server.SimpleHTTPRequestHandler):
12
16
  logger = get_logger()
@@ -34,6 +38,9 @@ class MimickerHandler(http.server.SimpleHTTPRequestHandler):
34
38
  self._handle_request("PATCH")
35
39
 
36
40
  def _handle_request(self, method: str):
41
+ parsed = urlparse(self.path)
42
+ clean_path = parsed.path
43
+
37
44
  request_headers = {key.lower(): value for key, value in self.headers.items()}
38
45
  request_body = self._get_request_body()
39
46
 
@@ -45,8 +52,13 @@ class MimickerHandler(http.server.SimpleHTTPRequestHandler):
45
52
 
46
53
  if matched_stub:
47
54
  self._send_response(matched_stub, method, path_params,
48
- parse_qs(urlparse(self.path).query), request_body,
55
+ parse_qs(parsed.query), request_body,
49
56
  request_headers)
57
+ elif clean_path == _HEALTH_PATH:
58
+ # User stubs take precedence; admin handler is the fallback.
59
+ self._send_admin_json({"status": "up"})
60
+ elif clean_path == _REPORT_PATH:
61
+ self._send_admin_json(self.stub_matcher.tracker.report())
50
62
  else:
51
63
  self.logger.warning("No match for %s %s. Returning 404.", method, self.path)
52
64
  self._send_404_response(method)
@@ -62,6 +74,13 @@ class MimickerHandler(http.server.SimpleHTTPRequestHandler):
62
74
  f"\nBody:\n{body_str}" if body_str else ""
63
75
  )
64
76
 
77
+ def _send_admin_json(self, data: dict):
78
+ body = json.dumps(data).encode("utf-8")
79
+ self.send_response(200)
80
+ self.send_header("Content-Type", "application/json")
81
+ self.end_headers()
82
+ self.wfile.write(body)
83
+
65
84
  def _send_response(self, matched_stub: Stub, method: str,
66
85
  path_params: Dict[str, str],
67
86
  query_params: Dict[str, List[str]], request_body: Any,
@@ -124,6 +143,8 @@ class MimickerHandler(http.server.SimpleHTTPRequestHandler):
124
143
  self.wfile.write(json.dumps(response).encode('utf-8'))
125
144
  elif isinstance(response, str):
126
145
  self.wfile.write(response.encode('utf-8'))
146
+ elif response is None:
147
+ pass
127
148
  else:
128
149
  self.wfile.write(str(response).encode('utf-8'))
129
150