mian-mohid-mockapi 0.1.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.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: mian-mohid-mockapi
3
+ Version: 0.1.0
4
+ Summary: A local HTTP mock API server driven by YAML specs
5
+ Author: Mian Mohid
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mian-mohid/mockapi
8
+ Keywords: mockapi,yaml,http,server,cli
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: PyYAML>=6.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == "dev"
24
+
25
+ # mockapi
26
+
27
+ `mockapi` is a small YAML-driven HTTP mock server for local development and API simulation.
28
+
29
+ The PyPI distribution name is `mian-mohid-mockapi`, while the CLI command remains `mockapi`.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install mian-mohid-mockapi
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ mockapi serve spec.yaml
41
+ mockapi serve spec.yaml --port 3000
42
+ mockapi validate spec.yaml
43
+ ```
44
+
45
+ ## Spec
46
+
47
+ Supported endpoint fields:
48
+
49
+ - `path`
50
+ - `method`
51
+ - `status_code`
52
+ - `response`
53
+ - `headers` optional
54
+ - `delay` optional in milliseconds
55
+ - `description` optional
56
+ - `auth` optional, including `none`
57
+
58
+ See [requirements/fr.md](requirements/fr.md) for the full functional requirements.
@@ -0,0 +1,34 @@
1
+ # mockapi
2
+
3
+ `mockapi` is a small YAML-driven HTTP mock server for local development and API simulation.
4
+
5
+ The PyPI distribution name is `mian-mohid-mockapi`, while the CLI command remains `mockapi`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install mian-mohid-mockapi
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ mockapi serve spec.yaml
17
+ mockapi serve spec.yaml --port 3000
18
+ mockapi validate spec.yaml
19
+ ```
20
+
21
+ ## Spec
22
+
23
+ Supported endpoint fields:
24
+
25
+ - `path`
26
+ - `method`
27
+ - `status_code`
28
+ - `response`
29
+ - `headers` optional
30
+ - `delay` optional in milliseconds
31
+ - `description` optional
32
+ - `auth` optional, including `none`
33
+
34
+ See [requirements/fr.md](requirements/fr.md) for the full functional requirements.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mian-mohid-mockapi"
7
+ version = "0.1.0"
8
+ description = "A local HTTP mock API server driven by YAML specs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Mian Mohid" }
14
+ ]
15
+ dependencies = [
16
+ "PyYAML>=6.0",
17
+ ]
18
+ keywords = ["mockapi", "yaml", "http", "server", "cli"]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Environment :: Console",
22
+ "Intended Audience :: Developers",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Operating System :: OS Independent",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/mian-mohid/mockapi"
34
+
35
+ [project.scripts]
36
+ mockapi = "mockapi.cli:main"
37
+
38
+ [project.optional-dependencies]
39
+ dev = ["pytest>=8.0"]
40
+
41
+ [tool.setuptools]
42
+ package-dir = {"" = "src"}
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: mian-mohid-mockapi
3
+ Version: 0.1.0
4
+ Summary: A local HTTP mock API server driven by YAML specs
5
+ Author: Mian Mohid
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mian-mohid/mockapi
8
+ Keywords: mockapi,yaml,http,server,cli
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: PyYAML>=6.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == "dev"
24
+
25
+ # mockapi
26
+
27
+ `mockapi` is a small YAML-driven HTTP mock server for local development and API simulation.
28
+
29
+ The PyPI distribution name is `mian-mohid-mockapi`, while the CLI command remains `mockapi`.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install mian-mohid-mockapi
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ mockapi serve spec.yaml
41
+ mockapi serve spec.yaml --port 3000
42
+ mockapi validate spec.yaml
43
+ ```
44
+
45
+ ## Spec
46
+
47
+ Supported endpoint fields:
48
+
49
+ - `path`
50
+ - `method`
51
+ - `status_code`
52
+ - `response`
53
+ - `headers` optional
54
+ - `delay` optional in milliseconds
55
+ - `description` optional
56
+ - `auth` optional, including `none`
57
+
58
+ See [requirements/fr.md](requirements/fr.md) for the full functional requirements.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/mian_mohid_mockapi.egg-info/PKG-INFO
4
+ src/mian_mohid_mockapi.egg-info/SOURCES.txt
5
+ src/mian_mohid_mockapi.egg-info/dependency_links.txt
6
+ src/mian_mohid_mockapi.egg-info/entry_points.txt
7
+ src/mian_mohid_mockapi.egg-info/requires.txt
8
+ src/mian_mohid_mockapi.egg-info/top_level.txt
9
+ src/mockapi/__init__.py
10
+ src/mockapi/__main__.py
11
+ src/mockapi/cli.py
12
+ src/mockapi/config.py
13
+ src/mockapi/server.py
14
+ tests/test_mockapi.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mockapi = mockapi.cli:main
@@ -0,0 +1,4 @@
1
+ PyYAML>=6.0
2
+
3
+ [dev]
4
+ pytest>=8.0
@@ -0,0 +1,7 @@
1
+ """mockapi package."""
2
+
3
+ from .config import load_spec, validate_spec
4
+ from .server import MockApiServer, serve_spec
5
+
6
+ __all__ = ["MockApiServer", "load_spec", "serve_spec", "validate_spec"]
7
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from .config import load_spec
7
+ from .server import serve_spec
8
+
9
+
10
+ def build_parser() -> argparse.ArgumentParser:
11
+ parser = argparse.ArgumentParser(prog="mockapi", description="Serve YAML-defined mock APIs")
12
+ subparsers = parser.add_subparsers(dest="command", required=True)
13
+
14
+ serve_parser = subparsers.add_parser("serve", help="start the HTTP server")
15
+ serve_parser.add_argument("spec_path", type=Path, help="path to the YAML spec file")
16
+ serve_parser.add_argument("--port", type=int, default=7248, help="port to bind to")
17
+ serve_parser.add_argument("--host", type=str, default="127.0.0.1", help="host to bind to")
18
+
19
+ validate_parser = subparsers.add_parser("validate", help="validate the YAML spec without starting a server")
20
+ validate_parser.add_argument("spec_path", type=Path, help="path to the YAML spec file")
21
+
22
+ return parser
23
+
24
+
25
+ def main(argv: list[str] | None = None) -> int:
26
+ parser = build_parser()
27
+ args = parser.parse_args(argv)
28
+
29
+ if args.command == "validate":
30
+ load_spec(args.spec_path)
31
+ print(f"Spec is valid: {args.spec_path}")
32
+ return 0
33
+
34
+ if args.command == "serve":
35
+ spec = load_spec(args.spec_path)
36
+ print(f"Serving {args.spec_path} on http://{args.host}:{args.port}")
37
+ serve_spec(spec, host=args.host, port=args.port)
38
+ return 0
39
+
40
+ parser.error("Unknown command")
41
+ return 2
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import base64
5
+ import binascii
6
+ import json
7
+ import re
8
+ import warnings
9
+ from pathlib import Path
10
+ from typing import Any, Mapping
11
+
12
+ import yaml
13
+
14
+
15
+ SUPPORTED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
16
+ SUPPORTED_AUTH_TYPES = {"bearer", "api_key", "basic"}
17
+
18
+
19
+ class SpecError(ValueError):
20
+ """Raised when the YAML spec cannot be parsed or validated."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class AuthConfig:
25
+ type: str
26
+ token: str | None = None
27
+ header: str | None = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class EndpointSpec:
32
+ path: str
33
+ method: str
34
+ status_code: int
35
+ response: Any
36
+ headers: dict[str, str] = field(default_factory=dict)
37
+ delay: int = 0
38
+ description: str | None = None
39
+ auth: AuthConfig | None = None
40
+ auth_disabled: bool = False
41
+
42
+ def route_regex(self) -> re.Pattern[str]:
43
+ if self.path == "/":
44
+ return re.compile(r"^/$")
45
+
46
+ parts: list[str] = []
47
+ for segment in self.path.strip("/").split("/"):
48
+ if segment.startswith(":"):
49
+ name = segment[1:]
50
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
51
+ raise SpecError(f"Invalid path parameter name '{name}' in {self.path}")
52
+ parts.append(f"(?P<{name}>[^/]+)")
53
+ else:
54
+ parts.append(re.escape(segment))
55
+ return re.compile(r"^/" + "/".join(parts) + r"$")
56
+
57
+ def match(self, method: str, path: str) -> dict[str, str] | None:
58
+ if self.method != method:
59
+ return None
60
+ match = self.route_regex().match(path)
61
+ if not match:
62
+ return None
63
+ return match.groupdict()
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Spec:
68
+ endpoints: list[EndpointSpec]
69
+ global_auth: AuthConfig | None = None
70
+
71
+ def find_route(self, method: str, path: str) -> tuple[EndpointSpec, dict[str, str]] | None:
72
+ for endpoint in self.endpoints:
73
+ params = endpoint.match(method, path)
74
+ if params is not None:
75
+ return endpoint, params
76
+ return None
77
+
78
+
79
+ def load_spec(path: str | Path) -> Spec:
80
+ spec_path = Path(path)
81
+ try:
82
+ raw = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
83
+ except OSError as exc:
84
+ raise SpecError(f"Unable to read spec file {spec_path}: {exc}") from exc
85
+ except yaml.YAMLError as exc:
86
+ raise SpecError(f"Invalid YAML in {spec_path}: {exc}") from exc
87
+
88
+ return validate_spec(raw)
89
+
90
+
91
+ def validate_spec(raw: Any) -> Spec:
92
+ if not isinstance(raw, Mapping):
93
+ raise SpecError("Spec root must be a mapping")
94
+
95
+ endpoints_raw = raw.get("endpoints")
96
+ if not isinstance(endpoints_raw, list):
97
+ raise SpecError("Spec must include an 'endpoints' list")
98
+
99
+ global_auth = None
100
+ global_block = raw.get("global")
101
+ if isinstance(global_block, Mapping):
102
+ global_auth = _parse_auth(global_block.get("auth"), "global.auth", allow_none=True)
103
+ elif global_block is not None:
104
+ raise SpecError("'global' must be a mapping when provided")
105
+
106
+ endpoints: list[EndpointSpec] = []
107
+ seen_routes: set[tuple[str, str]] = set()
108
+ for index, endpoint_raw in enumerate(endpoints_raw):
109
+ endpoint = _parse_endpoint(endpoint_raw, index)
110
+ route_key = (endpoint.method, endpoint.path)
111
+ if route_key in seen_routes:
112
+ warnings.warn(f"Duplicate route detected for {endpoint.method} {endpoint.path}", stacklevel=2)
113
+ seen_routes.add(route_key)
114
+ endpoints.append(endpoint)
115
+
116
+ return Spec(endpoints=endpoints, global_auth=global_auth)
117
+
118
+
119
+ def _parse_endpoint(endpoint_raw: Any, index: int) -> EndpointSpec:
120
+ if not isinstance(endpoint_raw, Mapping):
121
+ raise SpecError(f"Endpoint #{index + 1} must be a mapping")
122
+
123
+ required_fields = ("path", "method", "status_code", "response")
124
+ missing = [field for field in required_fields if field not in endpoint_raw]
125
+ if missing:
126
+ raise SpecError(f"Endpoint #{index + 1} missing required field(s): {', '.join(missing)}")
127
+
128
+ path = endpoint_raw["path"]
129
+ method = endpoint_raw["method"]
130
+ status_code = endpoint_raw["status_code"]
131
+ response = endpoint_raw["response"]
132
+
133
+ if not isinstance(path, str) or not path.startswith("/"):
134
+ raise SpecError(f"Endpoint #{index + 1} path must be a string starting with '/'")
135
+ if not isinstance(method, str):
136
+ raise SpecError(f"Endpoint #{index + 1} method must be a string")
137
+ method = method.upper()
138
+ if method not in SUPPORTED_METHODS:
139
+ raise SpecError(f"Endpoint #{index + 1} uses unsupported method '{method}'")
140
+ if not isinstance(status_code, int):
141
+ raise SpecError(f"Endpoint #{index + 1} status_code must be an integer")
142
+ if status_code < 100 or status_code > 599:
143
+ raise SpecError(f"Endpoint #{index + 1} status_code must be a valid HTTP status code")
144
+
145
+ headers_raw = endpoint_raw.get("headers") or {}
146
+ if not isinstance(headers_raw, Mapping):
147
+ raise SpecError(f"Endpoint #{index + 1} headers must be a mapping when provided")
148
+ headers = {str(key): str(value) for key, value in headers_raw.items()}
149
+
150
+ delay = endpoint_raw.get("delay", 0)
151
+ if not isinstance(delay, int) or delay < 0:
152
+ raise SpecError(f"Endpoint #{index + 1} delay must be a non-negative integer")
153
+
154
+ description = endpoint_raw.get("description")
155
+ if description is not None and not isinstance(description, str):
156
+ raise SpecError(f"Endpoint #{index + 1} description must be a string when provided")
157
+
158
+ auth_value = endpoint_raw.get("auth")
159
+ auth_disabled = auth_value == "none"
160
+ auth = None if auth_disabled else _parse_auth(auth_value, f"endpoint #{index + 1}.auth", allow_none=True)
161
+
162
+ try:
163
+ json.dumps(response)
164
+ except (TypeError, ValueError) as exc:
165
+ raise SpecError(f"Endpoint #{index + 1} response must be JSON-serializable") from exc
166
+
167
+ return EndpointSpec(
168
+ path=path,
169
+ method=method,
170
+ status_code=status_code,
171
+ response=response,
172
+ headers=headers,
173
+ delay=delay,
174
+ description=description,
175
+ auth=auth,
176
+ auth_disabled=auth_disabled,
177
+ )
178
+
179
+
180
+ def _parse_auth(auth_raw: Any, label: str, *, allow_none: bool) -> AuthConfig | None:
181
+ if auth_raw is None:
182
+ return None
183
+ if allow_none and auth_raw == "none":
184
+ return None
185
+ if not isinstance(auth_raw, Mapping):
186
+ raise SpecError(f"{label} must be a mapping or 'none'")
187
+
188
+ auth_type = auth_raw.get("type")
189
+ token = auth_raw.get("token")
190
+ header = auth_raw.get("header")
191
+
192
+ if not isinstance(auth_type, str):
193
+ warnings.warn(f"{label} is missing an auth type", stacklevel=3)
194
+ auth_type = ""
195
+ auth_type = auth_type.lower()
196
+ if auth_type not in SUPPORTED_AUTH_TYPES:
197
+ warnings.warn(f"{label} uses unsupported auth type '{auth_type}'", stacklevel=3)
198
+
199
+ if token is None or token == "":
200
+ warnings.warn(f"{label} is missing a token", stacklevel=3)
201
+ token = None
202
+ elif not isinstance(token, str):
203
+ token = str(token)
204
+
205
+ if header is not None and not isinstance(header, str):
206
+ header = str(header)
207
+
208
+ return AuthConfig(type=auth_type, token=token, header=header)
209
+
210
+
211
+ def expected_basic_token_value(token: str) -> str:
212
+ """Return the request header fragment that should match the configured basic token."""
213
+
214
+ if ":" in token:
215
+ encoded = base64.b64encode(token.encode("utf-8")).decode("ascii")
216
+ return f"Basic {encoded}"
217
+
218
+ try:
219
+ decoded = base64.b64decode(token, validate=True).decode("utf-8")
220
+ except (binascii.Error, UnicodeDecodeError):
221
+ decoded = ""
222
+
223
+ if ":" in decoded:
224
+ return f"Basic {token}"
225
+
226
+ encoded = base64.b64encode(token.encode("utf-8")).decode("ascii")
227
+ return f"Basic {encoded}"
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ from .config import AuthConfig, EndpointSpec, Spec, expected_basic_token_value
11
+
12
+
13
+ RESET = "\033[0m"
14
+ GREEN = "\033[32m"
15
+ YELLOW = "\033[33m"
16
+ RED = "\033[31m"
17
+
18
+
19
+ def serve_spec(spec: Spec, *, host: str = "127.0.0.1", port: int = 7248) -> None:
20
+ server = MockApiServer(spec, host=host, port=port)
21
+ try:
22
+ server.serve_forever()
23
+ except KeyboardInterrupt:
24
+ pass
25
+ finally:
26
+ server.shutdown()
27
+
28
+
29
+ class MockApiServer:
30
+ def __init__(self, spec: Spec, *, host: str = "127.0.0.1", port: int = 7248) -> None:
31
+ self.spec = spec
32
+ self._handler_class = self._build_handler_class()
33
+ self._server = ThreadingHTTPServer((host, port), self._handler_class)
34
+ self._server.mockapi_spec = spec # type: ignore[attr-defined]
35
+ self._thread: threading.Thread | None = None
36
+
37
+ @property
38
+ def server_address(self) -> tuple[str, int]:
39
+ host, port = self._server.server_address
40
+ return str(host), int(port)
41
+
42
+ def serve_forever(self) -> None:
43
+ self._server.serve_forever()
44
+
45
+ def start_in_thread(self) -> threading.Thread:
46
+ thread = threading.Thread(target=self.serve_forever, daemon=True)
47
+ thread.start()
48
+ self._thread = thread
49
+ return thread
50
+
51
+ def shutdown(self) -> None:
52
+ self._server.shutdown()
53
+ self._server.server_close()
54
+ if self._thread and self._thread.is_alive():
55
+ self._thread.join(timeout=2)
56
+
57
+ def _build_handler_class(self) -> type[BaseHTTPRequestHandler]:
58
+ spec = self.spec
59
+
60
+ class MockApiRequestHandler(BaseHTTPRequestHandler):
61
+ protocol_version = "HTTP/1.1"
62
+ server_version = "mockapi/0.1"
63
+
64
+ def do_GET(self) -> None: # noqa: N802
65
+ self._handle_request("GET")
66
+
67
+ def do_POST(self) -> None: # noqa: N802
68
+ self._handle_request("POST")
69
+
70
+ def do_PUT(self) -> None: # noqa: N802
71
+ self._handle_request("PUT")
72
+
73
+ def do_PATCH(self) -> None: # noqa: N802
74
+ self._handle_request("PATCH")
75
+
76
+ def do_DELETE(self) -> None: # noqa: N802
77
+ self._handle_request("DELETE")
78
+
79
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
80
+ return
81
+
82
+ def _handle_request(self, method: str) -> None:
83
+ started = time.perf_counter()
84
+ parsed_path = urlparse(self.path).path
85
+ routed = spec.find_route(method, parsed_path)
86
+
87
+ if routed is None:
88
+ self._write_json_response(404, {"error": "Route not found"})
89
+ elapsed = (time.perf_counter() - started) * 1000
90
+ self._log_request(method, parsed_path, "unmatched", 404, elapsed)
91
+ return
92
+
93
+ endpoint, params = routed
94
+ auth = endpoint.auth if endpoint.auth is not None else spec.global_auth
95
+ if not self._is_authorized(auth):
96
+ self._write_json_response(
97
+ 401,
98
+ {"error": "Unauthorized", "detail": "Missing or invalid auth token"},
99
+ )
100
+ elapsed = (time.perf_counter() - started) * 1000
101
+ self._log_request(method, parsed_path, endpoint.path, 401, elapsed)
102
+ return
103
+
104
+ if endpoint.delay:
105
+ time.sleep(endpoint.delay / 1000)
106
+
107
+ response_body = _inject_path_params(endpoint.response, params)
108
+ self._write_json_response(endpoint.status_code, response_body, endpoint.headers)
109
+ elapsed = (time.perf_counter() - started) * 1000
110
+ self._log_request(method, parsed_path, endpoint.path, endpoint.status_code, elapsed)
111
+
112
+ def _is_authorized(self, auth: AuthConfig | None) -> bool:
113
+ if auth is None:
114
+ return True
115
+ if not auth.type or not auth.token:
116
+ return False
117
+
118
+ authorization = self.headers.get("Authorization", "")
119
+ if auth.type == "bearer":
120
+ return authorization == f"Bearer {auth.token}"
121
+
122
+ if auth.type == "api_key":
123
+ header_name = auth.header or "X-API-Key"
124
+ return self.headers.get(header_name, "") == auth.token
125
+
126
+ if auth.type == "basic":
127
+ return authorization == expected_basic_token_value(auth.token)
128
+
129
+ return False
130
+
131
+ def _write_json_response(
132
+ self,
133
+ status_code: int,
134
+ payload: Any,
135
+ headers: dict[str, str] | None = None,
136
+ ) -> None:
137
+ body = json.dumps(payload).encode("utf-8")
138
+ self.send_response(status_code)
139
+ if headers:
140
+ for header_name, header_value in headers.items():
141
+ self.send_header(header_name, header_value)
142
+ self.send_header("Content-Type", "application/json; charset=utf-8")
143
+ self.send_header("Content-Length", str(len(body)))
144
+ self.end_headers()
145
+ self.wfile.write(body)
146
+
147
+ def _log_request(
148
+ self,
149
+ method: str,
150
+ path: str,
151
+ matched_route: str,
152
+ status_code: int,
153
+ elapsed_ms: float,
154
+ ) -> None:
155
+ if 200 <= status_code < 300:
156
+ color = GREEN
157
+ elif 300 <= status_code < 400:
158
+ color = YELLOW
159
+ else:
160
+ color = RED
161
+
162
+ print(
163
+ f"{color}{method} {path} -> {matched_route} {status_code} {elapsed_ms:.1f}ms{RESET}",
164
+ flush=True,
165
+ )
166
+
167
+ return MockApiRequestHandler
168
+
169
+
170
+ def _inject_path_params(payload: Any, params: dict[str, str]) -> Any:
171
+ if isinstance(payload, dict):
172
+ return {key: _inject_path_params(value, params) for key, value in payload.items()}
173
+ if isinstance(payload, list):
174
+ return [_inject_path_params(value, params) for value in payload]
175
+ if isinstance(payload, tuple):
176
+ return tuple(_inject_path_params(value, params) for value in payload)
177
+ if isinstance(payload, str):
178
+ updated = payload
179
+ for name, value in params.items():
180
+ updated = updated.replace(f":{name}", value)
181
+ return updated
182
+ return payload
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import time
6
+ from pathlib import Path
7
+ from urllib.error import HTTPError
8
+ from urllib.request import Request, urlopen
9
+
10
+ import pytest
11
+
12
+ from mockapi import MockApiServer, load_spec
13
+ from mockapi.cli import main
14
+
15
+
16
+ def _write_spec(tmp_path: Path, content: str) -> Path:
17
+ path = tmp_path / "spec.yaml"
18
+ path.write_text(content, encoding="utf-8")
19
+ return path
20
+
21
+
22
+ def _run_server(spec_path: Path) -> MockApiServer:
23
+ spec = load_spec(spec_path)
24
+ server = MockApiServer(spec, port=0)
25
+ server.start_in_thread()
26
+ time.sleep(0.1)
27
+ return server
28
+
29
+
30
+ def test_validate_spec_accepts_auth_and_path_params(tmp_path: Path) -> None:
31
+ spec_path = _write_spec(
32
+ tmp_path,
33
+ """
34
+ global:
35
+ auth:
36
+ type: bearer
37
+ token: globaltoken
38
+ endpoints:
39
+ - path: /users/:id
40
+ method: GET
41
+ status_code: 200
42
+ response:
43
+ id: :id
44
+ message: Hello :id
45
+ """,
46
+ )
47
+
48
+ spec = load_spec(spec_path)
49
+ assert len(spec.endpoints) == 1
50
+ assert spec.endpoints[0].method == "GET"
51
+ assert spec.global_auth is not None
52
+
53
+
54
+ def test_server_returns_path_params_and_headers(tmp_path: Path) -> None:
55
+ spec_path = _write_spec(
56
+ tmp_path,
57
+ """
58
+ endpoints:
59
+ - path: /users/:id
60
+ method: GET
61
+ status_code: 200
62
+ headers:
63
+ X-Example: present
64
+ response:
65
+ id: :id
66
+ message: Hello :id
67
+ """,
68
+ )
69
+
70
+ server = _run_server(spec_path)
71
+ host, port = server.server_address
72
+ try:
73
+ with urlopen(f"http://{host}:{port}/users/42") as response:
74
+ assert response.status == 200
75
+ assert response.headers["X-Example"] == "present"
76
+ body = json.loads(response.read().decode("utf-8"))
77
+ assert body == {"id": "42", "message": "Hello 42"}
78
+ finally:
79
+ server.shutdown()
80
+
81
+
82
+ def test_server_enforces_bearer_auth(tmp_path: Path) -> None:
83
+ spec_path = _write_spec(
84
+ tmp_path,
85
+ """
86
+ endpoints:
87
+ - path: /profile
88
+ method: GET
89
+ status_code: 200
90
+ auth:
91
+ type: bearer
92
+ token: secret-token
93
+ response:
94
+ ok: true
95
+ """,
96
+ )
97
+
98
+ server = _run_server(spec_path)
99
+ host, port = server.server_address
100
+ try:
101
+ request = Request(f"http://{host}:{port}/profile")
102
+ with pytest.raises(HTTPError) as exc_info:
103
+ urlopen(request)
104
+ assert exc_info.value.code == 401
105
+
106
+ authed_request = Request(f"http://{host}:{port}/profile", headers={"Authorization": "Bearer secret-token"})
107
+ with urlopen(authed_request) as response:
108
+ assert response.status == 200
109
+ assert json.loads(response.read().decode("utf-8")) == {"ok": True}
110
+ finally:
111
+ server.shutdown()
112
+
113
+
114
+ def test_basic_auth_accepts_raw_token(tmp_path: Path) -> None:
115
+ spec_path = _write_spec(
116
+ tmp_path,
117
+ """
118
+ endpoints:
119
+ - path: /basic
120
+ method: GET
121
+ status_code: 200
122
+ auth:
123
+ type: basic
124
+ token: user:pass
125
+ response:
126
+ ok: true
127
+ """,
128
+ )
129
+
130
+ server = _run_server(spec_path)
131
+ host, port = server.server_address
132
+ try:
133
+ encoded = base64.b64encode(b"user:pass").decode("ascii")
134
+ request = Request(f"http://{host}:{port}/basic", headers={"Authorization": f"Basic {encoded}"})
135
+ with urlopen(request) as response:
136
+ assert response.status == 200
137
+ finally:
138
+ server.shutdown()
139
+
140
+
141
+ def test_404_fallback(tmp_path: Path) -> None:
142
+ spec_path = _write_spec(
143
+ tmp_path,
144
+ """
145
+ endpoints:
146
+ - path: /known
147
+ method: GET
148
+ status_code: 200
149
+ response:
150
+ ok: true
151
+ """,
152
+ )
153
+
154
+ server = _run_server(spec_path)
155
+ host, port = server.server_address
156
+ try:
157
+ with pytest.raises(HTTPError) as exc_info:
158
+ urlopen(f"http://{host}:{port}/missing")
159
+ assert exc_info.value.code == 404
160
+ assert json.loads(exc_info.value.read().decode("utf-8")) == {"error": "Route not found"}
161
+ finally:
162
+ server.shutdown()
163
+
164
+
165
+ def test_cli_validate_returns_zero(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
166
+ spec_path = _write_spec(
167
+ tmp_path,
168
+ """
169
+ endpoints:
170
+ - path: /ping
171
+ method: GET
172
+ status_code: 200
173
+ response:
174
+ ok: true
175
+ """,
176
+ )
177
+
178
+ exit_code = main(["validate", str(spec_path)])
179
+ assert exit_code == 0
180
+ captured = capsys.readouterr()
181
+ assert "Spec is valid" in captured.out