mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b678542192 | |||
| 079e7ae3e5 | |||
| 9d4a9d5083 | |||
| 79c32fcef4 | |||
| 52896c6040 | |||
| def071fc71 | |||
| f194efecae | |||
| 868f84fc8b | |||
| ea3b6a6e4a | |||
| 0b16558b04 | |||
| 2440d4caed | |||
| e653a9f1fd | |||
| c1fe6e11bd | |||
| 06b7bae7c0 | |||
| 2494034111 | |||
| 07cfa66e5c | |||
| a5d38cf9c3 |
@@ -31,9 +31,6 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and
|
||||
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [v3.6.2] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- GraphQL error responses now correctly return 400 status instead of always 200
|
||||
- OpenAPI docs UI now respects custom `openapi_route` instead of hardcoding `/schema.yml`
|
||||
- `before_requests` default type mismatch that could crash routes called outside the router
|
||||
- Blocking synchronous file I/O in `Response.stream_file()` — now uses async I/O via anyio
|
||||
- Memory leak in rate limiter (empty bucket keys never cleaned up)
|
||||
- Race condition in rate limiter `check()` — added thread-safe locking
|
||||
- WSGI fallback catching all `TypeError`s instead of just call-signature mismatches
|
||||
- Pydantic request/response model validation crashing on non-dict bodies
|
||||
- Test assertions that could never fail (`or True`, `< 500` patterns)
|
||||
- `CaseInsensitiveDict` missing `__delitem__`, `pop`, and `setdefault` overrides
|
||||
- `assert` used for input validation in OpenAPI extension (stripped by `python -O`)
|
||||
- Potential XSS in GraphiQL template endpoint injection
|
||||
- Dead `or ""` in media format detection logic
|
||||
|
||||
### Changed
|
||||
|
||||
- `DELETE` requests now participate in Pydantic request body validation
|
||||
- Simplified status code category check to use chained comparison
|
||||
|
||||
### Removed
|
||||
|
||||
- Unused `method` parameter from `load_target()`
|
||||
- Unused Node.js setup step from CI test workflow
|
||||
|
||||
## [v3.6.1] - 2026-04-12
|
||||
|
||||
### Added
|
||||
@@ -497,6 +525,7 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
- Conception!
|
||||
|
||||
[v3.6.2]: https://github.com/kennethreitz/responder/compare/v3.6.1..v3.6.2
|
||||
[v3.6.1]: https://github.com/kennethreitz/responder/compare/v3.6.0..v3.6.1
|
||||
[v3.6.0]: https://github.com/kennethreitz/responder/compare/v3.5.0..v3.6.0
|
||||
[v3.5.0]: https://github.com/kennethreitz/responder/compare/v3.4.0..v3.5.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.6.1"
|
||||
__version__ = "3.6.2"
|
||||
|
||||
@@ -101,8 +101,7 @@ class GraphQLView:
|
||||
response_data["data"] = result.data
|
||||
|
||||
resp.media = response_data
|
||||
status_code = 200 if not result.errors else 400
|
||||
return (query, json.dumps(response_data), status_code)
|
||||
resp.status_code = 200 if not result.errors else 400
|
||||
|
||||
async def on_request(self, req, resp):
|
||||
await self.graphql_response(req, resp)
|
||||
|
||||
@@ -25,7 +25,7 @@ GRAPHIQL = """
|
||||
<script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
|
||||
<script>
|
||||
const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
|
||||
const fetcher = GraphiQL.createFetcher({ url: {{ endpoint | tojson }} });
|
||||
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
|
||||
root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
|
||||
</script>
|
||||
|
||||
@@ -174,8 +174,8 @@ class OpenAPISchema:
|
||||
def add_schema(self, name, schema, check_existing=True):
|
||||
"""Adds a marshmallow or Pydantic schema to the API specification."""
|
||||
if check_existing:
|
||||
assert name not in self.schemas
|
||||
assert name not in self.pydantic_schemas
|
||||
if name in self.schemas or name in self.pydantic_schemas:
|
||||
raise ValueError(f"Schema '{name}' is already registered")
|
||||
|
||||
if _is_pydantic_model(schema):
|
||||
self.pydantic_schemas[name] = schema
|
||||
@@ -216,12 +216,13 @@ class OpenAPISchema:
|
||||
f"{self.docs_theme}.html",
|
||||
title=self.title,
|
||||
version=self.version,
|
||||
schema_url="/schema.yml",
|
||||
schema_url=self.openapi_route,
|
||||
)
|
||||
|
||||
def static_url(self, asset):
|
||||
"""Given a static asset, return its URL path."""
|
||||
assert self.static_route is not None
|
||||
if self.static_route is None:
|
||||
raise RuntimeError("Cannot generate static URL: static_route is disabled")
|
||||
return f"{self.static_route}/{str(asset)}"
|
||||
|
||||
def docs_response(self, req, resp):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Simple in-memory rate limiter for Responder."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -28,6 +29,7 @@ class RateLimiter:
|
||||
self.max_requests = requests
|
||||
self.period = period
|
||||
self._buckets: dict[str, list[float]] = defaultdict(list)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _client_key(self, req):
|
||||
client = req.client
|
||||
@@ -39,20 +41,25 @@ class RateLimiter:
|
||||
now = time.time()
|
||||
cutoff = now - self.period
|
||||
self._buckets[key] = [t for t in self._buckets[key] if t > cutoff]
|
||||
if not self._buckets[key]:
|
||||
del self._buckets[key]
|
||||
|
||||
def check(self, req, resp):
|
||||
"""Check rate limit. Sets 429 status if exceeded."""
|
||||
key = self._client_key(req)
|
||||
self._cleanup(key)
|
||||
|
||||
if len(self._buckets[key]) >= self.max_requests:
|
||||
resp.status_code = 429
|
||||
resp.media = {"error": "rate limit exceeded"}
|
||||
resp.headers["Retry-After"] = str(self.period)
|
||||
return False
|
||||
with self._lock:
|
||||
self._cleanup(key)
|
||||
|
||||
if len(self._buckets[key]) >= self.max_requests:
|
||||
resp.status_code = 429
|
||||
resp.media = {"error": "rate limit exceeded"}
|
||||
resp.headers["Retry-After"] = str(self.period)
|
||||
return False
|
||||
|
||||
self._buckets[key].append(time.time())
|
||||
remaining = self.max_requests - len(self._buckets[key])
|
||||
|
||||
self._buckets[key].append(time.time())
|
||||
remaining = self.max_requests - len(self._buckets[key])
|
||||
resp.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
||||
resp.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
return True
|
||||
|
||||
+15
-4
@@ -34,12 +34,21 @@ class CaseInsensitiveDict(dict):
|
||||
def __getitem__(self, key):
|
||||
return super().__getitem__(key.lower())
|
||||
|
||||
def __delitem__(self, key):
|
||||
super().__delitem__(key.lower())
|
||||
|
||||
def __contains__(self, key):
|
||||
return super().__contains__(key.lower())
|
||||
|
||||
def get(self, key, default=None):
|
||||
return super().get(key.lower(), default)
|
||||
|
||||
def pop(self, key, *args):
|
||||
return super().pop(key.lower(), *args)
|
||||
|
||||
def setdefault(self, key, default=None):
|
||||
return super().setdefault(key.lower(), default)
|
||||
|
||||
def update(self, other=None, **kwargs):
|
||||
if other:
|
||||
for key, value in other.items():
|
||||
@@ -299,8 +308,8 @@ class Request:
|
||||
"""
|
||||
|
||||
if format is None:
|
||||
format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001
|
||||
format = "form" if "form" in self.mimetype or "" else format # noqa: A001
|
||||
format = "yaml" if "yaml" in self.mimetype else "json" # noqa: A001
|
||||
format = "form" if "form" in self.mimetype else format # noqa: A001
|
||||
|
||||
formatter: Callable
|
||||
if isinstance(format, str):
|
||||
@@ -464,9 +473,11 @@ class Response:
|
||||
self.mimetype = guessed or "application/octet-stream"
|
||||
|
||||
async def file_generator():
|
||||
with open(path, "rb") as f:
|
||||
import anyio
|
||||
|
||||
async with await anyio.open_file(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
chunk = await f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
+21
-10
@@ -130,7 +130,7 @@ class Route(BaseRoute):
|
||||
response = Response(req=request, formats=get_formats())
|
||||
|
||||
path_params = scope.get("path_params", {})
|
||||
before_requests = scope.get("before_requests", [])
|
||||
before_requests = scope.get("before_requests", {"http": [], "ws": []})
|
||||
|
||||
for before_request in before_requests.get("http", []):
|
||||
if inspect.iscoroutinefunction(before_request):
|
||||
@@ -144,9 +144,11 @@ class Route(BaseRoute):
|
||||
|
||||
# Auto-validate request body with Pydantic model
|
||||
req_model = getattr(self.endpoint, "_request_model", None)
|
||||
if req_model is not None and request.method in ("post", "put", "patch"):
|
||||
if req_model is not None and request.method in ("post", "put", "patch", "delete"):
|
||||
try:
|
||||
body = await request.media()
|
||||
if not isinstance(body, dict):
|
||||
raise TypeError("Request body must be a JSON object")
|
||||
req_model(**body)
|
||||
except Exception as exc:
|
||||
response.status_code = 422
|
||||
@@ -188,7 +190,7 @@ class Route(BaseRoute):
|
||||
|
||||
# Auto-serialize response with Pydantic model
|
||||
resp_model = getattr(self.endpoint, "_response_model", None)
|
||||
if resp_model is not None and response.media is not None:
|
||||
if resp_model is not None and isinstance(response.media, dict):
|
||||
try:
|
||||
validated = resp_model(**response.media)
|
||||
response.media = validated.model_dump()
|
||||
@@ -266,7 +268,7 @@ class WebSocketRoute(BaseRoute):
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
ws = WebSocket(scope, receive, send)
|
||||
|
||||
before_requests = scope.get("before_requests", [])
|
||||
before_requests = scope.get("before_requests", {"http": [], "ws": []})
|
||||
for before_request in before_requests.get("ws", []):
|
||||
await before_request(ws)
|
||||
|
||||
@@ -459,13 +461,22 @@ class Router:
|
||||
if path.startswith(path_prefix):
|
||||
scope["path"] = path[len(path_prefix) :] or "/"
|
||||
scope["root_path"] = root_path + path_prefix
|
||||
try:
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
except TypeError:
|
||||
from a2wsgi import WSGIMiddleware
|
||||
|
||||
app = WSGIMiddleware(app)
|
||||
if not (inspect.iscoroutinefunction(app) or hasattr(app, "__asgi_app__")):
|
||||
# Check if it looks like a WSGI app (callable with fewer params)
|
||||
try:
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
except TypeError as exc:
|
||||
# Only fall back to WSGI if the error is about call signature
|
||||
if "argument" not in str(exc) and "positional" not in str(exc):
|
||||
raise
|
||||
from a2wsgi import WSGIMiddleware
|
||||
|
||||
app = WSGIMiddleware(app)
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
else:
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ for number in codes:
|
||||
|
||||
|
||||
def _is_category(category, status_code):
|
||||
return all([(status_code >= category), (status_code < category + 100)])
|
||||
return category <= status_code < category + 100
|
||||
|
||||
|
||||
def is_100(status_code):
|
||||
|
||||
@@ -12,7 +12,7 @@ __all__ = [
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_target(target: str, default_property: str = "api", method: str = "run") -> t.Any:
|
||||
def load_target(target: str, default_property: str = "api") -> t.Any:
|
||||
"""
|
||||
Load Python code from a file path or module name.
|
||||
|
||||
@@ -24,7 +24,6 @@ def load_target(target: str, default_property: str = "api", method: str = "run")
|
||||
target: Module address (e.g., 'acme.app:foo'), file path (e.g., '/path/to/acme/app.py'),
|
||||
or URL.
|
||||
default_property: Name of the property to load if not specified in target (default: "api")
|
||||
method: Name of the method to invoke on the API instance (default: "run")
|
||||
|
||||
Returns:
|
||||
The API instance, loaded from the given property.
|
||||
@@ -32,7 +31,7 @@ def load_target(target: str, default_property: str = "api", method: str = "run")
|
||||
Raises:
|
||||
ValueError: If target format is invalid
|
||||
ImportError: If module cannot be imported
|
||||
AttributeError: If property or method is not found
|
||||
AttributeError: If property is not found
|
||||
|
||||
Example:
|
||||
>>> api = load_target("myapp.api:server")
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_background_task_exception(capsys):
|
||||
time.sleep(0.2) # let the done callback fire
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "ValueError" in captured.err or True # traceback goes to stderr
|
||||
assert "ValueError" in captured.err or "ValueError" in captured.out
|
||||
|
||||
|
||||
def test_background_run():
|
||||
@@ -112,7 +112,8 @@ def test_form_uploads_without_multipart(api):
|
||||
content="name=hello&value=world",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
assert r.json() == {"name": "world", "value": "world"} or r.status_code < 500
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"name": ["hello"], "value": ["world"]}
|
||||
|
||||
|
||||
# --- models.py coverage ---
|
||||
@@ -469,7 +470,7 @@ def test_graphql_text_query(api):
|
||||
content="{ hello }",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
assert r.status_code < 500
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_openapi_info_fields():
|
||||
|
||||
Reference in New Issue
Block a user