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.
- mimicker-2.2.3/PKG-INFO +102 -0
- mimicker-2.2.3/README.md +75 -0
- mimicker-2.2.3/mimicker/cli.py +274 -0
- mimicker-2.2.3/mimicker/config.py +101 -0
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/handler.py +23 -2
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/regex.py +15 -15
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/server.py +29 -11
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/stub_group.py +25 -8
- mimicker-2.2.3/mimicker/tracking.py +76 -0
- {mimicker-2.1.3 → mimicker-2.2.3}/pyproject.toml +12 -3
- mimicker-2.1.3/PKG-INFO +0 -533
- mimicker-2.1.3/README.md +0 -508
- {mimicker-2.1.3 → mimicker-2.2.3}/LICENSE +0 -0
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/__init__.py +0 -0
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/exceptions.py +0 -0
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/logger.py +0 -0
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/mimicker.py +0 -0
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/rate_limit.py +0 -0
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/route.py +0 -0
- {mimicker-2.1.3 → mimicker-2.2.3}/mimicker/sequence.py +0 -0
mimicker-2.2.3/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/mimickerhq/mimicker/actions/workflows/test.yml)
|
|
41
|
+
[](https://pypi.org/project/mimicker/)
|
|
42
|
+
[](https://pepy.tech/project/mimicker)
|
|
43
|
+
[](https://github.com/mimickerhq/mimicker/commits/main)
|
|
44
|
+
[](https://codecov.io/gh/mimickerhq/mimicker)
|
|
45
|
+
[](http://doge.mit-license.org)
|
|
46
|
+

|
|
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
|
+
|
mimicker-2.2.3/README.md
ADDED
|
@@ -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
|
+
[](https://github.com/mimickerhq/mimicker/actions/workflows/test.yml)
|
|
15
|
+
[](https://pypi.org/project/mimicker/)
|
|
16
|
+
[](https://pepy.tech/project/mimicker)
|
|
17
|
+
[](https://github.com/mimickerhq/mimicker/commits/main)
|
|
18
|
+
[](https://codecov.io/gh/mimickerhq/mimicker)
|
|
19
|
+
[](http://doge.mit-license.org)
|
|
20
|
+

|
|
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" | **{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,
|
|
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(
|
|
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
|
|