Skip to content

Commit 5b97863

Browse files
committed
Enabled config validation at all things
1 parent 65d230e commit 5b97863

File tree

5 files changed

+183
-49
lines changed

5 files changed

+183
-49
lines changed

ellar/core/conf/app_settings_models.py

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,38 @@
1717
from ellar.pydantic import ENCODERS_BY_TYPE as encoders_by_type
1818
from ellar.pydantic import AllowTypeOfSource, field_validator
1919
from starlette.exceptions import HTTPException as StarletteHTTPException
20+
from starlette.middleware import Middleware
21+
from starlette.requests import HTTPConnection, Request
2022
from starlette.websockets import WebSocketClose
2123
from typing_extensions import Annotated
2224

2325
if t.TYPE_CHECKING: # pragma: no cover
2426
from ellar.app.main import App
2527

28+
TEMPLATE_TYPE = t.Dict[
29+
str, t.Union[t.Callable[..., t.Any], t.Dict[str, t.Callable[..., t.Any]]]
30+
]
31+
32+
33+
_TemplateValidator = AllowTypeOfSource(
34+
validator=lambda s, v: callable(v),
35+
error_message=lambda v: f"Expected a callable, got {type(v)}",
36+
)
37+
38+
_VersioningValidator = AllowTypeOfSource(
39+
error_message=lambda s, v: f"Expected BaseAPIVersioning, received: {type(v)}"
40+
)
41+
42+
_MiddlewareValidator = AllowTypeOfSource(
43+
validator=lambda s, v: isinstance(v, (s, Middleware)),
44+
error_message=lambda s,
45+
v: f"Expected EllarMiddleware or Starlette Middleware object, received: {type(v)}",
46+
)
47+
48+
_ExceptionHandlerValidator = AllowTypeOfSource(
49+
error_message=lambda s, v: f"Expected IExceptionHandler object, received: {type(v)}"
50+
)
51+
2652

