mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5055fe7974 |
@@ -25,12 +25,16 @@ jobs:
|
||||
"3.12",
|
||||
"3.13",
|
||||
"3.14",
|
||||
"3.14t",
|
||||
"3.15",
|
||||
"3.15t",
|
||||
"pypy3.11",
|
||||
]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
@@ -5,40 +5,6 @@ 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
|
||||
|
||||
- Configurable GZip compression via `gzip` parameter on `API()` (defaults to `True`)
|
||||
|
||||
## [v3.6.0] - 2026-03-24
|
||||
|
||||
### Added
|
||||
@@ -525,8 +491,6 @@ 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
|
||||
[v3.4.0]: https://github.com/kennethreitz/responder/compare/v3.3.0..v3.4.0
|
||||
|
||||
@@ -26,6 +26,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Programming Language :: Python :: 3.15",
|
||||
"Programming Language :: Python :: Free Threading",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.6.2"
|
||||
__version__ = "3.6.0"
|
||||
|
||||
+1
-6
@@ -31,7 +31,6 @@ class API:
|
||||
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
|
||||
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
|
||||
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
|
||||
:param gzip: If ``True`` (the default), compress responses with GZip.
|
||||
:param openapi_theme: OpenAPI documentation theme, must be one of ``elements``, ``rapidoc``, ``redoc``, ``swagger_ui``
|
||||
""" # noqa: E501
|
||||
|
||||
@@ -61,7 +60,6 @@ class API:
|
||||
allowed_hosts=None,
|
||||
openapi_theme=DEFAULT_OPENAPI_THEME,
|
||||
lifespan=None,
|
||||
gzip=True,
|
||||
request_id=False,
|
||||
enable_logging=False,
|
||||
):
|
||||
@@ -88,7 +86,6 @@ class API:
|
||||
:param allowed_hosts: List of allowed hostnames (e.g. ``["example.com"]``). Defaults to ``["*"]``.
|
||||
:param openapi_theme: Documentation UI theme: ``"swagger_ui"``, ``"redoc"``, ``"rapidoc"``, or ``"elements"``.
|
||||
:param lifespan: An async context manager for startup/shutdown logic.
|
||||
:param gzip: If ``True`` (the default), compress responses with GZip.
|
||||
:param request_id: If ``True``, add ``X-Request-ID`` headers to all responses.
|
||||
:param enable_logging: If ``True``, enable structured logging with per-request context (request ID, method, path, client IP).
|
||||
""" # noqa: E501
|
||||
@@ -125,9 +122,7 @@ class API:
|
||||
|
||||
self.default_endpoint = None
|
||||
self.app = ExceptionMiddleware(self.router, debug=debug)
|
||||
|
||||
if gzip:
|
||||
self.add_middleware(GZipMiddleware)
|
||||
self.add_middleware(GZipMiddleware)
|
||||
|
||||
if self.hsts_enabled:
|
||||
self.add_middleware(HTTPSRedirectMiddleware)
|
||||
|
||||
@@ -101,7 +101,8 @@ class GraphQLView:
|
||||
response_data["data"] = result.data
|
||||
|
||||
resp.media = response_data
|
||||
resp.status_code = 200 if not result.errors else 400
|
||||
status_code = 200 if not result.errors else 400
|
||||
return (query, json.dumps(response_data), status_code)
|
||||
|
||||
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 | tojson }} });
|
||||
const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
|
||||
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:
|
||||
if name in self.schemas or name in self.pydantic_schemas:
|
||||
raise ValueError(f"Schema '{name}' is already registered")
|
||||
assert name not in self.schemas
|
||||
assert name not in self.pydantic_schemas
|
||||
|
||||
if _is_pydantic_model(schema):
|
||||
self.pydantic_schemas[name] = schema
|
||||
@@ -216,13 +216,12 @@ class OpenAPISchema:
|
||||
f"{self.docs_theme}.html",
|
||||
title=self.title,
|
||||
version=self.version,
|
||||
schema_url=self.openapi_route,
|
||||
schema_url="/schema.yml",
|
||||
)
|
||||
|
||||
def static_url(self, asset):
|
||||
"""Given a static asset, return its URL path."""
|
||||
if self.static_route is None:
|
||||
raise RuntimeError("Cannot generate static URL: static_route is disabled")
|
||||
assert self.static_route is not None
|
||||
return f"{self.static_route}/{str(asset)}"
|
||||
|
||||
def docs_response(self, req, resp):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Simple in-memory rate limiter for Responder."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -29,7 +28,6 @@ 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
|
||||
@@ -41,25 +39,20 @@ 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)
|
||||
|
||||
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])
|
||||
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])
|
||||
resp.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
||||
resp.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
return True
|
||||
|
||||
+4
-15
@@ -34,21 +34,12 @@ 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():
|
||||
@@ -308,8 +299,8 @@ class Request:
|
||||
"""
|
||||
|
||||
if format is None:
|
||||
format = "yaml" if "yaml" in self.mimetype else "json" # noqa: A001
|
||||
format = "form" if "form" in self.mimetype else format # noqa: A001
|
||||
format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001
|
||||
format = "form" if "form" in self.mimetype or "" else format # noqa: A001
|
||||
|
||||
formatter: Callable
|
||||
if isinstance(format, str):
|
||||
@@ -473,11 +464,9 @@ class Response:
|
||||
self.mimetype = guessed or "application/octet-stream"
|
||||
|
||||
async def file_generator():
|
||||
import anyio
|
||||
|
||||
async with await anyio.open_file(path, "rb") as f:
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(chunk_size)
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
+10
-21
@@ -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", {"http": [], "ws": []})
|
||||
before_requests = scope.get("before_requests", [])
|
||||
|
||||
for before_request in before_requests.get("http", []):
|
||||
if inspect.iscoroutinefunction(before_request):
|
||||
@@ -144,11 +144,9 @@ 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", "delete"):
|
||||
if req_model is not None and request.method in ("post", "put", "patch"):
|
||||
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
|
||||
@@ -190,7 +188,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 isinstance(response.media, dict):
|
||||
if resp_model is not None and response.media is not None:
|
||||
try:
|
||||
validated = resp_model(**response.media)
|
||||
response.media = validated.model_dump()
|
||||
@@ -268,7 +266,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", {"http": [], "ws": []})
|
||||
before_requests = scope.get("before_requests", [])
|
||||
for before_request in before_requests.get("ws", []):
|
||||
await before_request(ws)
|
||||
|
||||
@@ -461,22 +459,13 @@ 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
|
||||
|
||||
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:
|
||||
app = WSGIMiddleware(app)
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ for number in codes:
|
||||
|
||||
|
||||
def _is_category(category, status_code):
|
||||
return category <= status_code < category + 100
|
||||
return all([(status_code >= 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") -> t.Any:
|
||||
def load_target(target: str, default_property: str = "api", method: str = "run") -> t.Any:
|
||||
"""
|
||||
Load Python code from a file path or module name.
|
||||
|
||||
@@ -24,6 +24,7 @@ def load_target(target: str, default_property: str = "api") -> t.Any:
|
||||
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.
|
||||
@@ -31,7 +32,7 @@ def load_target(target: str, default_property: str = "api") -> t.Any:
|
||||
Raises:
|
||||
ValueError: If target format is invalid
|
||||
ImportError: If module cannot be imported
|
||||
AttributeError: If property is not found
|
||||
AttributeError: If property or method 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 "ValueError" in captured.out
|
||||
assert "ValueError" in captured.err or True # traceback goes to stderr
|
||||
|
||||
|
||||
def test_background_run():
|
||||
@@ -112,8 +112,7 @@ def test_form_uploads_without_multipart(api):
|
||||
content="name=hello&value=world",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"name": ["hello"], "value": ["world"]}
|
||||
assert r.json() == {"name": "world", "value": "world"} or r.status_code < 500
|
||||
|
||||
|
||||
# --- models.py coverage ---
|
||||
@@ -470,7 +469,7 @@ def test_graphql_text_query(api):
|
||||
content="{ hello }",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.status_code < 500
|
||||
|
||||
|
||||
def test_openapi_info_fields():
|
||||
|
||||
Reference in New Issue
Block a user