semblance 0.2.2__tar.gz → 0.3.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.
- {semblance-0.2.2/src/semblance.egg-info → semblance-0.3.0}/PKG-INFO +10 -4
- {semblance-0.2.2 → semblance-0.3.0}/README.md +8 -3
- {semblance-0.2.2 → semblance-0.3.0}/pyproject.toml +6 -1
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/api.py +325 -14
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/cli.py +2 -1
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/export.py +1 -1
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/factory.py +15 -0
- semblance-0.3.0/src/semblance/property_testing.py +140 -0
- semblance-0.3.0/src/semblance/rate_limit.py +48 -0
- {semblance-0.2.2 → semblance-0.3.0/src/semblance.egg-info}/PKG-INFO +10 -4
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance.egg-info/SOURCES.txt +3 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance.egg-info/requires.txt +1 -0
- semblance-0.3.0/tests/test_phase5.py +164 -0
- {semblance-0.2.2 → semblance-0.3.0}/LICENSE.md +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/setup.cfg +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/__init__.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/links.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/pagination.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/plugins.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/resolver.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/state.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance/testing.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance.egg-info/dependency_links.txt +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance.egg-info/entry_points.txt +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/src/semblance.egg-info/top_level.txt +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_api.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_doc_examples.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_edge_cases.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_factory.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_links.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_phase2.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_phase3.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_phase4.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_plugins.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_resolver.py +0 -0
- {semblance-0.2.2 → semblance-0.3.0}/tests/test_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: semblance
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Schema-driven REST API simulation with FastAPI, Pydantic, and Polyfactory
|
|
5
5
|
Author: Semblance Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,6 +25,7 @@ Provides-Extra: dev
|
|
|
25
25
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
26
26
|
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
27
27
|
Requires-Dist: httpx>=0.25.0; extra == "dev"
|
|
28
|
+
Requires-Dist: hypothesis>=6.0.0; extra == "dev"
|
|
28
29
|
Requires-Dist: ruff>=0.8.0; extra == "dev"
|
|
29
30
|
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
30
31
|
Requires-Dist: bandit>=1.7.0; extra == "dev"
|
|
@@ -55,7 +56,7 @@ Define API behavior declaratively using schemas and dependency metadata—no end
|
|
|
55
56
|
- **FastAPI-native** — Full OpenAPI, validation, async
|
|
56
57
|
- **Deterministic** — Seeded generation for reproducible tests
|
|
57
58
|
- **Extensible** — Custom link types via plugins
|
|
58
|
-
- **Production-ready** — Error simulation, latency, pagination, stateful mode
|
|
59
|
+
- **Production-ready** — Error simulation, latency, rate limiting, pagination, stateful mode, optional response validation
|
|
59
60
|
|
|
60
61
|
## Requirements
|
|
61
62
|
|
|
@@ -111,6 +112,8 @@ def users():
|
|
|
111
112
|
app = api.as_fastapi()
|
|
112
113
|
```
|
|
113
114
|
|
|
115
|
+
You can register PUT, PATCH, and DELETE endpoints the same way (`@api.put(...)`, `@api.patch(...)`, `@api.delete(..., output=None)` for 204).
|
|
116
|
+
|
|
114
117
|
Run:
|
|
115
118
|
|
|
116
119
|
```bash
|
|
@@ -219,15 +222,18 @@ class User(BaseModel):
|
|
|
219
222
|
|
|
220
223
|
| Feature | Description |
|
|
221
224
|
|---------|-------------|
|
|
222
|
-
| **SemblanceAPI** | GET
|
|
225
|
+
| **SemblanceAPI** | GET, POST, PUT, PATCH, DELETE endpoints with input/output models |
|
|
223
226
|
| **Links** | FromInput, DateRangeFrom, WhenInput, ComputedFrom |
|
|
224
227
|
| **Pagination** | PageParams, PaginatedResponse[T] |
|
|
225
228
|
| **Seeding** | `SemblanceAPI(seed=42)` or `seed_from="seed"` |
|
|
226
229
|
| **Error simulation** | `error_rate`, `error_codes` |
|
|
227
230
|
| **Latency** | `latency_ms`, `jitter_ms` |
|
|
231
|
+
| **Rate limiting** | `rate_limit=N` — 429 when exceeded (per endpoint, sliding window) |
|
|
228
232
|
| **Filtering** | `filter_by` for list endpoints |
|
|
229
233
|
| **Stateful mode** | `SemblanceAPI(stateful=True)` — POST stores, GET returns stored |
|
|
234
|
+
| **Response validation** | `SemblanceAPI(validate_responses=True)` — verify output conforms to model |
|
|
230
235
|
| **OpenAPI** | summary, description, tags on endpoints |
|
|
236
|
+
| **Property-based testing** | `semblance.property_testing`: `strategy_for_input_model()`, `test_endpoint()` (Hypothesis) |
|
|
231
237
|
|
|
232
238
|
## Competitors & Alternatives
|
|
233
239
|
|
|
@@ -245,7 +251,7 @@ class User(BaseModel):
|
|
|
245
251
|
| **Extensible (plugins)** | ✅ | <span title="Middleware-based; custom behavior via decorators or wrappers">🟡</span> | ❌ | ❌ | ✅ | <span title="Templates and response rules provide extensibility">🟡</span> |
|
|
246
252
|
| **OpenAPI schema** | ✅ | ✅ | ✅ | ❌ | ✅ | <span title="Can import/export OpenAPI; design is GUI-first, not schema-first">🟡</span> |
|
|
247
253
|
| **CI / pytest integration** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
|
248
|
-
| **Property-based testing** |
|
|
254
|
+
| **Property-based testing** | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ |
|
|
249
255
|
|
|
250
256
|
🟡 = partial or configurable
|
|
251
257
|
|
|
@@ -16,7 +16,7 @@ Define API behavior declaratively using schemas and dependency metadata—no end
|
|
|
16
16
|
- **FastAPI-native** — Full OpenAPI, validation, async
|
|
17
17
|
- **Deterministic** — Seeded generation for reproducible tests
|
|
18
18
|
- **Extensible** — Custom link types via plugins
|
|
19
|
-
- **Production-ready** — Error simulation, latency, pagination, stateful mode
|
|
19
|
+
- **Production-ready** — Error simulation, latency, rate limiting, pagination, stateful mode, optional response validation
|
|
20
20
|
|
|
21
21
|
## Requirements
|
|
22
22
|
|
|
@@ -72,6 +72,8 @@ def users():
|
|
|
72
72
|
app = api.as_fastapi()
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
You can register PUT, PATCH, and DELETE endpoints the same way (`@api.put(...)`, `@api.patch(...)`, `@api.delete(..., output=None)` for 204).
|
|
76
|
+
|
|
75
77
|
Run:
|
|
76
78
|
|
|
77
79
|
```bash
|
|
@@ -180,15 +182,18 @@ class User(BaseModel):
|
|
|
180
182
|
|
|
181
183
|
| Feature | Description |
|
|
182
184
|
|---------|-------------|
|
|
183
|
-
| **SemblanceAPI** | GET
|
|
185
|
+
| **SemblanceAPI** | GET, POST, PUT, PATCH, DELETE endpoints with input/output models |
|
|
184
186
|
| **Links** | FromInput, DateRangeFrom, WhenInput, ComputedFrom |
|
|
185
187
|
| **Pagination** | PageParams, PaginatedResponse[T] |
|
|
186
188
|
| **Seeding** | `SemblanceAPI(seed=42)` or `seed_from="seed"` |
|
|
187
189
|
| **Error simulation** | `error_rate`, `error_codes` |
|
|
188
190
|
| **Latency** | `latency_ms`, `jitter_ms` |
|
|
191
|
+
| **Rate limiting** | `rate_limit=N` — 429 when exceeded (per endpoint, sliding window) |
|
|
189
192
|
| **Filtering** | `filter_by` for list endpoints |
|
|
190
193
|
| **Stateful mode** | `SemblanceAPI(stateful=True)` — POST stores, GET returns stored |
|
|
194
|
+
| **Response validation** | `SemblanceAPI(validate_responses=True)` — verify output conforms to model |
|
|
191
195
|
| **OpenAPI** | summary, description, tags on endpoints |
|
|
196
|
+
| **Property-based testing** | `semblance.property_testing`: `strategy_for_input_model()`, `test_endpoint()` (Hypothesis) |
|
|
192
197
|
|
|
193
198
|
## Competitors & Alternatives
|
|
194
199
|
|
|
@@ -206,7 +211,7 @@ class User(BaseModel):
|
|
|
206
211
|
| **Extensible (plugins)** | ✅ | <span title="Middleware-based; custom behavior via decorators or wrappers">🟡</span> | ❌ | ❌ | ✅ | <span title="Templates and response rules provide extensibility">🟡</span> |
|
|
207
212
|
| **OpenAPI schema** | ✅ | ✅ | ✅ | ❌ | ✅ | <span title="Can import/export OpenAPI; design is GUI-first, not schema-first">🟡</span> |
|
|
208
213
|
| **CI / pytest integration** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
|
209
|
-
| **Property-based testing** |
|
|
214
|
+
| **Property-based testing** | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ |
|
|
210
215
|
|
|
211
216
|
🟡 = partial or configurable
|
|
212
217
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "semblance"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Schema-driven REST API simulation with FastAPI, Pydantic, and Polyfactory"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -35,6 +35,7 @@ dev = [
|
|
|
35
35
|
"pytest>=7.0.0",
|
|
36
36
|
"pytest-cov>=4.0.0",
|
|
37
37
|
"httpx>=0.25.0",
|
|
38
|
+
"hypothesis>=6.0.0",
|
|
38
39
|
"ruff>=0.8.0",
|
|
39
40
|
"mypy>=1.0.0",
|
|
40
41
|
"bandit>=1.7.0",
|
|
@@ -76,6 +77,10 @@ warn_unused_configs = true
|
|
|
76
77
|
disallow_untyped_defs = false
|
|
77
78
|
mypy_path = "src"
|
|
78
79
|
|
|
80
|
+
[[tool.mypy.overrides]]
|
|
81
|
+
module = ["semblance.*"]
|
|
82
|
+
disallow_untyped_defs = true
|
|
83
|
+
|
|
79
84
|
[[tool.mypy.overrides]]
|
|
80
85
|
module = ["polyfactory.*"]
|
|
81
86
|
ignore_missing_imports = true
|
|
@@ -13,10 +13,12 @@ import re
|
|
|
13
13
|
from collections.abc import Callable
|
|
14
14
|
from typing import Annotated, Any, get_origin
|
|
15
15
|
|
|
16
|
-
from fastapi import FastAPI, HTTPException, Query, Request
|
|
16
|
+
from fastapi import Body, FastAPI, HTTPException, Query, Request
|
|
17
|
+
from fastapi.responses import Response
|
|
17
18
|
from pydantic import BaseModel
|
|
18
19
|
|
|
19
|
-
from semblance.factory import build_response
|
|
20
|
+
from semblance.factory import build_response, validate_response
|
|
21
|
+
from semblance.rate_limit import get_limiter
|
|
20
22
|
from semblance.state import StatefulStore
|
|
21
23
|
|
|
22
24
|
|
|
@@ -41,6 +43,7 @@ class EndpointSpec:
|
|
|
41
43
|
"latency_ms",
|
|
42
44
|
"jitter_ms",
|
|
43
45
|
"filter_by",
|
|
46
|
+
"rate_limit",
|
|
44
47
|
"summary",
|
|
45
48
|
"description",
|
|
46
49
|
"tags",
|
|
@@ -51,7 +54,7 @@ class EndpointSpec:
|
|
|
51
54
|
path: str,
|
|
52
55
|
methods: list[str],
|
|
53
56
|
input_model: type[BaseModel],
|
|
54
|
-
output_annotation: type,
|
|
57
|
+
output_annotation: type | None,
|
|
55
58
|
handler: Callable[..., Any],
|
|
56
59
|
list_count: int | str = 5,
|
|
57
60
|
seed_from: str | None = None,
|
|
@@ -60,6 +63,7 @@ class EndpointSpec:
|
|
|
60
63
|
latency_ms: float = 0,
|
|
61
64
|
jitter_ms: float = 0,
|
|
62
65
|
filter_by: str | None = None,
|
|
66
|
+
rate_limit: float | None = None,
|
|
63
67
|
summary: str | None = None,
|
|
64
68
|
description: str | None = None,
|
|
65
69
|
tags: list[str] | None = None,
|
|
@@ -76,6 +80,7 @@ class EndpointSpec:
|
|
|
76
80
|
self.latency_ms = latency_ms
|
|
77
81
|
self.jitter_ms = jitter_ms
|
|
78
82
|
self.filter_by = filter_by
|
|
83
|
+
self.rate_limit = rate_limit
|
|
79
84
|
self.summary = summary
|
|
80
85
|
self.description = description
|
|
81
86
|
self.tags = tags
|
|
@@ -90,12 +95,20 @@ class SemblanceAPI:
|
|
|
90
95
|
seed: Optional seed for deterministic random generation.
|
|
91
96
|
stateful: If True, POST responses are stored and GET list endpoints
|
|
92
97
|
return stored instances instead of generating new ones.
|
|
98
|
+
validate_responses: If True, generated responses are validated against
|
|
99
|
+
the output model (for development/CI; adds overhead).
|
|
93
100
|
"""
|
|
94
101
|
|
|
95
|
-
def __init__(
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
seed: int | None = None,
|
|
105
|
+
stateful: bool = False,
|
|
106
|
+
validate_responses: bool = False,
|
|
107
|
+
) -> None:
|
|
96
108
|
self._specs: list[EndpointSpec] = []
|
|
97
109
|
self._seed = seed
|
|
98
110
|
self._store: StatefulStore | None = StatefulStore() if stateful else None
|
|
111
|
+
self._validate_responses = validate_responses
|
|
99
112
|
|
|
100
113
|
def clear_store(self, path: str | None = None) -> None:
|
|
101
114
|
"""Clear the stateful store. Only available when stateful=True."""
|
|
@@ -115,6 +128,7 @@ class SemblanceAPI:
|
|
|
115
128
|
latency_ms: float = 0,
|
|
116
129
|
jitter_ms: float = 0,
|
|
117
130
|
filter_by: str | None = None,
|
|
131
|
+
rate_limit: float | None = None,
|
|
118
132
|
summary: str | None = None,
|
|
119
133
|
description: str | None = None,
|
|
120
134
|
tags: list[str] | None = None,
|
|
@@ -140,6 +154,7 @@ class SemblanceAPI:
|
|
|
140
154
|
latency_ms,
|
|
141
155
|
jitter_ms,
|
|
142
156
|
filter_by,
|
|
157
|
+
rate_limit,
|
|
143
158
|
summary,
|
|
144
159
|
description,
|
|
145
160
|
tags,
|
|
@@ -158,6 +173,7 @@ class SemblanceAPI:
|
|
|
158
173
|
latency_ms: float = 0,
|
|
159
174
|
jitter_ms: float = 0,
|
|
160
175
|
filter_by: str | None = None,
|
|
176
|
+
rate_limit: float | None = None,
|
|
161
177
|
summary: str | None = None,
|
|
162
178
|
description: str | None = None,
|
|
163
179
|
tags: list[str] | None = None,
|
|
@@ -183,17 +199,147 @@ class SemblanceAPI:
|
|
|
183
199
|
latency_ms,
|
|
184
200
|
jitter_ms,
|
|
185
201
|
filter_by,
|
|
202
|
+
rate_limit,
|
|
186
203
|
summary,
|
|
187
204
|
description,
|
|
188
205
|
tags,
|
|
189
206
|
)
|
|
190
207
|
|
|
208
|
+
def put(
|
|
209
|
+
self,
|
|
210
|
+
path: str,
|
|
211
|
+
*,
|
|
212
|
+
input: type[BaseModel],
|
|
213
|
+
output: type,
|
|
214
|
+
list_count: int | str = 5,
|
|
215
|
+
seed_from: str | None = None,
|
|
216
|
+
error_rate: float = 0,
|
|
217
|
+
error_codes: list[int] | None = None,
|
|
218
|
+
latency_ms: float = 0,
|
|
219
|
+
jitter_ms: float = 0,
|
|
220
|
+
filter_by: str | None = None,
|
|
221
|
+
rate_limit: float | None = None,
|
|
222
|
+
summary: str | None = None,
|
|
223
|
+
description: str | None = None,
|
|
224
|
+
tags: list[str] | None = None,
|
|
225
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
226
|
+
"""
|
|
227
|
+
Register a PUT endpoint. Request body is validated against `input`.
|
|
228
|
+
Response is generated from `output` (single model or list[model]).
|
|
229
|
+
|
|
230
|
+
Usage:
|
|
231
|
+
@api.put("/users/{id}", input=UpdateUserRequest, output=User)
|
|
232
|
+
def update_user():
|
|
233
|
+
pass
|
|
234
|
+
"""
|
|
235
|
+
return self._register(
|
|
236
|
+
path,
|
|
237
|
+
"PUT",
|
|
238
|
+
input,
|
|
239
|
+
output,
|
|
240
|
+
list_count,
|
|
241
|
+
seed_from,
|
|
242
|
+
error_rate,
|
|
243
|
+
error_codes,
|
|
244
|
+
latency_ms,
|
|
245
|
+
jitter_ms,
|
|
246
|
+
filter_by,
|
|
247
|
+
rate_limit,
|
|
248
|
+
summary,
|
|
249
|
+
description,
|
|
250
|
+
tags,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def patch(
|
|
254
|
+
self,
|
|
255
|
+
path: str,
|
|
256
|
+
*,
|
|
257
|
+
input: type[BaseModel],
|
|
258
|
+
output: type,
|
|
259
|
+
list_count: int | str = 5,
|
|
260
|
+
seed_from: str | None = None,
|
|
261
|
+
error_rate: float = 0,
|
|
262
|
+
error_codes: list[int] | None = None,
|
|
263
|
+
latency_ms: float = 0,
|
|
264
|
+
jitter_ms: float = 0,
|
|
265
|
+
filter_by: str | None = None,
|
|
266
|
+
rate_limit: float | None = None,
|
|
267
|
+
summary: str | None = None,
|
|
268
|
+
description: str | None = None,
|
|
269
|
+
tags: list[str] | None = None,
|
|
270
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
271
|
+
"""
|
|
272
|
+
Register a PATCH endpoint. Request body is validated against `input`.
|
|
273
|
+
Response is generated from `output` (single model or list[model]).
|
|
274
|
+
|
|
275
|
+
Usage:
|
|
276
|
+
@api.patch("/users/{id}", input=PatchUserRequest, output=User)
|
|
277
|
+
def patch_user():
|
|
278
|
+
pass
|
|
279
|
+
"""
|
|
280
|
+
return self._register(
|
|
281
|
+
path,
|
|
282
|
+
"PATCH",
|
|
283
|
+
input,
|
|
284
|
+
output,
|
|
285
|
+
list_count,
|
|
286
|
+
seed_from,
|
|
287
|
+
error_rate,
|
|
288
|
+
error_codes,
|
|
289
|
+
latency_ms,
|
|
290
|
+
jitter_ms,
|
|
291
|
+
filter_by,
|
|
292
|
+
rate_limit,
|
|
293
|
+
summary,
|
|
294
|
+
description,
|
|
295
|
+
tags,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def delete(
|
|
299
|
+
self,
|
|
300
|
+
path: str,
|
|
301
|
+
*,
|
|
302
|
+
input: type[BaseModel],
|
|
303
|
+
output: type | None = None,
|
|
304
|
+
rate_limit: float | None = None,
|
|
305
|
+
summary: str | None = None,
|
|
306
|
+
description: str | None = None,
|
|
307
|
+
tags: list[str] | None = None,
|
|
308
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
309
|
+
"""
|
|
310
|
+
Register a DELETE endpoint. Path params (and optional body) are validated
|
|
311
|
+
against `input`. If `output` is None, returns 204 No Content.
|
|
312
|
+
If `output` is a model, returns 200 with generated body.
|
|
313
|
+
|
|
314
|
+
Usage:
|
|
315
|
+
@api.delete("/users/{id}", input=DeleteUserQuery)
|
|
316
|
+
def delete_user():
|
|
317
|
+
pass
|
|
318
|
+
"""
|
|
319
|
+
return self._register(
|
|
320
|
+
path,
|
|
321
|
+
"DELETE",
|
|
322
|
+
input,
|
|
323
|
+
output,
|
|
324
|
+
list_count=1,
|
|
325
|
+
seed_from=None,
|
|
326
|
+
error_rate=0,
|
|
327
|
+
error_codes=None,
|
|
328
|
+
latency_ms=0,
|
|
329
|
+
jitter_ms=0,
|
|
330
|
+
filter_by=None,
|
|
331
|
+
rate_limit=rate_limit,
|
|
332
|
+
summary=summary,
|
|
333
|
+
description=description,
|
|
334
|
+
tags=tags,
|
|
335
|
+
)
|
|
336
|
+
|
|
191
337
|
def _register(
|
|
192
338
|
self,
|
|
193
339
|
path: str,
|
|
194
340
|
method: str,
|
|
195
341
|
input_model: type[BaseModel],
|
|
196
|
-
output: type,
|
|
342
|
+
output: type | None,
|
|
197
343
|
list_count: int | str = 5,
|
|
198
344
|
seed_from: str | None = None,
|
|
199
345
|
error_rate: float = 0,
|
|
@@ -201,6 +347,7 @@ class SemblanceAPI:
|
|
|
201
347
|
latency_ms: float = 0,
|
|
202
348
|
jitter_ms: float = 0,
|
|
203
349
|
filter_by: str | None = None,
|
|
350
|
+
rate_limit: float | None = None,
|
|
204
351
|
summary: str | None = None,
|
|
205
352
|
description: str | None = None,
|
|
206
353
|
tags: list[str] | None = None,
|
|
@@ -220,6 +367,7 @@ class SemblanceAPI:
|
|
|
220
367
|
latency_ms=latency_ms,
|
|
221
368
|
jitter_ms=jitter_ms,
|
|
222
369
|
filter_by=filter_by,
|
|
370
|
+
rate_limit=rate_limit,
|
|
223
371
|
summary=summary,
|
|
224
372
|
description=description,
|
|
225
373
|
tags=tags,
|
|
@@ -246,6 +394,12 @@ class SemblanceAPI:
|
|
|
246
394
|
self._register_get(app, spec)
|
|
247
395
|
elif method == "POST":
|
|
248
396
|
self._register_post(app, spec)
|
|
397
|
+
elif method == "PUT":
|
|
398
|
+
self._register_put(app, spec)
|
|
399
|
+
elif method == "PATCH":
|
|
400
|
+
self._register_patch(app, spec)
|
|
401
|
+
elif method == "DELETE":
|
|
402
|
+
self._register_delete(app, spec)
|
|
249
403
|
|
|
250
404
|
return app
|
|
251
405
|
|
|
@@ -307,6 +461,17 @@ class SemblanceAPI:
|
|
|
307
461
|
merged = {**data.model_dump(), **path_params}
|
|
308
462
|
return input_model.model_validate(merged)
|
|
309
463
|
|
|
464
|
+
def _check_rate_limit(self, spec: EndpointSpec) -> None:
|
|
465
|
+
"""Raise HTTPException 429 if rate limit exceeded."""
|
|
466
|
+
if spec.rate_limit is None or spec.rate_limit <= 0:
|
|
467
|
+
return
|
|
468
|
+
limiter = get_limiter()
|
|
469
|
+
method = spec.methods[0]
|
|
470
|
+
if not limiter.check_and_record(spec.path, method, spec.rate_limit):
|
|
471
|
+
raise HTTPException(
|
|
472
|
+
status_code=429, detail="Rate limit exceeded (simulated)"
|
|
473
|
+
)
|
|
474
|
+
|
|
310
475
|
def _register_get(self, app: FastAPI, spec: EndpointSpec) -> None:
|
|
311
476
|
input_model = spec.input_model
|
|
312
477
|
output_annotation = spec.output_annotation
|
|
@@ -324,16 +489,67 @@ class SemblanceAPI:
|
|
|
324
489
|
request: Request,
|
|
325
490
|
query: Annotated[input_model, Query()],
|
|
326
491
|
) -> output_annotation:
|
|
492
|
+
assert output_annotation is not None
|
|
493
|
+
self._check_rate_limit(spec)
|
|
327
494
|
merged = self._merge_path_params(
|
|
328
495
|
input_model, query, dict(request.path_params)
|
|
329
496
|
)
|
|
330
497
|
seed = self._resolve_seed(seed_from, merged)
|
|
331
498
|
self._maybe_raise_error(error_rate, error_codes, seed)
|
|
332
499
|
await self._await_latency(latency_ms, jitter_ms)
|
|
500
|
+
response: BaseModel | list[BaseModel]
|
|
333
501
|
if store is not None and get_origin(output_annotation) is list:
|
|
334
|
-
|
|
502
|
+
response = store.get_all(path)
|
|
503
|
+
else:
|
|
504
|
+
count = self._resolve_list_count(list_count, merged)
|
|
505
|
+
response = build_response(
|
|
506
|
+
output_annotation,
|
|
507
|
+
input_model,
|
|
508
|
+
merged,
|
|
509
|
+
list_count=count,
|
|
510
|
+
seed=seed,
|
|
511
|
+
filter_by=filter_by,
|
|
512
|
+
)
|
|
513
|
+
if self._validate_responses:
|
|
514
|
+
validate_response(output_annotation, response)
|
|
515
|
+
return response
|
|
516
|
+
|
|
517
|
+
kwargs: dict[str, Any] = {"response_model": output_annotation}
|
|
518
|
+
if spec.summary is not None:
|
|
519
|
+
kwargs["summary"] = spec.summary
|
|
520
|
+
if spec.description is not None:
|
|
521
|
+
kwargs["description"] = spec.description
|
|
522
|
+
if spec.tags is not None:
|
|
523
|
+
kwargs["tags"] = spec.tags
|
|
524
|
+
app.get(spec.path, **kwargs)(handler)
|
|
525
|
+
|
|
526
|
+
def _register_post(self, app: FastAPI, spec: EndpointSpec) -> None:
|
|
527
|
+
assert spec.output_annotation is not None
|
|
528
|
+
input_model = spec.input_model
|
|
529
|
+
output_annotation = spec.output_annotation
|
|
530
|
+
list_count = spec.list_count
|
|
531
|
+
seed_from = spec.seed_from
|
|
532
|
+
error_rate = spec.error_rate
|
|
533
|
+
error_codes = spec.error_codes
|
|
534
|
+
latency_ms = spec.latency_ms
|
|
535
|
+
jitter_ms = spec.jitter_ms
|
|
536
|
+
filter_by = spec.filter_by
|
|
537
|
+
store = self._store
|
|
538
|
+
path = spec.path
|
|
539
|
+
|
|
540
|
+
async def handler(
|
|
541
|
+
request: Request,
|
|
542
|
+
body: input_model,
|
|
543
|
+
) -> output_annotation:
|
|
544
|
+
self._check_rate_limit(spec)
|
|
545
|
+
merged = self._merge_path_params(
|
|
546
|
+
input_model, body, dict(request.path_params)
|
|
547
|
+
)
|
|
548
|
+
seed = self._resolve_seed(seed_from, merged)
|
|
549
|
+
self._maybe_raise_error(error_rate, error_codes, seed)
|
|
550
|
+
await self._await_latency(latency_ms, jitter_ms)
|
|
335
551
|
count = self._resolve_list_count(list_count, merged)
|
|
336
|
-
|
|
552
|
+
response = build_response(
|
|
337
553
|
output_annotation,
|
|
338
554
|
input_model,
|
|
339
555
|
merged,
|
|
@@ -341,6 +557,11 @@ class SemblanceAPI:
|
|
|
341
557
|
seed=seed,
|
|
342
558
|
filter_by=filter_by,
|
|
343
559
|
)
|
|
560
|
+
if store is not None and not isinstance(response, list):
|
|
561
|
+
response = store.add(path, response)
|
|
562
|
+
if self._validate_responses:
|
|
563
|
+
validate_response(output_annotation, response)
|
|
564
|
+
return response
|
|
344
565
|
|
|
345
566
|
kwargs: dict[str, Any] = {"response_model": output_annotation}
|
|
346
567
|
if spec.summary is not None:
|
|
@@ -349,9 +570,10 @@ class SemblanceAPI:
|
|
|
349
570
|
kwargs["description"] = spec.description
|
|
350
571
|
if spec.tags is not None:
|
|
351
572
|
kwargs["tags"] = spec.tags
|
|
352
|
-
app.
|
|
573
|
+
app.post(spec.path, **kwargs)(handler)
|
|
353
574
|
|
|
354
|
-
def
|
|
575
|
+
def _register_put(self, app: FastAPI, spec: EndpointSpec) -> None:
|
|
576
|
+
assert spec.output_annotation is not None
|
|
355
577
|
input_model = spec.input_model
|
|
356
578
|
output_annotation = spec.output_annotation
|
|
357
579
|
list_count = spec.list_count
|
|
@@ -361,13 +583,12 @@ class SemblanceAPI:
|
|
|
361
583
|
latency_ms = spec.latency_ms
|
|
362
584
|
jitter_ms = spec.jitter_ms
|
|
363
585
|
filter_by = spec.filter_by
|
|
364
|
-
store = self._store
|
|
365
|
-
path = spec.path
|
|
366
586
|
|
|
367
587
|
async def handler(
|
|
368
588
|
request: Request,
|
|
369
589
|
body: input_model,
|
|
370
590
|
) -> output_annotation:
|
|
591
|
+
self._check_rate_limit(spec)
|
|
371
592
|
merged = self._merge_path_params(
|
|
372
593
|
input_model, body, dict(request.path_params)
|
|
373
594
|
)
|
|
@@ -383,8 +604,8 @@ class SemblanceAPI:
|
|
|
383
604
|
seed=seed,
|
|
384
605
|
filter_by=filter_by,
|
|
385
606
|
)
|
|
386
|
-
if
|
|
387
|
-
|
|
607
|
+
if self._validate_responses:
|
|
608
|
+
validate_response(output_annotation, response)
|
|
388
609
|
return response
|
|
389
610
|
|
|
390
611
|
kwargs: dict[str, Any] = {"response_model": output_annotation}
|
|
@@ -394,4 +615,94 @@ class SemblanceAPI:
|
|
|
394
615
|
kwargs["description"] = spec.description
|
|
395
616
|
if spec.tags is not None:
|
|
396
617
|
kwargs["tags"] = spec.tags
|
|
397
|
-
app.
|
|
618
|
+
app.put(spec.path, **kwargs)(handler)
|
|
619
|
+
|
|
620
|
+
def _register_patch(self, app: FastAPI, spec: EndpointSpec) -> None:
|
|
621
|
+
assert spec.output_annotation is not None
|
|
622
|
+
input_model = spec.input_model
|
|
623
|
+
output_annotation = spec.output_annotation
|
|
624
|
+
list_count = spec.list_count
|
|
625
|
+
seed_from = spec.seed_from
|
|
626
|
+
error_rate = spec.error_rate
|
|
627
|
+
error_codes = spec.error_codes
|
|
628
|
+
latency_ms = spec.latency_ms
|
|
629
|
+
jitter_ms = spec.jitter_ms
|
|
630
|
+
filter_by = spec.filter_by
|
|
631
|
+
|
|
632
|
+
async def handler(
|
|
633
|
+
request: Request,
|
|
634
|
+
body: input_model,
|
|
635
|
+
) -> output_annotation:
|
|
636
|
+
self._check_rate_limit(spec)
|
|
637
|
+
merged = self._merge_path_params(
|
|
638
|
+
input_model, body, dict(request.path_params)
|
|
639
|
+
)
|
|
640
|
+
seed = self._resolve_seed(seed_from, merged)
|
|
641
|
+
self._maybe_raise_error(error_rate, error_codes, seed)
|
|
642
|
+
await self._await_latency(latency_ms, jitter_ms)
|
|
643
|
+
count = self._resolve_list_count(list_count, merged)
|
|
644
|
+
response = build_response(
|
|
645
|
+
output_annotation,
|
|
646
|
+
input_model,
|
|
647
|
+
merged,
|
|
648
|
+
list_count=count,
|
|
649
|
+
seed=seed,
|
|
650
|
+
filter_by=filter_by,
|
|
651
|
+
)
|
|
652
|
+
if self._validate_responses:
|
|
653
|
+
validate_response(output_annotation, response)
|
|
654
|
+
return response
|
|
655
|
+
|
|
656
|
+
kwargs: dict[str, Any] = {"response_model": output_annotation}
|
|
657
|
+
if spec.summary is not None:
|
|
658
|
+
kwargs["summary"] = spec.summary
|
|
659
|
+
if spec.description is not None:
|
|
660
|
+
kwargs["description"] = spec.description
|
|
661
|
+
if spec.tags is not None:
|
|
662
|
+
kwargs["tags"] = spec.tags
|
|
663
|
+
app.patch(spec.path, **kwargs)(handler)
|
|
664
|
+
|
|
665
|
+
def _register_delete(self, app: FastAPI, spec: EndpointSpec) -> None:
|
|
666
|
+
input_model = spec.input_model
|
|
667
|
+
output_annotation = spec.output_annotation
|
|
668
|
+
seed_from = spec.seed_from
|
|
669
|
+
error_rate = spec.error_rate
|
|
670
|
+
error_codes = spec.error_codes
|
|
671
|
+
latency_ms = spec.latency_ms
|
|
672
|
+
jitter_ms = spec.jitter_ms
|
|
673
|
+
|
|
674
|
+
async def handler(
|
|
675
|
+
request: Request,
|
|
676
|
+
body: input_model | None = Body(None),
|
|
677
|
+
) -> Any:
|
|
678
|
+
self._check_rate_limit(spec)
|
|
679
|
+
path_params = dict(request.path_params)
|
|
680
|
+
data: dict[str, Any] = body.model_dump() if body is not None else {}
|
|
681
|
+
merged = input_model.model_validate({**data, **path_params})
|
|
682
|
+
seed = self._resolve_seed(seed_from, merged)
|
|
683
|
+
self._maybe_raise_error(error_rate, error_codes, seed)
|
|
684
|
+
await self._await_latency(latency_ms, jitter_ms)
|
|
685
|
+
if output_annotation is None:
|
|
686
|
+
return Response(status_code=204)
|
|
687
|
+
response = build_response(
|
|
688
|
+
output_annotation,
|
|
689
|
+
input_model,
|
|
690
|
+
merged,
|
|
691
|
+
list_count=1,
|
|
692
|
+
seed=seed,
|
|
693
|
+
filter_by=None,
|
|
694
|
+
)
|
|
695
|
+
if self._validate_responses:
|
|
696
|
+
validate_response(output_annotation, response)
|
|
697
|
+
return response
|
|
698
|
+
|
|
699
|
+
kwargs: dict[str, Any] = {}
|
|
700
|
+
if output_annotation is not None:
|
|
701
|
+
kwargs["response_model"] = output_annotation
|
|
702
|
+
if spec.summary is not None:
|
|
703
|
+
kwargs["summary"] = spec.summary
|
|
704
|
+
if spec.description is not None:
|
|
705
|
+
kwargs["description"] = spec.description
|
|
706
|
+
if spec.tags is not None:
|
|
707
|
+
kwargs["tags"] = spec.tags
|
|
708
|
+
app.delete(spec.path, **kwargs)(handler)
|
|
@@ -9,9 +9,10 @@ import argparse
|
|
|
9
9
|
import importlib.util
|
|
10
10
|
import sys
|
|
11
11
|
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
def _load_app(path: str):
|
|
15
|
+
def _load_app(path: str) -> Any:
|
|
15
16
|
"""Load app from module:attr. If attr has as_fastapi(), it is called to get FastAPI app."""
|
|
16
17
|
if ":" not in path:
|
|
17
18
|
raise SystemExit(
|
|
@@ -16,7 +16,7 @@ from fastapi.testclient import TestClient
|
|
|
16
16
|
|
|
17
17
|
def _get_routes(app: FastAPI) -> list[tuple[str, str, str]]:
|
|
18
18
|
"""Return (path, method, route_id) for each API route."""
|
|
19
|
-
routes = []
|
|
19
|
+
routes: list[tuple[str, str, str]] = []
|
|
20
20
|
for route in app.routes:
|
|
21
21
|
if hasattr(route, "path") and hasattr(route, "methods"):
|
|
22
22
|
for method in route.methods - {"HEAD", "OPTIONS"}:
|
|
@@ -187,3 +187,18 @@ def build_response(
|
|
|
187
187
|
"Use a Pydantic BaseModel or list[SomeModel] for output."
|
|
188
188
|
)
|
|
189
189
|
return build_one(model, input_model, input_instance, seed=seed)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def validate_response(
|
|
193
|
+
output_annotation: type,
|
|
194
|
+
instance: BaseModel | list[BaseModel],
|
|
195
|
+
) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Validate that instance conforms to output_annotation.
|
|
198
|
+
Raises ValidationError on mismatch. For list/PaginatedResponse,
|
|
199
|
+
validates the structure and each item.
|
|
200
|
+
"""
|
|
201
|
+
from pydantic import TypeAdapter
|
|
202
|
+
|
|
203
|
+
adapter: TypeAdapter[Any] = TypeAdapter(output_annotation)
|
|
204
|
+
adapter.validate_python(instance)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Property-based testing helpers for Semblance APIs.
|
|
3
|
+
|
|
4
|
+
Generate Hypothesis strategies from input models and run endpoint tests
|
|
5
|
+
that validate responses against output schemas and optional invariants.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Annotated, Any, get_args, get_origin
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from hypothesis import given
|
|
18
|
+
from hypothesis import strategies as st
|
|
19
|
+
except ImportError as e:
|
|
20
|
+
raise ImportError(
|
|
21
|
+
"Property-based testing requires hypothesis. Install with: pip install semblance[dev]"
|
|
22
|
+
) from e
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_path_params(path: str) -> list[str]:
|
|
26
|
+
"""Extract path param names from template, e.g. '/users/{id}' -> ['id']."""
|
|
27
|
+
return re.findall(r"\{(\w+)\}", path)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_bare_annotation(annotation: type) -> type:
|
|
31
|
+
"""Strip Annotated and Union to get a concrete type for strategy generation."""
|
|
32
|
+
origin = get_origin(annotation)
|
|
33
|
+
args = get_args(annotation)
|
|
34
|
+
if origin is Annotated and args:
|
|
35
|
+
return _get_bare_annotation(args[0])
|
|
36
|
+
if origin is type(None) or (origin is not None and "Union" in str(origin)) and args:
|
|
37
|
+
for a in args:
|
|
38
|
+
if a is type(None):
|
|
39
|
+
continue
|
|
40
|
+
return _get_bare_annotation(a)
|
|
41
|
+
return annotation
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _strategy_for_annotation(annotation: type) -> st.SearchStrategy[Any]:
|
|
45
|
+
"""Build a Hypothesis strategy for a single field annotation."""
|
|
46
|
+
bare = _get_bare_annotation(annotation)
|
|
47
|
+
if bare is type(None):
|
|
48
|
+
return st.none()
|
|
49
|
+
try:
|
|
50
|
+
if isinstance(bare, type) and issubclass(bare, BaseModel):
|
|
51
|
+
return strategy_for_input_model(bare)
|
|
52
|
+
except TypeError:
|
|
53
|
+
pass
|
|
54
|
+
if bare is str:
|
|
55
|
+
return st.text()
|
|
56
|
+
if bare is int:
|
|
57
|
+
return st.integers()
|
|
58
|
+
if bare is float:
|
|
59
|
+
return st.floats(allow_nan=False)
|
|
60
|
+
if bare is bool:
|
|
61
|
+
return st.booleans()
|
|
62
|
+
try:
|
|
63
|
+
return st.from_type(bare)
|
|
64
|
+
except Exception:
|
|
65
|
+
return st.none()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def strategy_for_input_model(
|
|
69
|
+
model: type[BaseModel],
|
|
70
|
+
path_template: str | None = None,
|
|
71
|
+
) -> st.SearchStrategy[BaseModel]:
|
|
72
|
+
"""
|
|
73
|
+
Build a Hypothesis strategy that generates instances of the input model.
|
|
74
|
+
|
|
75
|
+
Use for GET (query) or POST/PUT/PATCH (body) input. If path_template is
|
|
76
|
+
given (e.g. '/users/{id}'), path param names are inferred; the strategy
|
|
77
|
+
still generates full model instances (path params can be passed separately
|
|
78
|
+
when building the request).
|
|
79
|
+
"""
|
|
80
|
+
strategies: dict[str, st.SearchStrategy[Any]] = {}
|
|
81
|
+
for name, field in model.model_fields.items():
|
|
82
|
+
ann = field.annotation
|
|
83
|
+
if ann is not None:
|
|
84
|
+
strategies[name] = _strategy_for_annotation(ann)
|
|
85
|
+
else:
|
|
86
|
+
strategies[name] = st.none()
|
|
87
|
+
return st.builds(model, **strategies)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_endpoint(
|
|
91
|
+
client: Any,
|
|
92
|
+
method: str,
|
|
93
|
+
path: str,
|
|
94
|
+
input_strategy: st.SearchStrategy[BaseModel],
|
|
95
|
+
output_model: type,
|
|
96
|
+
path_params: dict[str, Any] | None = None,
|
|
97
|
+
validate_response: bool = True,
|
|
98
|
+
invariants: tuple[Callable[[Any, Any], bool], ...] = (),
|
|
99
|
+
) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Run a property-based test: draw input from input_strategy, call the
|
|
102
|
+
endpoint, assert status and that response conforms to output_model.
|
|
103
|
+
Optional invariants are (input, output) -> bool; all must hold.
|
|
104
|
+
"""
|
|
105
|
+
path_params = path_params or {}
|
|
106
|
+
path_params_from_template = {
|
|
107
|
+
k: (path_params.get(k) or "placeholder") for k in _parse_path_params(path)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@given(input_strategy)
|
|
111
|
+
def _run(input_instance: BaseModel) -> None:
|
|
112
|
+
data = input_instance.model_dump()
|
|
113
|
+
url = path
|
|
114
|
+
for key, val in path_params_from_template.items():
|
|
115
|
+
url = url.replace("{" + key + "}", str(val))
|
|
116
|
+
if method.upper() == "GET":
|
|
117
|
+
from urllib.parse import urlencode
|
|
118
|
+
|
|
119
|
+
query = urlencode(data)
|
|
120
|
+
url = f"{url}?{query}" if query else url
|
|
121
|
+
r = client.get(url)
|
|
122
|
+
elif method.upper() in ("POST", "PUT", "PATCH"):
|
|
123
|
+
r = client.request(method.upper(), url, json=data)
|
|
124
|
+
elif method.upper() == "DELETE":
|
|
125
|
+
r = client.delete(url)
|
|
126
|
+
else:
|
|
127
|
+
raise ValueError(f"Unsupported method {method}")
|
|
128
|
+
assert r.status_code in (200, 201, 204), (r.status_code, r.text)
|
|
129
|
+
if r.status_code == 204:
|
|
130
|
+
return
|
|
131
|
+
body = r.json()
|
|
132
|
+
if validate_response:
|
|
133
|
+
from pydantic import TypeAdapter
|
|
134
|
+
|
|
135
|
+
adapter: TypeAdapter[Any] = TypeAdapter(output_model)
|
|
136
|
+
adapter.validate_python(body)
|
|
137
|
+
for inv in invariants:
|
|
138
|
+
assert inv(input_instance, body), f"Invariant failed: {inv}"
|
|
139
|
+
|
|
140
|
+
_run()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rate limiting simulation for endpoints.
|
|
3
|
+
|
|
4
|
+
Sliding-window: at most N requests per second per (path, method).
|
|
5
|
+
Per-process, in-memory; for simulation only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from threading import Lock
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RateLimiter:
|
|
14
|
+
"""Sliding-window rate limiter keyed by (path, method)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._timestamps: dict[tuple[str, str], list[float]] = defaultdict(list)
|
|
18
|
+
self._lock = Lock()
|
|
19
|
+
|
|
20
|
+
def check_and_record(self, path: str, method: str, limit: float) -> bool:
|
|
21
|
+
"""
|
|
22
|
+
Record a request and return True if under limit, False if over limit.
|
|
23
|
+
|
|
24
|
+
Uses a 1-second sliding window: requests older than 1 second are dropped.
|
|
25
|
+
"""
|
|
26
|
+
if limit <= 0:
|
|
27
|
+
return True
|
|
28
|
+
now = time.monotonic()
|
|
29
|
+
key = (path, method)
|
|
30
|
+
with self._lock:
|
|
31
|
+
ts_list = self._timestamps[key]
|
|
32
|
+
# Drop timestamps older than 1 second
|
|
33
|
+
ts_list[:] = [t for t in ts_list if now - t < 1.0]
|
|
34
|
+
if len(ts_list) >= limit:
|
|
35
|
+
return False
|
|
36
|
+
ts_list.append(now)
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_limiter: RateLimiter | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_limiter() -> RateLimiter:
|
|
44
|
+
"""Return the global rate limiter instance."""
|
|
45
|
+
global _limiter
|
|
46
|
+
if _limiter is None:
|
|
47
|
+
_limiter = RateLimiter()
|
|
48
|
+
return _limiter
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: semblance
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Schema-driven REST API simulation with FastAPI, Pydantic, and Polyfactory
|
|
5
5
|
Author: Semblance Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,6 +25,7 @@ Provides-Extra: dev
|
|
|
25
25
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
26
26
|
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
27
27
|
Requires-Dist: httpx>=0.25.0; extra == "dev"
|
|
28
|
+
Requires-Dist: hypothesis>=6.0.0; extra == "dev"
|
|
28
29
|
Requires-Dist: ruff>=0.8.0; extra == "dev"
|
|
29
30
|
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
30
31
|
Requires-Dist: bandit>=1.7.0; extra == "dev"
|
|
@@ -55,7 +56,7 @@ Define API behavior declaratively using schemas and dependency metadata—no end
|
|
|
55
56
|
- **FastAPI-native** — Full OpenAPI, validation, async
|
|
56
57
|
- **Deterministic** — Seeded generation for reproducible tests
|
|
57
58
|
- **Extensible** — Custom link types via plugins
|
|
58
|
-
- **Production-ready** — Error simulation, latency, pagination, stateful mode
|
|
59
|
+
- **Production-ready** — Error simulation, latency, rate limiting, pagination, stateful mode, optional response validation
|
|
59
60
|
|
|
60
61
|
## Requirements
|
|
61
62
|
|
|
@@ -111,6 +112,8 @@ def users():
|
|
|
111
112
|
app = api.as_fastapi()
|
|
112
113
|
```
|
|
113
114
|
|
|
115
|
+
You can register PUT, PATCH, and DELETE endpoints the same way (`@api.put(...)`, `@api.patch(...)`, `@api.delete(..., output=None)` for 204).
|
|
116
|
+
|
|
114
117
|
Run:
|
|
115
118
|
|
|
116
119
|
```bash
|
|
@@ -219,15 +222,18 @@ class User(BaseModel):
|
|
|
219
222
|
|
|
220
223
|
| Feature | Description |
|
|
221
224
|
|---------|-------------|
|
|
222
|
-
| **SemblanceAPI** | GET
|
|
225
|
+
| **SemblanceAPI** | GET, POST, PUT, PATCH, DELETE endpoints with input/output models |
|
|
223
226
|
| **Links** | FromInput, DateRangeFrom, WhenInput, ComputedFrom |
|
|
224
227
|
| **Pagination** | PageParams, PaginatedResponse[T] |
|
|
225
228
|
| **Seeding** | `SemblanceAPI(seed=42)` or `seed_from="seed"` |
|
|
226
229
|
| **Error simulation** | `error_rate`, `error_codes` |
|
|
227
230
|
| **Latency** | `latency_ms`, `jitter_ms` |
|
|
231
|
+
| **Rate limiting** | `rate_limit=N` — 429 when exceeded (per endpoint, sliding window) |
|
|
228
232
|
| **Filtering** | `filter_by` for list endpoints |
|
|
229
233
|
| **Stateful mode** | `SemblanceAPI(stateful=True)` — POST stores, GET returns stored |
|
|
234
|
+
| **Response validation** | `SemblanceAPI(validate_responses=True)` — verify output conforms to model |
|
|
230
235
|
| **OpenAPI** | summary, description, tags on endpoints |
|
|
236
|
+
| **Property-based testing** | `semblance.property_testing`: `strategy_for_input_model()`, `test_endpoint()` (Hypothesis) |
|
|
231
237
|
|
|
232
238
|
## Competitors & Alternatives
|
|
233
239
|
|
|
@@ -245,7 +251,7 @@ class User(BaseModel):
|
|
|
245
251
|
| **Extensible (plugins)** | ✅ | <span title="Middleware-based; custom behavior via decorators or wrappers">🟡</span> | ❌ | ❌ | ✅ | <span title="Templates and response rules provide extensibility">🟡</span> |
|
|
246
252
|
| **OpenAPI schema** | ✅ | ✅ | ✅ | ❌ | ✅ | <span title="Can import/export OpenAPI; design is GUI-first, not schema-first">🟡</span> |
|
|
247
253
|
| **CI / pytest integration** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
|
248
|
-
| **Property-based testing** |
|
|
254
|
+
| **Property-based testing** | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ |
|
|
249
255
|
|
|
250
256
|
🟡 = partial or configurable
|
|
251
257
|
|
|
@@ -9,6 +9,8 @@ src/semblance/factory.py
|
|
|
9
9
|
src/semblance/links.py
|
|
10
10
|
src/semblance/pagination.py
|
|
11
11
|
src/semblance/plugins.py
|
|
12
|
+
src/semblance/property_testing.py
|
|
13
|
+
src/semblance/rate_limit.py
|
|
12
14
|
src/semblance/resolver.py
|
|
13
15
|
src/semblance/state.py
|
|
14
16
|
src/semblance/testing.py
|
|
@@ -26,6 +28,7 @@ tests/test_links.py
|
|
|
26
28
|
tests/test_phase2.py
|
|
27
29
|
tests/test_phase3.py
|
|
28
30
|
tests/test_phase4.py
|
|
31
|
+
tests/test_phase5.py
|
|
29
32
|
tests/test_plugins.py
|
|
30
33
|
tests/test_resolver.py
|
|
31
34
|
tests/test_state.py
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Tests for Phase 5: PUT/PATCH/DELETE, rate limiting, response validation, property-based testing."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from semblance import SemblanceAPI
|
|
9
|
+
from semblance.testing import test_client as make_client
|
|
10
|
+
from tests.example_models import User, UserQuery
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UpdateBody(BaseModel):
|
|
14
|
+
"""Minimal body for PUT/PATCH."""
|
|
15
|
+
|
|
16
|
+
name: str = "updated"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DeletePathInput(BaseModel):
|
|
20
|
+
"""Path-only input for DELETE (e.g. id from path)."""
|
|
21
|
+
|
|
22
|
+
id: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --- PUT / PATCH / DELETE ---
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestPutPatchDelete:
|
|
29
|
+
def test_put_returns_generated_response(self):
|
|
30
|
+
api = SemblanceAPI(seed=42)
|
|
31
|
+
api.put("/users/{id}", input=UpdateBody, output=User)(lambda: None)
|
|
32
|
+
app = api.as_fastapi()
|
|
33
|
+
client = make_client(app)
|
|
34
|
+
r = client.put("/users/abc", json={"name": "put-user"})
|
|
35
|
+
assert r.status_code == 200
|
|
36
|
+
data = r.json()
|
|
37
|
+
assert "name" in data
|
|
38
|
+
assert data["name"] == "put-user"
|
|
39
|
+
|
|
40
|
+
def test_patch_returns_generated_response(self):
|
|
41
|
+
api = SemblanceAPI(seed=42)
|
|
42
|
+
api.patch("/users/{id}", input=UpdateBody, output=User)(lambda: None)
|
|
43
|
+
app = api.as_fastapi()
|
|
44
|
+
client = make_client(app)
|
|
45
|
+
r = client.patch("/users/xyz", json={"name": "patch-user"})
|
|
46
|
+
assert r.status_code == 200
|
|
47
|
+
data = r.json()
|
|
48
|
+
assert data["name"] == "patch-user"
|
|
49
|
+
|
|
50
|
+
def test_delete_204_when_no_output(self):
|
|
51
|
+
api = SemblanceAPI()
|
|
52
|
+
api.delete("/users/{id}", input=DeletePathInput)(lambda: None)
|
|
53
|
+
app = api.as_fastapi()
|
|
54
|
+
client = make_client(app)
|
|
55
|
+
r = client.delete("/users/123")
|
|
56
|
+
assert r.status_code == 204
|
|
57
|
+
assert r.content in (b"", b" ")
|
|
58
|
+
|
|
59
|
+
def test_delete_200_with_output_model(self):
|
|
60
|
+
api = SemblanceAPI(seed=42)
|
|
61
|
+
api.delete("/users/{id}", input=DeletePathInput, output=User)(lambda: None)
|
|
62
|
+
app = api.as_fastapi()
|
|
63
|
+
client = make_client(app)
|
|
64
|
+
r = client.delete("/users/123")
|
|
65
|
+
assert r.status_code == 200
|
|
66
|
+
data = r.json()
|
|
67
|
+
assert "name" in data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- Rate limiting ---
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestRateLimit:
|
|
74
|
+
def test_rate_limit_returns_429_when_exceeded(self):
|
|
75
|
+
api = SemblanceAPI()
|
|
76
|
+
api.get(
|
|
77
|
+
"/limited",
|
|
78
|
+
input=UserQuery,
|
|
79
|
+
output=list[User],
|
|
80
|
+
rate_limit=2,
|
|
81
|
+
)(lambda: None)
|
|
82
|
+
app = api.as_fastapi()
|
|
83
|
+
client = make_client(app)
|
|
84
|
+
r1 = client.get("/limited?name=a")
|
|
85
|
+
r2 = client.get("/limited?name=b")
|
|
86
|
+
r3 = client.get("/limited?name=c")
|
|
87
|
+
assert r1.status_code == 200
|
|
88
|
+
assert r2.status_code == 200
|
|
89
|
+
assert r3.status_code == 429
|
|
90
|
+
|
|
91
|
+
def test_rate_limit_allows_after_window(self):
|
|
92
|
+
api = SemblanceAPI()
|
|
93
|
+
api.get(
|
|
94
|
+
"/limited2",
|
|
95
|
+
input=UserQuery,
|
|
96
|
+
output=list[User],
|
|
97
|
+
rate_limit=1,
|
|
98
|
+
)(lambda: None)
|
|
99
|
+
app = api.as_fastapi()
|
|
100
|
+
client = make_client(app)
|
|
101
|
+
assert client.get("/limited2?name=a").status_code == 200
|
|
102
|
+
assert client.get("/limited2?name=b").status_code == 429
|
|
103
|
+
time.sleep(1.1)
|
|
104
|
+
assert client.get("/limited2?name=c").status_code == 200
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- Response validation ---
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestValidateResponses:
|
|
111
|
+
def test_validate_responses_does_not_raise_on_valid_response(self):
|
|
112
|
+
api = SemblanceAPI(validate_responses=True)
|
|
113
|
+
api.get("/users", input=UserQuery, output=list[User])(lambda: None)
|
|
114
|
+
app = api.as_fastapi()
|
|
115
|
+
client = make_client(app)
|
|
116
|
+
r = client.get("/users?name=alice")
|
|
117
|
+
assert r.status_code == 200
|
|
118
|
+
assert isinstance(r.json(), list)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- Property-based testing ---
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.skipif(
|
|
125
|
+
__import__("importlib.util").util.find_spec("hypothesis") is None,
|
|
126
|
+
reason="hypothesis not installed",
|
|
127
|
+
)
|
|
128
|
+
class TestPropertyBased:
|
|
129
|
+
def test_strategy_for_input_model_generates_valid_instances(self):
|
|
130
|
+
from hypothesis import given
|
|
131
|
+
|
|
132
|
+
from semblance.property_testing import strategy_for_input_model
|
|
133
|
+
|
|
134
|
+
strategy = strategy_for_input_model(UserQuery)
|
|
135
|
+
|
|
136
|
+
@given(strategy)
|
|
137
|
+
def run(inp):
|
|
138
|
+
assert isinstance(inp, UserQuery)
|
|
139
|
+
parsed = UserQuery.model_validate(inp.model_dump())
|
|
140
|
+
assert parsed.name == inp.name
|
|
141
|
+
|
|
142
|
+
run()
|
|
143
|
+
|
|
144
|
+
def test_test_endpoint_get_validates_responses(self):
|
|
145
|
+
from hypothesis import given
|
|
146
|
+
from pydantic import TypeAdapter
|
|
147
|
+
|
|
148
|
+
from semblance.property_testing import strategy_for_input_model
|
|
149
|
+
|
|
150
|
+
api = SemblanceAPI(seed=42)
|
|
151
|
+
api.get("/users", input=UserQuery, output=list[User], list_count=2)(
|
|
152
|
+
lambda: None
|
|
153
|
+
)
|
|
154
|
+
app = api.as_fastapi()
|
|
155
|
+
client = make_client(app)
|
|
156
|
+
strategy = strategy_for_input_model(UserQuery)
|
|
157
|
+
|
|
158
|
+
@given(strategy)
|
|
159
|
+
def run(inp):
|
|
160
|
+
r = client.get("/users", params=inp.model_dump())
|
|
161
|
+
assert r.status_code == 200
|
|
162
|
+
TypeAdapter(list[User]).validate_python(r.json())
|
|
163
|
+
|
|
164
|
+
run()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|