2753
async def _not_found(
2854
scope: TScope, receive: TReceive, send: TSend
@@ -42,7 +68,7 @@ async def _not_found(
4268
await response(scope, receive, send)
4369

4470

45-
class ConfigValidationSchema(Serializer, ConfigDefaultTypesMixin):
71+
class ConfigSchema(Serializer, ConfigDefaultTypesMixin):
4672
_filter = SerializerFilter(
4773
exclude={
4874
"EXCEPTION_HANDLERS_DECORATOR",
@@ -52,7 +78,12 @@ class ConfigValidationSchema(Serializer, ConfigDefaultTypesMixin):
5278
}
5379
)
5480

55-
model_config = {"validate_assignment": True, "from_attributes": True}
81+
model_config = {
82+
"validate_assignment": True,
83+
"validate_default": True,
84+
"from_attributes": True,
85+
"extra": "allow",
86+
}
5687

5788
OVERRIDE_CORE_SERVICE: t.List[Annotated[ProviderConfig, AllowTypeOfSource()]] = []
5889

@@ -73,8 +104,21 @@ class ConfigValidationSchema(Serializer, ConfigDefaultTypesMixin):
73104
JINJA_TEMPLATES_OPTIONS: t.Dict[str, t.Any] = {}
74105
JINJA_LOADERS: t.List[JinjaLoaderType] = []
75106

107+
TEMPLATES_CONTEXT_PROCESSORS: t.List[
108+
Annotated[
109+
t.Callable[[t.Union[Request, HTTPConnection]], t.Dict[str, t.Any]],
110+
_TemplateValidator,
111+
]
112+
] = [ # type:ignore[assignment]
113+
"ellar.core.templating.context_processors:request_context",
114+
"ellar.core.templating.context_processors:user",
115+
"ellar.core.templating.context_processors:request_state",
116+
]
117+
76118
# Application route versioning scheme
77-
VERSIONING_SCHEME: t.Optional[IAPIVersioning] = None
119+
VERSIONING_SCHEME: t.Optional[Annotated[IAPIVersioning, _VersioningValidator]] = (
120+
None
121+
)
78122

79123
REDIRECT_SLASHES: bool = False
80124

@@ -96,9 +140,23 @@ class ConfigValidationSchema(Serializer, ConfigDefaultTypesMixin):
96140
ALLOWED_HOSTS: t.List[str] = ["*"]
97141
REDIRECT_HOST: bool = True
98142

99-
MIDDLEWARE: t.List[IEllarMiddleware] = []
100-
101-
EXCEPTION_HANDLERS: t.List[IExceptionHandler] = []
143+
MIDDLEWARE: t.List[Annotated[IEllarMiddleware, _MiddlewareValidator]] = [
144+
"ellar.core.middleware.trusted_host:trusted_host_middleware",
145+
"ellar.core.middleware.cors:cors_middleware",
146+
"ellar.core.middleware.errors:server_error_middleware",
147+
"ellar.core.middleware.versioning:versioning_middleware",
148+
"ellar.auth.middleware.session:session_middleware",
149+
"ellar.auth.middleware.auth:identity_middleware",
150+
"ellar.core.middleware.exceptions:exception_middleware",
151+
]
152+
# A dictionary mapping either integer status codes,
153+
# or exception class types onto callables which handle the exceptions.
154+
# Exception handler callables should be of the form
155+
# `handler(context:IExecutionContext, exc: Exception) -> response`
156+
# and may be either standard functions, or async functions.
157+
EXCEPTION_HANDLERS: t.List[
158+
Annotated[IExceptionHandler, _ExceptionHandlerValidator]
159+
] = ["ellar.core.exceptions:error_404_handler"]
102160

103161
# Default not found handler
104162
DEFAULT_NOT_FOUND_HANDLER: ASGIApp = _not_found
@@ -118,8 +176,8 @@ class ConfigValidationSchema(Serializer, ConfigDefaultTypesMixin):
118176
# logging Level
119177
LOG_LEVEL: t.Optional[log_levels] = log_levels.info
120178

121-
TEMPLATE_FILTERS: t.Dict[str, t.Callable[..., t.Any]] = {}
122-
TEMPLATE_GLOBAL_FILTERS: t.Dict[str, t.Callable[..., t.Any]] = {}
179+
TEMPLATE_FILTERS: TEMPLATE_TYPE = {}
180+
TEMPLATE_GLOBAL_FILTERS: TEMPLATE_TYPE = {}
123181

124182
LOGGING: t.Optional[t.Dict[str, t.Any]] = None
125183
CACHES: t.Dict[str, t.Any] = {}

ellar/core/conf/config.py

Lines changed: 73 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import typing as t
22

3-
from ellar.common.compatible.dict import AttributeDictAccessMixin
43
from ellar.common.constants import ELLAR_CONFIG_MODULE
54
from ellar.common.types import VT
6-
from ellar.core.conf.app_settings_models import ConfigValidationSchema
5+
from ellar.core.conf.app_settings_models import ConfigSchema
76
from ellar.core.conf.mixins import ConfigDefaultTypesMixin
87
from ellar.utils.importer import import_from_string
98
from starlette.config import environ
@@ -13,8 +12,13 @@ class ConfigRuntimeError(RuntimeError):
1312
pass
1413

1514

16-
class Config(AttributeDictAccessMixin, dict, ConfigDefaultTypesMixin):
17-
__slots__ = ("config_module",)
15+
class Config(ConfigDefaultTypesMixin):
16+
__slots__ = (
17+
"_config_module",
18+
"_schema",
19+
)
20+
21+
_initialized: bool = False
1822

1923
def __init__(
2024
self,
@@ -23,52 +27,90 @@ def __init__(
2327
**mapping: t.Any,
2428
):
2529
"""
26-
Creates a new instance of Configuration object with the given values.
30+
Creates a new instance of a Configuration object with the given values.
2731
"""
28-
super().__init__()
29-
self.config_module = config_module or environ.get(ELLAR_CONFIG_MODULE, None)
3032

31-
self.clear()
33+
self._config_module = config_module or environ.get(ELLAR_CONFIG_MODULE, None)
3234

33-
self._load_config_module(config_prefix or "")
35+
data = self._load_config_module(config_prefix or "")
36+
data.update(**mapping)
3437

35-
self.update(**mapping)
38+
self._schema = ConfigSchema.model_validate(data, from_attributes=True)
39+
self._initialized = True
3640

37-
validate_config = ConfigValidationSchema.model_validate(
38-
self, from_attributes=True
39-
)
40-
self.update(validate_config.serialize())
41+
@property
42+
def config_module(self) -> t.Optional[str]:
43+
return self._config_module
4144

42-
def _load_config_module(self, prefix: str) -> None:
45+
def _load_config_module(self, prefix: str) -> dict:
46+
data = {}
4347
_prefix = prefix.upper()
44-
if self.config_module:
48+
49+
if self._config_module:
4550
try:
46-
mod = import_from_string(self.config_module)
51+
mod = import_from_string(self._config_module)
4752
for setting in dir(mod):
4853
if setting.isupper() and setting.startswith(_prefix):
49-
self[setting.replace(_prefix, "")] = getattr(mod, setting)
54+
data[setting.replace(_prefix, "")] = getattr(mod, setting)
5055
except Exception as ex:
5156
raise ConfigRuntimeError(str(ex)) from ex
5257

53-
def set_defaults(self, **kwargs: t.Any) -> "Config":
54-
for k, v in kwargs.items():
55-
self.setdefault(k, v)
56-
return self
58+
return data
5759

5860
def __repr__(self) -> str: # pragma: no cover
59-
hidden_values = {key: "..." for key in self.keys()}
60-
return f"<Configuration {repr(hidden_values)}, settings_module: {self.config_module}>"
61-
62-
def __setattr__(self, key: t.Any, value: t.Any) -> None:
63-
if key in self.__slots__:
64-
super(Config, self).__setattr__(key, value)
65-
return
61+
hidden_values = {key: "..." for key in self._schema.serialize().keys()}
62+
return f"<Configuration {repr(hidden_values)}, settings_module: {self._config_module}>"
6663

67-
self[key] = value
64+
def __str__(self) -> str:
65+
return repr(self)
6866

6967
@property
7068
def config_values(self) -> t.ValuesView[VT]:
7169
"""
7270
Returns a copy of the dictionary of current settings.
7371
"""
74-
return super().values()
72+
return self._schema.serialize().values()
73+
74+
def __setattr__(self, key: t.Any, value: t.Any) -> None:
75+
if key in self.__slots__ + ("_initialized",):
76+
# Assign to __dict__ to avoid infinite __setattr__ loops.
77+
if self._initialized:
78+
raise ConfigRuntimeError(
79+
f"Invalid Operation: Can not change {key} after configuration object has been created"
80+
)
81+
82+
super().__setattr__(key, value)
83+
else:
84+
setattr(self._schema, key, value)
85+
86+
def __delattr__(self, key: t.Any) -> None:
87+
if key in self.__slots__ + ("_initialized",):
88+
# TODO: add test
89+
raise TypeError("can't delete config attributes.")
90+
delattr(self._schema, key)
91+
92+
def __getattr__(self, key: t.Any) -> t.Any:
93+
value = getattr(self._schema, key)
94+
if isinstance(value, (list, set, tuple, dict)):
95+
# return immutable value
96+
return type(value)(value)
97+
return value
98+
99+
def set_defaults(self, **kwargs: t.Any) -> "Config":
100+
for k, v in kwargs.items():
101+
orig_value = getattr(self._schema, k, None)
102+
if orig_value is None:
103+
setattr(self._schema, k, v)
104+
return self
105+
106+
def get(self, key: t.Any, _default: t.Optional[t.Any] = None) -> t.Optional[t.Any]:
107+
return getattr(self, key, _default)
108+
109+
def __contains__(self, item: t.Any) -> bool:
110+
return hasattr(self._schema, item)
111+
112+
def __getitem__(self, item: t.Any) -> t.Any:
113+
return getattr(self, item)
114+
115+
def __setitem__(self, key: t.Any, value: t.Any) -> None:
116+
return setattr(self, key, value)

ellar/core/conf/mixins.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from ellar.common.constants import LOG_LEVELS as log_levels
44
from ellar.common.interfaces import IAPIVersioning, IEllarMiddleware, IExceptionHandler
55
from ellar.common.templating import JinjaLoaderType
6+
from ellar.common.templating.validator import TemplateContextProcessorValidator
67
from ellar.di import ProviderConfig
78
from ellar.di.injector.tree_manager import ModuleTreeManager
9+
from starlette.requests import HTTPConnection, Request
810
from starlette.responses import JSONResponse
911
from starlette.types import ASGIApp
1012

@@ -37,6 +39,13 @@ class ConfigDefaultTypesMixin:
3739

3840
JINJA_LOADERS: t.List[t.Union[JinjaLoaderType, t.Any]]
3941

42+
TEMPLATES_CONTEXT_PROCESSORS: t.List[
43+
t.Union[
44+
TemplateContextProcessorValidator,
45+
t.Callable[[t.Union[Request, HTTPConnection]], t.Dict[str, t.Any]],
46+
]
47+
]
48+
4049
# Application route versioning scheme
4150
VERSIONING_SCHEME: t.Optional[IAPIVersioning]
4251

ellar/core/exceptions/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
1+
from ellar.common import IExecutionContext
2+
from ellar.common.exceptions import CallableExceptionHandler
3+
from starlette.exceptions import HTTPException
4+
from starlette.responses import Response
5+
16
from .service import ExceptionMiddlewareService
27

38
__all__ = [
49
"ExceptionMiddlewareService",
10+
"error_404_handler",
511
]
12+
13+
14+
async def _404_error_handler(ctx: IExecutionContext, exc: HTTPException) -> Response:
15+
json_response_class = ctx.get_app().config.DEFAULT_JSON_CLASS
16+
return json_response_class({"detail": exc.detail}, status_code=exc.status_code)
17+
18+
19+
error_404_handler = CallableExceptionHandler(
20+
exc_or_status_code=404, handler=_404_error_handler
21+
)

ellar/core/exceptions/service.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,30 @@
99
from ellar.common.interfaces import IExceptionHandler, IExceptionMiddlewareService
1010
from ellar.di import injectable
1111

12+
EXCEPTION_DEFAULT_EXCEPTION_HANDLERS: t.List[IExceptionHandler] = [
13+
HTTPExceptionHandler(),
14+
APIExceptionHandler(),
15+
WebSocketExceptionHandler(),
16+
RequestValidationErrorHandler(),
17+
]
18+
1219

1320
@injectable()
1421
class ExceptionMiddlewareService(IExceptionMiddlewareService):
15-
DEFAULTS: t.List[IExceptionHandler] = [
16-
HTTPExceptionHandler(),
17-
APIExceptionHandler(),
18-
WebSocketExceptionHandler(),
19-
RequestValidationErrorHandler(),
20-
]
21-
2222
def __init__(self) -> None:
2323
self._status_handlers: t.Dict[int, IExceptionHandler] = {}
2424
self._exception_handlers: t.Dict[t.Type[Exception], IExceptionHandler] = {}
2525
self._500_error_handler: t.Optional[IExceptionHandler] = None
2626

27-
def build_exception_handlers(self, *exception_handlers: IExceptionHandler) -> None:
28-
handlers = list(self.DEFAULTS) + list(exception_handlers)
29-
for key, value in handlers:
27+
def build_exception_handlers(
28+
self, *exception_handlers: IExceptionHandler
29+
) -> "ExceptionMiddlewareService":
30+
for key, value in exception_handlers:
3031
if key == 500:
3132
self._500_error_handler = value
3233
continue
3334
self._add_exception_handler(key, value)
35+
return self
3436

3537
def get_500_error_handler(
3638
self,
@@ -58,3 +60,10 @@ def lookup_status_code_exception_handler(
5860
self, status_code: int
5961
) -> t.Optional[IExceptionHandler]:
6062
return self._status_handlers.get(status_code)
63+
64+
def __repr__(self) -> str: # praga: no cover
65+
return (
66+
f"<{self.__class__.__name__} id={id(self)}"
67+
f"status-handlers={self._status_handlers} "
68+
f"exc-handlers={self._exception_handlers}>"
69+
)

0 commit comments

Comments
 (0)