Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ of requirements for an API to count as public.

### Features

- Added `throttling_allow_unsafe_cache` setting to control whether unsafe
cache backends (`LocMemCache`, `DummyCache`) are allowed for throttling.
Emits `UnsafeCacheBackendWarning` by default,
raises `ImproperlyConfigured` when explicitly set to `False`, #978
- Added `--no-ensure-ascii` flag to `dmr_export_schema` management command

### Bugfixes
Expand Down
6 changes: 6 additions & 0 deletions django_test_app/server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

# DMR settings for the test application:
DMR_SETTINGS = {
# Allow unsafe cache backends (LocMemCache) for testing:
'throttling_allow_unsafe_cache': None,
}

# Content Security Policy:
# https://django-csp.readthedocs.io/en/latest/configuration.html

Expand Down
5 changes: 4 additions & 1 deletion dmr/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from dmr.serializer import BaseSerializer
from dmr.settings import HttpSpec
from dmr.throttling import AsyncThrottle, SyncThrottle
from dmr.types import AnnotationsContext, infer_type_args
from dmr.types import AnnotationsContext, Empty, EmptyObj, infer_type_args
from dmr.validation import ControllerValidator, SettingsValidator

if TYPE_CHECKING:
Expand Down Expand Up @@ -110,6 +110,8 @@ class Controller(Generic[_SerializerT_co], View): # noqa: WPS214
Async controllers must use instances
of :class:`dmr.throttling.AsyncThrottle`.
Set it to ``None`` to disable throttling of this controller.
throttling_allow_unsafe_cache: Should this controller allow
unsafe throttle Django cache backends?
error_model: Schema type that represents
and validates common error responses.
is_abstract: Whether or not this controller is abstract.
Expand Down Expand Up @@ -160,6 +162,7 @@ class Controller(Generic[_SerializerT_co], View): # noqa: WPS214
throttling: ClassVar[
Sequence[SyncThrottle] | Sequence[AsyncThrottle] | None
] = ()
throttling_allow_unsafe_cache: ClassVar[bool | Empty | None] = EmptyObj
error_model: ClassVar[Any] = ErrorModel
is_abstract: ClassVar[bool] = True
is_async: ClassVar[bool | None] = None # `None` means that nothing's found
Expand Down
15 changes: 15 additions & 0 deletions dmr/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from dmr.serializer import BaseSerializer
from dmr.settings import HttpSpec, Settings, resolve_setting
from dmr.throttling import AsyncThrottle, SyncThrottle
from dmr.types import Empty, EmptyObj
from dmr.validation import (
EndpointMetadataBuilder,
EndpointMetadataValidator,
Expand Down Expand Up @@ -607,6 +608,7 @@ def validate( # noqa: WPS234
validate_negotiation: bool | None = None,
auth: Sequence[AsyncAuth] | Sequence[SyncAuth] | None = (),
throttling: Sequence[AsyncThrottle] | Sequence[SyncThrottle] | None = (),
throttling_allow_unsafe_cache: bool | Empty | None = EmptyObj,
summary: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
Expand Down Expand Up @@ -637,6 +639,7 @@ def validate(
validate_negotiation: bool | None = None,
auth: Sequence[AsyncAuth] | Sequence[SyncAuth] | None = (),
throttling: Sequence[AsyncThrottle] | Sequence[SyncThrottle] | None = (),
throttling_allow_unsafe_cache: bool | Empty | None = EmptyObj,
summary: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
Expand Down Expand Up @@ -667,6 +670,7 @@ def validate(
validate_negotiation: bool | None = None,
auth: Sequence[AsyncAuth] | Sequence[SyncAuth] | None = (),
throttling: Sequence[AsyncThrottle] | Sequence[SyncThrottle] | None = (),
throttling_allow_unsafe_cache: bool | Empty | None = EmptyObj,
summary: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
Expand Down Expand Up @@ -696,6 +700,7 @@ def validate( # noqa: WPS211 # pyright: ignore[reportInconsistentOverload]
validate_negotiation: bool | None = None,
auth: Sequence[AsyncAuth] | Sequence[SyncAuth] | None = (),
throttling: Sequence[AsyncThrottle] | Sequence[SyncThrottle] | None = (),
throttling_allow_unsafe_cache: bool | Empty | None = EmptyObj,
summary: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
Expand Down Expand Up @@ -786,6 +791,8 @@ def validate( # noqa: WPS211 # pyright: ignore[reportInconsistentOverload]
Async endpoints must use instances
of :class:`dmr.throttling.AsyncThrottle`.
Set it to ``None`` to disable throttling of this endpoint.
throttling_allow_unsafe_cache: Should this controller allow
unsafe throttle Django cache backends?
summary: A short summary of what the operation does.
description: A verbose explanation of the operation behavior.
tags: A list of tags for API documentation control.
Expand Down Expand Up @@ -822,6 +829,7 @@ def validate( # noqa: WPS211 # pyright: ignore[reportInconsistentOverload]
validate_negotiation=validate_negotiation,
auth=auth,
throttling=throttling,
throttling_allow_unsafe_cache=throttling_allow_unsafe_cache,
summary=summary,
description=description,
tags=tags,
Expand Down Expand Up @@ -853,6 +861,7 @@ def modify(
validate_negotiation: bool | None = None,
auth: Sequence[AsyncAuth] | Sequence[SyncAuth] | None = (),
throttling: Sequence[AsyncThrottle] | Sequence[SyncThrottle] | None = (),
throttling_allow_unsafe_cache: bool | Empty | None = EmptyObj,
summary: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
Expand Down Expand Up @@ -883,6 +892,7 @@ def modify(
validate_negotiation: bool | None = None,
auth: Sequence[AsyncAuth] | Sequence[SyncAuth] | None = (),
throttling: Sequence[AsyncThrottle] | Sequence[SyncThrottle] | None = (),
throttling_allow_unsafe_cache: bool | Empty | None = EmptyObj,
summary: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
Expand Down Expand Up @@ -914,6 +924,7 @@ def modify(
validate_negotiation: bool | None = None,
auth: Sequence[AsyncAuth] | Sequence[SyncAuth] | None = (),
throttling: Sequence[AsyncThrottle] | Sequence[SyncThrottle] | None = (),
throttling_allow_unsafe_cache: bool | Empty | None = EmptyObj,
summary: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
Expand Down Expand Up @@ -944,6 +955,7 @@ def modify( # noqa: WPS211
validate_negotiation: bool | None = None,
auth: Sequence[AsyncAuth] | Sequence[SyncAuth] | None = (),
throttling: Sequence[AsyncThrottle] | Sequence[SyncThrottle] | None = (),
throttling_allow_unsafe_cache: bool | Empty | None = EmptyObj,
summary: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
Expand Down Expand Up @@ -1018,6 +1030,8 @@ def modify( # noqa: WPS211
Async endpoints must use instances
of :class:`dmr.throttling.AsyncThrottle`.
Set it to ``None`` to disable throttling of this endpoint.
throttling_allow_unsafe_cache: Should this endpoint allow
unsafe throttle Django cache backends?
summary: A short summary of what the operation does.
description: A verbose explanation of the operation behavior.
tags: A list of tags for API documentation control.
Expand Down Expand Up @@ -1060,6 +1074,7 @@ def modify( # noqa: WPS211
validate_negotiation=validate_negotiation,
auth=auth,
throttling=throttling,
throttling_allow_unsafe_cache=throttling_allow_unsafe_cache,
summary=summary,
description=description,
tags=tags,
Expand Down
7 changes: 7 additions & 0 deletions dmr/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,12 @@ class EndpointMetadata:
Async endpoints must use instances
of :class:`dmr.throttling.AsyncThrottle`.
Set it to ``None`` to disable throttling of this endpoint.
throttling_before_auth: Sequence of throttle instances
to be used before auth checks.
throttling_after_auth: Sequence of throttle instances
to be used after auth checks.
throttling_allow_unsafe_cache: Should this endpoint allow
unsafe throttle Django cache backends?
no_validate_http_spec: Set of checks that user wants
to disable for validation in this endpoint.
allowed_http_methods: Set of extra HTTP methods
Expand Down Expand Up @@ -442,6 +448,7 @@ class EndpointMetadata:
throttling_before_auth: tuple['SyncThrottle | AsyncThrottle', ...] | None
# Second line of throttling:
throttling_after_auth: tuple['SyncThrottle | AsyncThrottle', ...] | None
throttling_allow_unsafe_cache: bool | None

no_validate_http_spec: frozenset['HttpSpec']
allowed_http_methods: frozenset[str]
Expand Down
2 changes: 1 addition & 1 deletion dmr/openapi/generators/component_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def _parse_pattern(
# `re_path()` and `RegexPattern`:
regex = pattern.pattern.regex
schema = dict.fromkeys(
regex.groupindex, # pyrefly: ignore[missing-attribute]
regex.groupindex,
str,
)
if schema:
Expand Down
11 changes: 7 additions & 4 deletions dmr/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class Settings(enum.StrEnum):
validate_negotiation = 'validate_negotiation'
auth = 'auth'
throttling = 'throttling'
throttling_allow_unsafe_cache = 'throttling_allow_unsafe_cache'
no_validate_http_spec = 'no_validate_http_spec'
validate_responses = 'validate_responses'
semantic_responses = 'semantic_responses'
Expand All @@ -67,8 +68,8 @@ class Settings(enum.StrEnum):
global_error_handler = 'global_error_handler'
openapi_config = 'openapi_config'
openapi_examples_seed = 'openapi_examples_seed'
django_treat_as_post = 'django_treat_as_post'
openapi_static_cdn = 'openapi_static_cdn'
django_treat_as_post = 'django_treat_as_post'


@final
Expand Down Expand Up @@ -108,6 +109,7 @@ class SettingsDict(TypedDict, total=False):
validate_negotiation: bool | None
auth: Sequence['AsyncAuth | SyncAuth']
throttling: Sequence['AsyncThrottle | SyncThrottle']
throttling_allow_unsafe_cache: bool | None
no_validate_http_spec: Set[HttpSpec]
validate_responses: bool
semantic_responses: bool
Expand All @@ -117,8 +119,8 @@ class SettingsDict(TypedDict, total=False):
global_error_handler: Callable[[Any, Any, Any], Any] | str
openapi_config: 'OpenAPIConfig'
openapi_examples_seed: int | None
django_treat_as_post: Set[str]
openapi_static_cdn: dict[str, str]
django_treat_as_post: Set[str]


assert SettingsDict.__optional_keys__ == set(Settings), ( # noqa: S101
Expand All @@ -134,12 +136,15 @@ class SettingsDict(TypedDict, total=False):
Settings.validate_negotiation: None,
Settings.auth: [],
Settings.throttling: [],
Settings.throttling_allow_unsafe_cache: True,
# OpenAPI settings:
Settings.openapi_config: OpenAPIConfig(
title='Django Modern Rest',
version='0.1.0',
),
Settings.openapi_examples_seed: None, # turned off by default
# OpenAPI static CDN configuration:
Settings.openapi_static_cdn: {},
# We validate some HTTP spec things by default to be strict,
# can be disabled:
Settings.no_validate_http_spec: frozenset(),
Expand All @@ -153,8 +158,6 @@ class SettingsDict(TypedDict, total=False):
Settings.global_error_handler: 'dmr.errors.global_error_handler',
# Settings for middleware:
Settings.django_treat_as_post: frozenset(('PUT', 'PATCH')),
# OpenAPI static CDN configuration:
Settings.openapi_static_cdn: {},
}

assert all(setting_key in _DEFAULTS for setting_key in Settings), ( # noqa: S101
Expand Down
12 changes: 10 additions & 2 deletions dmr/throttling/backends/django_cache.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import dataclasses
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, final

from django.core.cache import DEFAULT_CACHE_ALIAS, BaseCache, caches
from typing_extensions import override

from dmr.settings import default_parser, default_renderer
from dmr.settings import (
default_parser,
default_renderer,
)
from dmr.throttling.backends.base import (
BaseThrottleAsyncBackend,
BaseThrottleSyncBackend,
Expand All @@ -19,6 +22,11 @@
from dmr.throttling.algorithms import BaseThrottleAlgorithm


@final
class UnsafeCacheBackendWarning(UserWarning):
"""Warning emitted when an unsafe cache backend is used for throttling."""


@dataclasses.dataclass(slots=True, frozen=True)
class _DjangoCache:
cache_name: str = DEFAULT_CACHE_ALIAS
Expand Down
14 changes: 8 additions & 6 deletions dmr/throttling/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,14 @@ def __init__(
# Default implementations of the logical parts:
self.cache_key = cache_key or RemoteAddr()

default_backend = (
SyncDjangoCache()
if isinstance(self, SyncThrottle)
else AsyncDjangoCache()
)
self._backend = backend or default_backend # type: ignore[assignment]
if backend is None:
self._backend = (
SyncDjangoCache() # type: ignore[assignment]
if isinstance(self, SyncThrottle)
else AsyncDjangoCache()
)
else:
self._backend = backend
self._algorithm = algorithm or SimpleRate()
self._response_headers = (
[XRateLimit(), RetryAfter()]
Expand Down
Loading
Loading