mocklimit 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.
- mocklimit-0.1.0/LICENSE +21 -0
- mocklimit-0.1.0/PKG-INFO +156 -0
- mocklimit-0.1.0/README.md +128 -0
- mocklimit-0.1.0/pyproject.toml +67 -0
- mocklimit-0.1.0/src/mocklimit/__init__.py +3 -0
- mocklimit-0.1.0/src/mocklimit/__main__.py +5 -0
- mocklimit-0.1.0/src/mocklimit/main.py +52 -0
- mocklimit-0.1.0/src/mocklimit/openapi/__init__.py +12 -0
- mocklimit-0.1.0/src/mocklimit/openapi/models.py +18 -0
- mocklimit-0.1.0/src/mocklimit/openapi/parser.py +88 -0
- mocklimit-0.1.0/src/mocklimit/openapi/response_generator.py +196 -0
- mocklimit-0.1.0/src/mocklimit/py.typed +0 -0
- mocklimit-0.1.0/src/mocklimit/ratelimit/__init__.py +14 -0
- mocklimit-0.1.0/src/mocklimit/ratelimit/composite.py +71 -0
- mocklimit-0.1.0/src/mocklimit/ratelimit/fixed_window.py +95 -0
- mocklimit-0.1.0/src/mocklimit/ratelimit/models.py +27 -0
- mocklimit-0.1.0/src/mocklimit/ratelimit/quantized.py +98 -0
- mocklimit-0.1.0/src/mocklimit/server/__init__.py +6 -0
- mocklimit-0.1.0/src/mocklimit/server/app.py +267 -0
- mocklimit-0.1.0/src/mocklimit/server/config.py +65 -0
- mocklimit-0.1.0/src/mocklimit/server/stats.py +68 -0
mocklimit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stanislav Kosorin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mocklimit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mocklimit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Configurable mock API server with realistic rate limiting for testing
|
|
5
|
+
Keywords: mock,rate-limiting,api,testing,openapi
|
|
6
|
+
Author: Stanislav Kosorin
|
|
7
|
+
Author-email: Stanislav Kosorin <stanokosorin4@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Software Development :: Testing
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Dist: fastapi>=0.135.1
|
|
19
|
+
Requires-Dist: jsonref>=1.1.0
|
|
20
|
+
Requires-Dist: pydantic>=2.0
|
|
21
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
22
|
+
Requires-Dist: uvicorn>=0.42.0
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Project-URL: Documentation, https://github.com/stano45/mocklimit#readme
|
|
25
|
+
Project-URL: Issues, https://github.com/stano45/mocklimit/issues
|
|
26
|
+
Project-URL: Repository, https://github.com/stano45/mocklimit
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# mocklimit
|
|
30
|
+
|
|
31
|
+
[](https://github.com/stano45/mocklimit/actions/workflows/ci.yml)
|
|
32
|
+
[](https://pypi.org/project/mocklimit/)
|
|
33
|
+
[](https://pypi.org/project/mocklimit/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
Configurable mock API server with realistic rate limiting for testing.
|
|
37
|
+
Point it at an OpenAPI spec, define rate limit policies in YAML, and get a
|
|
38
|
+
local server that behaves like a rate-limited production API, complete with
|
|
39
|
+
correct headers, 429 responses, and token usage estimation.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **OpenAPI spec auto-routing** - parses your spec and registers all endpoints with dummy responses
|
|
44
|
+
- **Fixed window rate limiting** with sub-second precision
|
|
45
|
+
- **Quantized rate limiter** for aligned reset windows
|
|
46
|
+
- **Composite limits** - stack multiple limits per endpoint (e.g. RPM + TPM)
|
|
47
|
+
- **Provider-accurate headers** - configurable header names (`x-ratelimit-limit-requests`, etc.)
|
|
48
|
+
- **Token usage estimation** for LLM API mocking
|
|
49
|
+
- **Configurable response latency** simulation
|
|
50
|
+
- **Per-key scoping** by API key or IP address
|
|
51
|
+
- **Request statistics** via `/mocklimit/stats`
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install mocklimit
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv add mocklimit
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Quick start
|
|
66
|
+
|
|
67
|
+
### 1. Create a rate limit config
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
# limits.yaml
|
|
71
|
+
policies:
|
|
72
|
+
openai_chat:
|
|
73
|
+
strategy: fixed_window
|
|
74
|
+
limits:
|
|
75
|
+
- max_requests: 5
|
|
76
|
+
window_seconds: 60
|
|
77
|
+
scope: api_key
|
|
78
|
+
response_latency_ms: [0, 0]
|
|
79
|
+
headers:
|
|
80
|
+
limit: x-ratelimit-limit-requests
|
|
81
|
+
remaining: x-ratelimit-remaining-requests
|
|
82
|
+
reset: x-ratelimit-reset-requests
|
|
83
|
+
|
|
84
|
+
endpoints:
|
|
85
|
+
/chat/completions:
|
|
86
|
+
methods: [POST]
|
|
87
|
+
policy: openai_chat
|
|
88
|
+
token_estimation:
|
|
89
|
+
input: characters_div_4
|
|
90
|
+
output: [50, 500]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2. Start the server
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
mocklimit serve --spec openapi.yaml --rate-config limits.yaml
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The server reads your OpenAPI spec for route definitions and response schemas,
|
|
100
|
+
then applies rate limiting according to the config. Requests beyond the limit
|
|
101
|
+
get a `429` with appropriate `Retry-After` and rate limit headers.
|
|
102
|
+
|
|
103
|
+
### 3. Options
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
mocklimit serve --spec <path> --rate-config <path> [--host HOST] [--port PORT]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Flag | Default | Description |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `--spec` | *(required)* | Path to OpenAPI spec (YAML) |
|
|
112
|
+
| `--rate-config` | *(required)* | Path to rate limit config (YAML) |
|
|
113
|
+
| `--host` | `127.0.0.1` | Host to bind to |
|
|
114
|
+
| `--port` | `8000` | Port to listen on |
|
|
115
|
+
|
|
116
|
+
## Rate limit config reference
|
|
117
|
+
|
|
118
|
+
### Policies
|
|
119
|
+
|
|
120
|
+
Each policy defines a rate limiting strategy:
|
|
121
|
+
|
|
122
|
+
| Field | Type | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `strategy` | `"fixed_window"` | Rate limiting algorithm |
|
|
125
|
+
| `limits` | list | One or more `{max_requests, window_seconds}` pairs |
|
|
126
|
+
| `scope` | `"api_key"` \| `"ip"` | How to identify clients |
|
|
127
|
+
| `response_latency_ms` | `[min, max]` | Simulated response delay range (ms) |
|
|
128
|
+
| `headers.limit` | string | Header name for the request limit |
|
|
129
|
+
| `headers.remaining` | string | Header name for remaining requests |
|
|
130
|
+
| `headers.reset` | string | Header name for reset time |
|
|
131
|
+
|
|
132
|
+
### Endpoints
|
|
133
|
+
|
|
134
|
+
Map API paths to policies:
|
|
135
|
+
|
|
136
|
+
| Field | Type | Description |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `methods` | list of strings | HTTP methods to rate limit |
|
|
139
|
+
| `policy` | string | Name of the policy to apply |
|
|
140
|
+
| `token_estimation` | object (optional) | `{input: "characters_div_4", output: [min, max]}` |
|
|
141
|
+
|
|
142
|
+
## Programmatic usage
|
|
143
|
+
|
|
144
|
+
You can also embed the server directly in tests:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from mocklimit.server import create_app
|
|
148
|
+
|
|
149
|
+
app = create_app("openapi.yaml", "limits.yaml")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
This returns a standard FastAPI app that can be used with any ASGI test client.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# mocklimit
|
|
2
|
+
|
|
3
|
+
[](https://github.com/stano45/mocklimit/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/mocklimit/)
|
|
5
|
+
[](https://pypi.org/project/mocklimit/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Configurable mock API server with realistic rate limiting for testing.
|
|
9
|
+
Point it at an OpenAPI spec, define rate limit policies in YAML, and get a
|
|
10
|
+
local server that behaves like a rate-limited production API, complete with
|
|
11
|
+
correct headers, 429 responses, and token usage estimation.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **OpenAPI spec auto-routing** - parses your spec and registers all endpoints with dummy responses
|
|
16
|
+
- **Fixed window rate limiting** with sub-second precision
|
|
17
|
+
- **Quantized rate limiter** for aligned reset windows
|
|
18
|
+
- **Composite limits** - stack multiple limits per endpoint (e.g. RPM + TPM)
|
|
19
|
+
- **Provider-accurate headers** - configurable header names (`x-ratelimit-limit-requests`, etc.)
|
|
20
|
+
- **Token usage estimation** for LLM API mocking
|
|
21
|
+
- **Configurable response latency** simulation
|
|
22
|
+
- **Per-key scoping** by API key or IP address
|
|
23
|
+
- **Request statistics** via `/mocklimit/stats`
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install mocklimit
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv add mocklimit
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
### 1. Create a rate limit config
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
# limits.yaml
|
|
43
|
+
policies:
|
|
44
|
+
openai_chat:
|
|
45
|
+
strategy: fixed_window
|
|
46
|
+
limits:
|
|
47
|
+
- max_requests: 5
|
|
48
|
+
window_seconds: 60
|
|
49
|
+
scope: api_key
|
|
50
|
+
response_latency_ms: [0, 0]
|
|
51
|
+
headers:
|
|
52
|
+
limit: x-ratelimit-limit-requests
|
|
53
|
+
remaining: x-ratelimit-remaining-requests
|
|
54
|
+
reset: x-ratelimit-reset-requests
|
|
55
|
+
|
|
56
|
+
endpoints:
|
|
57
|
+
/chat/completions:
|
|
58
|
+
methods: [POST]
|
|
59
|
+
policy: openai_chat
|
|
60
|
+
token_estimation:
|
|
61
|
+
input: characters_div_4
|
|
62
|
+
output: [50, 500]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 2. Start the server
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
mocklimit serve --spec openapi.yaml --rate-config limits.yaml
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The server reads your OpenAPI spec for route definitions and response schemas,
|
|
72
|
+
then applies rate limiting according to the config. Requests beyond the limit
|
|
73
|
+
get a `429` with appropriate `Retry-After` and rate limit headers.
|
|
74
|
+
|
|
75
|
+
### 3. Options
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
mocklimit serve --spec <path> --rate-config <path> [--host HOST] [--port PORT]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Flag | Default | Description |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `--spec` | *(required)* | Path to OpenAPI spec (YAML) |
|
|
84
|
+
| `--rate-config` | *(required)* | Path to rate limit config (YAML) |
|
|
85
|
+
| `--host` | `127.0.0.1` | Host to bind to |
|
|
86
|
+
| `--port` | `8000` | Port to listen on |
|
|
87
|
+
|
|
88
|
+
## Rate limit config reference
|
|
89
|
+
|
|
90
|
+
### Policies
|
|
91
|
+
|
|
92
|
+
Each policy defines a rate limiting strategy:
|
|
93
|
+
|
|
94
|
+
| Field | Type | Description |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `strategy` | `"fixed_window"` | Rate limiting algorithm |
|
|
97
|
+
| `limits` | list | One or more `{max_requests, window_seconds}` pairs |
|
|
98
|
+
| `scope` | `"api_key"` \| `"ip"` | How to identify clients |
|
|
99
|
+
| `response_latency_ms` | `[min, max]` | Simulated response delay range (ms) |
|
|
100
|
+
| `headers.limit` | string | Header name for the request limit |
|
|
101
|
+
| `headers.remaining` | string | Header name for remaining requests |
|
|
102
|
+
| `headers.reset` | string | Header name for reset time |
|
|
103
|
+
|
|
104
|
+
### Endpoints
|
|
105
|
+
|
|
106
|
+
Map API paths to policies:
|
|
107
|
+
|
|
108
|
+
| Field | Type | Description |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `methods` | list of strings | HTTP methods to rate limit |
|
|
111
|
+
| `policy` | string | Name of the policy to apply |
|
|
112
|
+
| `token_estimation` | object (optional) | `{input: "characters_div_4", output: [min, max]}` |
|
|
113
|
+
|
|
114
|
+
## Programmatic usage
|
|
115
|
+
|
|
116
|
+
You can also embed the server directly in tests:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from mocklimit.server import create_app
|
|
120
|
+
|
|
121
|
+
app = create_app("openapi.yaml", "limits.yaml")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
This returns a standard FastAPI app that can be used with any ASGI test client.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mocklimit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Configurable mock API server with realistic rate limiting for testing"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Stanislav Kosorin", email = "stanokosorin4@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
keywords = ["mock", "rate-limiting", "api", "testing", "openapi"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Framework :: FastAPI",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Software Development :: Testing",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"fastapi>=0.135.1",
|
|
25
|
+
"jsonref>=1.1.0",
|
|
26
|
+
"pydantic>=2.0",
|
|
27
|
+
"pyyaml>=6.0.3",
|
|
28
|
+
"uvicorn>=0.42.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/stano45/mocklimit"
|
|
33
|
+
Documentation = "https://github.com/stano45/mocklimit#readme"
|
|
34
|
+
Issues = "https://github.com/stano45/mocklimit/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
mocklimit = "mocklimit.main:main"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["uv_build>=0.8.23,<0.9.0"]
|
|
41
|
+
build-backend = "uv_build"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"basedpyright>=1.38.3",
|
|
46
|
+
"httpx>=0.28.1",
|
|
47
|
+
"openai>=1.0.0",
|
|
48
|
+
"pytest>=9.0.2",
|
|
49
|
+
"pytest-asyncio>=1.3.0",
|
|
50
|
+
"ruff>=0.15.6",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
src = ["src"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["ALL"]
|
|
58
|
+
ignore = ["D203", "D213"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint.per-file-ignores]
|
|
61
|
+
"tests/**/*.py" = ["S101", "PLR2004"]
|
|
62
|
+
|
|
63
|
+
[tool.basedpyright]
|
|
64
|
+
typeCheckingMode = "strict"
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""CLI entry point for the mocklimit server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from .server.app import create_app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
"""Construct the top-level argument parser."""
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="mocklimit",
|
|
17
|
+
description="Configurable mock API server with realistic rate limiting",
|
|
18
|
+
)
|
|
19
|
+
sub = parser.add_subparsers(dest="command")
|
|
20
|
+
|
|
21
|
+
serve = sub.add_parser("serve", help="Start the mock server")
|
|
22
|
+
serve.add_argument("--spec", required=True, help="Path to the OpenAPI spec YAML")
|
|
23
|
+
serve.add_argument(
|
|
24
|
+
"--rate-config",
|
|
25
|
+
required=True,
|
|
26
|
+
help="Path to the rate-limit config YAML",
|
|
27
|
+
)
|
|
28
|
+
serve.add_argument(
|
|
29
|
+
"--port",
|
|
30
|
+
type=int,
|
|
31
|
+
default=8000,
|
|
32
|
+
help="Port to listen on (default: 8000)",
|
|
33
|
+
)
|
|
34
|
+
serve.add_argument(
|
|
35
|
+
"--host",
|
|
36
|
+
default="127.0.0.1",
|
|
37
|
+
help="Host to bind to (default: 127.0.0.1)",
|
|
38
|
+
)
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv: list[str] | None = None) -> None:
|
|
43
|
+
"""Parse arguments and run the requested command."""
|
|
44
|
+
parser = _build_parser()
|
|
45
|
+
args = parser.parse_args(argv)
|
|
46
|
+
|
|
47
|
+
if args.command != "serve":
|
|
48
|
+
parser.print_help()
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
app = create_app(args.spec, args.rate_config)
|
|
52
|
+
uvicorn.run(app, host=args.host, port=args.port)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""OpenAPI spec parsing."""
|
|
2
|
+
|
|
3
|
+
from .models import RouteDefinition
|
|
4
|
+
from .parser import parse_spec
|
|
5
|
+
from .response_generator import generate_all_responses, generate_dummy_response
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"RouteDefinition",
|
|
9
|
+
"generate_all_responses",
|
|
10
|
+
"generate_dummy_response",
|
|
11
|
+
"parse_spec",
|
|
12
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""OpenAPI route definition models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
__all__ = ["RouteDefinition"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class RouteDefinition:
|
|
13
|
+
"""A single API route extracted from an OpenAPI spec."""
|
|
14
|
+
|
|
15
|
+
path: str
|
|
16
|
+
method: str
|
|
17
|
+
response_schema: dict[str, Any] = field(default_factory=dict)
|
|
18
|
+
operation_id: str | None = None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""OpenAPI spec parser for extracting route definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import jsonref
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .models import RouteDefinition
|
|
12
|
+
|
|
13
|
+
__all__ = ["parse_spec"]
|
|
14
|
+
|
|
15
|
+
_HTTP_METHODS = frozenset({
|
|
16
|
+
"get", "put", "post", "delete",
|
|
17
|
+
"options", "head", "patch", "trace",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _as_str_dict(value: object) -> dict[str, Any] | None:
|
|
22
|
+
"""Cast *value* to a string-keyed dict if it is one, else ``None``."""
|
|
23
|
+
if isinstance(value, dict):
|
|
24
|
+
return cast("dict[str, Any]", value)
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _extract_response_schema(operation: dict[str, Any]) -> dict[str, Any]:
|
|
29
|
+
"""Return the JSON schema for the first 2xx response, or empty dict."""
|
|
30
|
+
responses = _as_str_dict(operation.get("responses"))
|
|
31
|
+
if not responses:
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
for status_code in sorted(responses):
|
|
35
|
+
if not status_code.startswith("2"):
|
|
36
|
+
continue
|
|
37
|
+
response_dict = _as_str_dict(responses[status_code])
|
|
38
|
+
if response_dict is None:
|
|
39
|
+
continue
|
|
40
|
+
content = _as_str_dict(response_dict.get("content"))
|
|
41
|
+
if content is None:
|
|
42
|
+
continue
|
|
43
|
+
json_media = _as_str_dict(content.get("application/json"))
|
|
44
|
+
if json_media is None:
|
|
45
|
+
continue
|
|
46
|
+
schema = _as_str_dict(json_media.get("schema"))
|
|
47
|
+
if schema is not None:
|
|
48
|
+
return schema
|
|
49
|
+
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_spec(path: str) -> list[RouteDefinition]:
|
|
54
|
+
"""Parse an OpenAPI YAML/JSON file and return route definitions.
|
|
55
|
+
|
|
56
|
+
Iterates over all paths and HTTP methods, extracting the response schema
|
|
57
|
+
from the first 2xx response with ``application/json`` content. Missing
|
|
58
|
+
responses or schemas are represented as empty dicts.
|
|
59
|
+
"""
|
|
60
|
+
raw = Path(path).read_text(encoding="utf-8")
|
|
61
|
+
spec: dict[str, Any] = jsonref.replace_refs(yaml.safe_load(raw))
|
|
62
|
+
|
|
63
|
+
paths: dict[str, Any] | None = spec.get("paths")
|
|
64
|
+
if not paths:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
routes: list[RouteDefinition] = []
|
|
68
|
+
for route_path, path_item_raw in paths.items():
|
|
69
|
+
path_item = _as_str_dict(path_item_raw)
|
|
70
|
+
if path_item is None:
|
|
71
|
+
continue
|
|
72
|
+
for method, operation_raw in path_item.items():
|
|
73
|
+
if method not in _HTTP_METHODS:
|
|
74
|
+
continue
|
|
75
|
+
operation = _as_str_dict(operation_raw)
|
|
76
|
+
if operation is None:
|
|
77
|
+
continue
|
|
78
|
+
op_id: str | None = operation.get("operationId")
|
|
79
|
+
routes.append(
|
|
80
|
+
RouteDefinition(
|
|
81
|
+
path=route_path,
|
|
82
|
+
method=method.upper(),
|
|
83
|
+
response_schema=_extract_response_schema(operation),
|
|
84
|
+
operation_id=op_id,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return routes
|