Skip to content

Commit d54f5d7

Browse files
authored
Merge pull request #92 from eadwinCode/interceptors
WIP: Ellar Interceptors
2 parents ceb3bcf + 0b92672 commit d54f5d7

File tree

78 files changed

+1157
-513
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

78 files changed

+1157
-513
lines changed

docs/overview/interceptors.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# **Interceptors - [(AOP) technique](https://en.wikipedia.org/wiki/Aspect-oriented_programming){target="_blank"}**
2+
3+
An interceptor is a class annotated with the `@injectable()` decorator and implements the EllarInterceptor interface.
4+
During request-response cycle, interceptors are called after middleware execution before route handler execution.
5+
6+
Interceptors have a set of useful capabilities which are inspired by the [Aspect Oriented Programming (AOP) technique](https://en.wikipedia.org/wiki/Aspect-oriented_programming){target="_blank"} technique.
7+
They make it possible to:
8+
9+
- bind extra logic before / after method execution
10+
- transform the result returned from a function
11+
- transform the exception thrown from a function
12+
- extend the basic function behavior
13+
- completely override a function depending on specific conditions (e.g., for caching purposes)
14+
15+
## **Basic**
16+
Each interceptor implements the `intercept()` method, which takes two arguments.
17+
The first one is the `ExecutionContext` instance (exactly the same object as for [guards](./guards)) and
18+
`next_interceptor` awaitable function that executes the next interceptor in the execution chain.
19+
20+
```python
21+
import typing as t
22+
from abc import ABC, abstractmethod
23+
from ellar.common import IExecutionContext
24+
25+
26+
class EllarInterceptor(ABC):
27+
@abstractmethod
28+
async def intercept(
29+
self, context: IExecutionContext, next_interceptor: t.Callable[..., t.Coroutine]
30+
) -> t.Any:
31+
"""implementation comes here"""
32+
```
33+
!!! note
34+
`intercept` function of interceptor class is an asynchronous function.
35+
36+
## **Execution context**
37+
The `ExecutionContext` adds several new helper methods that provide additional details about the current execution process.
38+
These details can be helpful in building more generic interceptors that can work across a broad set of controllers, methods, and execution contexts.
39+
Learn more about `ExecutionContext` [here](../basics/execution-context).
40+
41+
## **Next Interceptor Handler**
42+
The second argument, `next_interceptor`, in `intercept` of EllarInterceptor class is used to invoke the route handler method at some point in your interceptor.
43+
If you don't call the `next_interceptor` method in your implementation of the `intercept()` method, the route handler method won't be executed at all.
44+
45+
This approach means that the `intercept()` method effectively wraps the request/response cycle.
46+
As a result, you may implement custom logic **both before and after** the execution of the final route handler.
47+
It's clear that you can write code in your `intercept()` method that executes before calling `next_interceptor()`,
48+
but how do you affect what happens afterward? depending on the nature of the data returned by `next_interceptor()`,
49+
further manipulation can be done before final response to the client.
50+
51+
Using Aspect Oriented Programming terminology, the invocation of the route handler
52+
(i.e., calling `next_interceptor()`) is called a Pointcut, indicating that it's the point at which our
53+
additional logic is inserted.
54+
55+
Consider, for example, an incoming `POST /car` request. This request is destined for the `create()` handler
56+
defined inside the `CarController`. If an interceptor which does not call the `next_interceptor()`
57+
method is called anywhere along the way, the `create()` method won't be executed.
58+
Once `next_interceptor()` is called, the `create()` handler will be triggered. And once the response is returned,
59+
additional operations can be performed on the data returned, and a final result returned to the client.
60+
61+
62+
## **Aspect interception**
63+
The first use case we'll look at is to use an interceptor to log user interaction (e.g., storing user calls, asynchronously dispatching events or calculating a timestamp).
64+
We show a simple LoggingInterceptor below:
65+
66+
```python
67+
import typing as t
68+
import logging
69+
import time
70+
from ellar.common import EllarInterceptor, IExecutionContext
71+
from ellar.di import injectable
72+
73+
74+
logger = logging.getLogger('ellar')
75+
76+
77+
@injectable()
78+
class LoggingInterceptor(EllarInterceptor):
79+
async def intercept(
80+
self, context: IExecutionContext, next_interceptor: t.Callable[..., t.Coroutine]
81+
) -> t.Any:
82+
logger.info('Before Route Handler Execution...')
83+
start_time = time.time()
84+
85+
res = await next_interceptor()
86+
logger.info(f'After Route Handler Execution.... {time.time() - start_time}s')
87+
return res
88+
```
89+
90+
!!! hint
91+
Interceptors, like controllers, providers, guards, and so on, can inject dependencies through their `constructor`.
92+
93+
## **Binding interceptors**
94+
In order to set up the interceptor, we use the `@UseInterceptors()` decorator imported from the `ellar.common` package.
95+
Like **guards**, interceptors can be controller-scoped, method-scoped, or global-scoped.
96+
97+
```python
98+
from ellar.common import UseInterceptors, Controller
99+
100+
@UseInterceptors(LoggingInterceptor)
101+
@Controller()
102+
class CarController:
103+
...
104+
```
105+
106+
Note that we passed the LoggingInterceptor type (instead of an instance), leaving responsibility for instantiation to the framework and enabling dependency injection.
107+
As with guards, we can also pass an in-place instance:
108+
109+
```python
110+
from ellar.common import UseInterceptors, Controller
111+
112+
@UseInterceptors(LoggingInterceptor())
113+
@Controller()
114+
class CarController:
115+
...
116+
```
117+
118+
As mentioned, the construction above attaches the interceptor to every handler declared by this controller.
119+
If we want to restrict the interceptor's scope to a single method, we simply apply the decorator at the method level.
120+
121+
In order to set up a global interceptor, we use the use_global_interceptors() method of the Ellar application instance:
122+
123+
```python
124+
from ellar.core import AppFactory
125+
126+
app = AppFactory.create_from_app_module(ApplicationModule)
127+
app.use_global_interceptors(LoggingInterceptor())
128+
# OR
129+
# app.use_global_interceptors(LoggingInterceptor)
130+
```
131+
132+
## **Exception Handling**
133+
You can also handle exception through on the process of request/response cycle before it gets handled by system exception handlers.
134+
135+
```python
136+
class CustomException(Exception):
137+
pass
138+
139+
140+
@injectable
141+
class InterceptCustomException(EllarInterceptor):
142+
async def intercept(
143+
self, context: IExecutionContext, next_interceptor: t.Callable[..., t.Coroutine]
144+
) -> t.Any:
145+
try:
146+
return await next_interceptor()
147+
except CustomException as cex:
148+
res = context.switch_to_http_connection().get_response()
149+
res.status_code = 400
150+
return {"message": str(cex)}
151+
```

ellar/cache/decorator.py

Lines changed: 56 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,64 @@
1+
import dataclasses
12
import typing as t
2-
import uuid
3-
from functools import wraps
43

54
from ellar.cache.interface import ICacheService
6-
from ellar.common import Context, IExecutionContext, Provide, extra_args
7-
from ellar.common.helper import is_async_callable
8-
from ellar.common.params import ExtraEndpointArg
5+
from ellar.common import EllarInterceptor, IExecutionContext, set_metadata
6+
from ellar.common.constants import ROUTE_CACHE_OPTIONS, ROUTE_INTERCEPTORS
7+
from ellar.core import Reflector
8+
from ellar.di import injectable
99

1010

11-
class _CacheDecorator:
11+
@dataclasses.dataclass
12+
class RouteCacheOptions:
13+
ttl: t.Union[int, float]
14+
key_prefix: str
15+
make_key_callback: t.Callable[[IExecutionContext, str], str]
16+
version: t.Optional[str] = None
17+
backend: str = "default"
18+
19+
20+
def route_cache_make_key(context: IExecutionContext, key_prefix: str) -> str:
21+
"""Defaults key generator for caching view"""
22+
connection = context.switch_to_http_connection()
23+
return f"{connection.get_client().url}:{key_prefix or 'view'}"
24+
25+
26+
@injectable
27+
class CacheEllarInterceptor(EllarInterceptor):
1228
__slots__ = (
13-
"_is_async",
14-
"_key_prefix",
15-
"_version",
16-
"_backend",
17-
"_func",
18-
"_ttl",
19-
"_cache_service_arg",
20-
"_context_arg",
21-
"_make_key_callback",
29+
"_cache_service",
30+
"_reflector",
2231
)
2332

24-
def __init__(
25-
self,
26-
func: t.Callable,
27-
ttl: t.Union[int, float],
28-
*,
29-
key_prefix: str = "",
30-
version: str = None,
31-
backend: str = "default",
32-
make_key_callback: t.Callable[[IExecutionContext, str], str] = None,
33-
) -> None:
34-
self._is_async = is_async_callable(func)
35-
self._key_prefix = key_prefix
36-
self._version = version
37-
self._backend = backend
38-
self._func = func
39-
self._ttl = ttl
40-
41-
# create extra args
42-
self._cache_service_arg = ExtraEndpointArg(
43-
name=f"cache_service_{uuid.uuid4().hex[:4]}",
44-
annotation=ICacheService, # type:ignore[misc]
45-
default_value=Provide(),
33+
def __init__(self, cache_service: ICacheService, reflector: "Reflector") -> None:
34+
self._cache_service = cache_service
35+
self._reflector = reflector
36+
37+
async def intercept(
38+
self, context: IExecutionContext, next_interceptor: t.Callable[..., t.Coroutine]
39+
) -> t.Any:
40+
opts: RouteCacheOptions = self._reflector.get(
41+
ROUTE_CACHE_OPTIONS, context.get_handler()
4642
)
47-
self._context_arg = ExtraEndpointArg(
48-
name=f"route_context_{uuid.uuid4().hex[:4]}",
49-
annotation=IExecutionContext, # type:ignore[misc]
50-
default_value=Context(),
43+
44+
backend = self._cache_service.get_backend(backend=opts.backend)
45+
key = opts.make_key_callback(context, opts.key_prefix or backend.key_prefix)
46+
47+
cached_value = await self._cache_service.get_async(
48+
key, opts.version, backend=opts.backend
5149
)
52-
# apply extra_args to endpoint
53-
extra_args(self._cache_service_arg, self._context_arg)(func)
54-
self._make_key_callback: t.Callable[[IExecutionContext, str], str] = (
55-
make_key_callback or self.route_cache_make_key
50+
if cached_value:
51+
return cached_value
52+
53+
response = await next_interceptor()
54+
await self._cache_service.set_async(
55+
key,
56+
response,
57+
ttl=opts.ttl,
58+
version=opts.version,
59+
backend=opts.backend,
5660
)
57-
58-
def get_decorator_wrapper(self) -> t.Callable:
59-
if self._is_async:
60-
return self.get_async_cache_wrapper()
61-
return self.get_cache_wrapper()
62-
63-
def route_cache_make_key(self, context: IExecutionContext, key_prefix: str) -> str:
64-
"""Defaults key generator for caching view"""
65-
connection = context.switch_to_http_connection()
66-
return f"{connection.get_client().url}:{key_prefix or 'view'}"
67-
68-
def get_async_cache_wrapper(self) -> t.Callable:
69-
"""Gets endpoint asynchronous wrapper function"""
70-
71-
@wraps(self._func)
72-
async def _async_wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any:
73-
cache_service: ICacheService = self._cache_service_arg.resolve(kwargs)
74-
context: IExecutionContext = self._context_arg.resolve(kwargs)
75-
76-
backend = cache_service.get_backend(backend=self._backend)
77-
key = self._make_key_callback(
78-
context, self._key_prefix or backend.key_prefix
79-
)
80-
81-
cached_value = await cache_service.get_async(
82-
key, self._version, backend=self._backend
83-
)
84-
if cached_value:
85-
return cached_value
86-
87-
response = await self._func(*args, **kwargs)
88-
await cache_service.set_async(
89-
key,
90-
response,
91-
ttl=self._ttl,
92-
version=self._version,
93-
backend=self._backend,
94-
)
95-
return response
96-
97-
return _async_wrapper
98-
99-
def get_cache_wrapper(self) -> t.Callable:
100-
"""Gets endpoint synchronous wrapper function"""
101-
102-
@wraps(self._func)
103-
def _wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any:
104-
cache_service: ICacheService = self._cache_service_arg.resolve(kwargs)
105-
context: IExecutionContext = self._context_arg.resolve(kwargs)
106-
107-
backend = cache_service.get_backend(backend=self._backend)
108-
key = self._make_key_callback(
109-
context, self._key_prefix or backend.key_prefix
110-
)
111-
112-
cached_value = cache_service.get(key, self._version, backend=self._backend)
113-
if cached_value:
114-
return cached_value
115-
116-
response = self._func(*args, **kwargs)
117-
cache_service.set(
118-
key,
119-
response,
120-
ttl=self._ttl,
121-
version=self._version,
122-
backend=self._backend,
123-
)
124-
return response
125-
126-
return _wrapper
61+
return response
12762

12863

12964
def cache(
@@ -145,14 +80,14 @@ def cache(
14580
"""
14681

14782
def _wraps(func: t.Callable) -> t.Callable:
148-
cache_decorator = _CacheDecorator(
149-
func,
150-
ttl,
83+
options = RouteCacheOptions(
84+
ttl=ttl,
15185
key_prefix=key_prefix,
15286
version=version,
153-
backend=backend,
154-
make_key_callback=make_key_callback,
87+
backend=backend or "default",
88+
make_key_callback=make_key_callback or route_cache_make_key,
15589
)
156-
return cache_decorator.get_decorator_wrapper()
90+
func = set_metadata(ROUTE_CACHE_OPTIONS, options)(func)
91+
return set_metadata(ROUTE_INTERCEPTORS, [CacheEllarInterceptor])(func) # type: ignore[no-any-return]
15792

15893
return _wraps

ellar/cache/service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def has_key(self, key: str, version: str = None, backend: str = None) -> bool:
6060
return _backend.has_key(key, version=version)
6161

6262

63-
@injectable # type: ignore
63+
@injectable
6464
class CacheService(_CacheServiceSync, ICacheService):
6565
"""
6666
A Cache Backend Service that wraps Ellar cache backends

0 commit comments

Comments
 (0)