fastapi-ext-pkg 0.4.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/PKG-INFO +2 -2
  2. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/PKG-INFO +2 -2
  3. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/SOURCES.txt +2 -1
  4. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/databases/redis.py +13 -2
  5. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/security/auth.py +9 -2
  6. fastapi_ext_pkg-0.5.0/fastapi_extras/security/auth_async.py +177 -0
  7. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/pyproject.toml +1 -1
  8. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/README.md +0 -0
  9. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/dependency_links.txt +0 -0
  10. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/requires.txt +0 -0
  11. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/top_level.txt +0 -0
  12. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/__init__.py +0 -0
  13. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/databases/__init__.py +0 -0
  14. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/middlewares/__init__.py +0 -0
  15. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/middlewares/remove_cloud_trace_headers.py +0 -0
  16. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/middlewares/remove_path.py +0 -0
  17. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/orm/__init__.py +0 -0
  18. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/orm/sqlalchemy.py +0 -0
  19. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/security/__init__.py +0 -0
  20. {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: fastapi-ext-pkg
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: A well-tailored utility set for FastAPI.
5
5
  Author-email: Dotz Developers <devs-dotz@dotz.com>
6
6
  Project-URL: Repository, https://github.com/DotzInc/fastapi-ext-pkg
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: fastapi-ext-pkg
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: A well-tailored utility set for FastAPI.
5
5
  Author-email: Dotz Developers <devs-dotz@dotz.com>
6
6
  Project-URL: Repository, https://github.com/DotzInc/fastapi-ext-pkg
@@ -14,4 +14,5 @@ fastapi_extras/middlewares/remove_path.py
14
14
  fastapi_extras/orm/__init__.py
15
15
  fastapi_extras/orm/sqlalchemy.py
16
16
  fastapi_extras/security/__init__.py
17
- fastapi_extras/security/auth.py
17
+ fastapi_extras/security/auth.py
18
+ fastapi_extras/security/auth_async.py
@@ -15,8 +15,19 @@ class Redis(redis.Redis):
15
15
 
16
16
 
17
17
  class RedisManager:
18
- def __init__(self, url: Union[RedisDsn, str]):
19
- self.pool = redis.ConnectionPool.from_url(str(url))
18
+ def __init__(
19
+ self,
20
+ url: Union[RedisDsn, str],
21
+ socket_timeout: float = 2.0,
22
+ socket_connect_timeout: float = 2.0,
23
+ health_check_interval: int = 30,
24
+ ):
25
+ self.pool = redis.ConnectionPool.from_url(
26
+ str(url),
27
+ socket_timeout=socket_timeout,
28
+ socket_connect_timeout=socket_connect_timeout,
29
+ health_check_interval=health_check_interval,
30
+ )
20
31
 
21
32
  async def __call__(self) -> AsyncGenerator[Redis, None]:
22
33
  cli = Redis(connection_pool=self.pool)
@@ -55,7 +55,12 @@ def remote_authorization(
55
55
  cache: Annotated[Optional[Cache], Depends(cache_gen)],
56
56
  ) -> Any:
57
57
  key = keygen(token, prefix="authorizer:")
58
- cached = await cache.get(key) if cache else None
58
+
59
+ cached = None
60
+ try:
61
+ cached = await cache.get(key)
62
+ except Exception as cache_error:
63
+ logger.warning("Cache get failed, proceeding without cache: %s", cache_error)
59
64
 
60
65
  if cached:
61
66
  cached = json.loads(cached)
@@ -85,9 +90,11 @@ def remote_authorization(
85
90
  authorized = response.status_code == status.HTTP_200_OK
86
91
  context = response.json()
87
92
 
88
- if cache:
93
+ try:
89
94
  value = json.dumps({"authorized": authorized, "context": context})
90
95
  await cache.set(key, value, cache_ttl)
96
+ except Exception as cache_error:
97
+ logger.warning("Cache set failed, proceeding without cache: %s", cache_error)
91
98
 
92
99
  if not authorized:
93
100
  raise UNAUTHORIZED
@@ -0,0 +1,177 @@
1
+ import hashlib
2
+ import json
3
+ import logging
4
+ from typing import Any, AsyncGenerator, Callable, Optional, Protocol, Union
5
+
6
+ import httpx
7
+ from fastapi import Depends, HTTPException, Request, status
8
+ from fastapi.security import APIKeyCookie, APIKeyHeader, APIKeyQuery
9
+ from pydantic import AnyHttpUrl, RedisDsn
10
+ from redis.asyncio import ConnectionPool, Redis
11
+ from redis.asyncio.retry import Retry
12
+ from redis.backoff import ExponentialBackoff
13
+ from redis.exceptions import ConnectionError as RedisConnectionError
14
+ from redis.exceptions import RedisError
15
+ from typing_extensions import Annotated
16
+
17
+ logger = logging.getLogger(__name__)
18
+ ssl_context = httpx.create_ssl_context()
19
+
20
+ UNAUTHORIZED = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
21
+ DEFAULT_SCHEME = APIKeyHeader(name="Authorization")
22
+ DEFAULT_CACHE_TTL = 60 # seconds
23
+
24
+ # Redis client bounds: keep a stuck connection from a Redis outage from being
25
+ # reused forever instead of getting discarded and replaced.
26
+ DEFAULT_SOCKET_TIMEOUT = 2.0
27
+ DEFAULT_SOCKET_CONNECT_TIMEOUT = 2.0
28
+ DEFAULT_HEALTH_CHECK_INTERVAL = 30
29
+ DEFAULT_HTTP_TIMEOUT = 5.0
30
+ DEFAULT_HTTP_CONNECT_TIMEOUT = 2.0
31
+
32
+ APIKeyScheme = Union[APIKeyCookie, APIKeyHeader, APIKeyQuery]
33
+
34
+ CacheDependency = Callable[..., Union[Redis, AsyncGenerator[Redis, None]]]
35
+ HTTPClientDependency = Callable[..., httpx.AsyncClient]
36
+
37
+
38
+ def keygen(seed: str, prefix: str = "") -> str:
39
+ hash = hashlib.sha256(seed.encode()).hexdigest()
40
+ return f"{prefix}{hash}"
41
+
42
+
43
+ class AsyncRedisCache:
44
+ """FastAPI dependency yielding a redis.asyncio.Redis bound to a shared
45
+ pool with bounded timeouts + health checks, so stuck connections from a
46
+ Redis outage get discarded instead of reused forever."""
47
+
48
+ def __init__(
49
+ self,
50
+ url: Union[RedisDsn, str],
51
+ *,
52
+ socket_timeout: float = DEFAULT_SOCKET_TIMEOUT,
53
+ socket_connect_timeout: float = DEFAULT_SOCKET_CONNECT_TIMEOUT,
54
+ health_check_interval: int = DEFAULT_HEALTH_CHECK_INTERVAL,
55
+ ) -> None:
56
+ self.pool = ConnectionPool.from_url(
57
+ str(url),
58
+ decode_responses=True,
59
+ socket_timeout=socket_timeout,
60
+ socket_connect_timeout=socket_connect_timeout,
61
+ health_check_interval=health_check_interval,
62
+ retry_on_error=[RedisConnectionError],
63
+ retry=Retry(ExponentialBackoff(cap=1.0, base=0.05), retries=1),
64
+ )
65
+
66
+ async def __call__(self) -> AsyncGenerator[Redis, None]:
67
+ cli = Redis(connection_pool=self.pool)
68
+ try:
69
+ yield cli
70
+ finally:
71
+ await cli.aclose()
72
+
73
+
74
+ class Authorizer(Protocol):
75
+ """Structural type for what remote_authorization_async returns: a FastAPI
76
+ dependency callable that also owns a closeable httpx client."""
77
+
78
+ async def __call__(
79
+ self, request: Request, token: str, cache: Redis, http_client: httpx.AsyncClient
80
+ ) -> Any: ...
81
+ async def aclose(self) -> None: ...
82
+
83
+
84
+ def remote_authorization_async(
85
+ url: Union[str, AnyHttpUrl],
86
+ *,
87
+ scheme: APIKeyScheme = DEFAULT_SCHEME,
88
+ cache_gen: CacheDependency,
89
+ http_client_gen: Optional[HTTPClientDependency] = None,
90
+ cache_ttl: int = DEFAULT_CACHE_TTL,
91
+ http_timeout: float = DEFAULT_HTTP_TIMEOUT,
92
+ http_connect_timeout: float = DEFAULT_HTTP_CONNECT_TIMEOUT,
93
+ **kwargs: Any,
94
+ ) -> Authorizer:
95
+ if not isinstance(scheme, (APIKeyCookie, APIKeyHeader, APIKeyQuery)):
96
+ raise TypeError("Invalid APIKeyScheme")
97
+
98
+ request_timeout = httpx.Timeout(http_timeout, connect=http_connect_timeout)
99
+
100
+ # Only built when the caller has no httpx client of its own to inject.
101
+ _owned_client: Optional[httpx.AsyncClient] = None
102
+
103
+ def _get_owned_client() -> httpx.AsyncClient:
104
+ nonlocal _owned_client
105
+ if _owned_client is None:
106
+ _owned_client = httpx.AsyncClient(verify=ssl_context, timeout=request_timeout)
107
+ return _owned_client
108
+
109
+ async def _default_http_client_gen() -> httpx.AsyncClient:
110
+ return _get_owned_client()
111
+
112
+ async def aclose() -> None:
113
+ nonlocal _owned_client
114
+ if _owned_client is not None:
115
+ await _owned_client.aclose()
116
+ _owned_client = None
117
+
118
+ async def authorizer(
119
+ request: Request,
120
+ token: Annotated[str, Depends(scheme)],
121
+ cache: Annotated[Redis, Depends(cache_gen)],
122
+ http_client: Annotated[
123
+ httpx.AsyncClient, Depends(http_client_gen or _default_http_client_gen)
124
+ ],
125
+ ) -> Any:
126
+ key = keygen(token, prefix="authorizer:")
127
+
128
+ cached = None
129
+ try:
130
+ cached = await cache.get(key)
131
+ except RedisError as cache_error:
132
+ logger.warning("Cache get failed, proceeding without cache: %s", cache_error)
133
+
134
+ if cached:
135
+ cached = json.loads(cached)
136
+
137
+ if cached and isinstance(cached, dict):
138
+ if not cached.get("authorized"):
139
+ raise UNAUTHORIZED
140
+
141
+ request.scope["authorizer"] = cached.get("context")
142
+ return request.scope["authorizer"]
143
+
144
+ info = {
145
+ "method": request.method,
146
+ "path": request.url.path,
147
+ "query_params": dict(request.query_params),
148
+ "headers": dict(request.headers),
149
+ "cookies": request.cookies,
150
+ }
151
+
152
+ # Explicit per-call timeout so it applies even when http_client is an
153
+ # externally-shared client whose own default timeout differs.
154
+ request_kwargs = {**kwargs, "timeout": request_timeout}
155
+ try:
156
+ response = await http_client.post(str(url), json=info, **request_kwargs)
157
+ except httpx.HTTPError as http_error:
158
+ logger.error(http_error)
159
+ raise UNAUTHORIZED
160
+ else:
161
+ authorized = response.status_code == status.HTTP_200_OK
162
+ context = response.json()
163
+
164
+ try:
165
+ value = json.dumps({"authorized": authorized, "context": context})
166
+ await cache.set(key, value, ex=cache_ttl)
167
+ except RedisError as cache_error:
168
+ logger.warning("Cache set failed, proceeding without cache: %s", cache_error)
169
+
170
+ if not authorized:
171
+ raise UNAUTHORIZED
172
+
173
+ request.scope["authorizer"] = context
174
+ return request.scope["authorizer"]
175
+
176
+ authorizer.aclose = aclose
177
+ return authorizer
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-ext-pkg"
7
- version = "0.4.2"
7
+ version = "0.5.0"
8
8
  description = "A well-tailored utility set for FastAPI."
9
9
  readme = {file = "README.md", content-type = "text/markdown"}
10
10
  authors = [