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.
- mian_mohid_mockapi-0.1.0/PKG-INFO +58 -0
- mian_mohid_mockapi-0.1.0/README.md +34 -0
- mian_mohid_mockapi-0.1.0/pyproject.toml +45 -0
- mian_mohid_mockapi-0.1.0/setup.cfg +4 -0
- mian_mohid_mockapi-0.1.0/src/mian_mohid_mockapi.egg-info/PKG-INFO +58 -0
- mian_mohid_mockapi-0.1.0/src/mian_mohid_mockapi.egg-info/SOURCES.txt +14 -0
- mian_mohid_mockapi-0.1.0/src/mian_mohid_mockapi.egg-info/dependency_links.txt +1 -0
- mian_mohid_mockapi-0.1.0/src/mian_mohid_mockapi.egg-info/entry_points.txt +2 -0
- mian_mohid_mockapi-0.1.0/src/mian_mohid_mockapi.egg-info/requires.txt +4 -0
- mian_mohid_mockapi-0.1.0/src/mian_mohid_mockapi.egg-info/top_level.txt +1 -0
- mian_mohid_mockapi-0.1.0/src/mockapi/__init__.py +7 -0
- mian_mohid_mockapi-0.1.0/src/mockapi/__main__.py +5 -0
- mian_mohid_mockapi-0.1.0/src/mockapi/cli.py +41 -0
- mian_mohid_mockapi-0.1.0/src/mockapi/config.py +227 -0
- mian_mohid_mockapi-0.1.0/src/mockapi/server.py +182 -0
- mian_mohid_mockapi-0.1.0/tests/test_mockapi.py +181 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mockapi
|
|
@@ -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
|