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.
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/PKG-INFO +2 -2
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/PKG-INFO +2 -2
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/SOURCES.txt +2 -1
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/databases/redis.py +13 -2
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/security/auth.py +9 -2
- fastapi_ext_pkg-0.5.0/fastapi_extras/security/auth_async.py +177 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/pyproject.toml +1 -1
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/README.md +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/dependency_links.txt +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/requires.txt +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/top_level.txt +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/__init__.py +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/databases/__init__.py +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/middlewares/__init__.py +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/middlewares/remove_cloud_trace_headers.py +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/middlewares/remove_path.py +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/orm/__init__.py +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/orm/sqlalchemy.py +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_extras/security/__init__.py +0 -0
- {fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-ext-pkg
|
|
3
|
-
Version: 0.
|
|
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.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-ext-pkg
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -15,8 +15,19 @@ class Redis(redis.Redis):
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class RedisManager:
|
|
18
|
-
def __init__(
|
|
19
|
-
self
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
{fastapi_ext_pkg-0.4.2 → fastapi_ext_pkg-0.5.0}/fastapi_ext_pkg.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|