Skip to content

Commit 9a0d724

Browse files
committed
New version
1 parent 53dd41e commit 9a0d724

9 files changed

Lines changed: 212 additions & 1 deletion

File tree

fastapi_toolkit/application.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from fastapi import (
2+
FastAPI,
3+
status,
4+
)
5+
from fastapi.encoders import jsonable_encoder
6+
from fastapi.exceptions import RequestValidationError
7+
from fastapi.responses import JSONResponse
8+
9+
from fastapi_toolkit.exceptions import Error, exc_detail
10+
11+
12+
def prepare_app(app: FastAPI) -> FastAPI:
13+
14+
@app.exception_handler(RequestValidationError)
15+
async def validation_exception_handler(request, exc):
16+
return JSONResponse({
17+
'detail': exc_detail(
18+
code=Error.request_validation_error,
19+
error='Invalid request',
20+
info=jsonable_encoder(exc.errors())
21+
)
22+
}, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
23+
24+
return app

fastapi_toolkit/dependencies/__init__.py

Whitespace-only changes.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from collections.abc import Awaitable, Callable
2+
from inspect import iscoroutinefunction
3+
from typing import TypeVar
4+
5+
import jwt
6+
from fastapi import (
7+
Depends,
8+
HTTPException,
9+
status,
10+
)
11+
from fastapi.security import (
12+
HTTPAuthorizationCredentials,
13+
SecurityScopes,
14+
)
15+
from jwt import PyJWTError
16+
17+
from fastapi_toolkit.exceptions import Error, exc_detail
18+
from fastapi_toolkit.schemas.user import DecodedUserModel
19+
from fastapi_toolkit.security import HTTPBearer
20+
21+
T = TypeVar('T', bound=DecodedUserModel)
22+
23+
24+
def get_user_dependency(
25+
jwt_secret_dependency: Callable[..., str | Awaitable[str]],
26+
alg_dependency: Callable[..., str | Awaitable[str]],
27+
project_dependency: Callable[..., str | Awaitable[str]],
28+
user_model: type[T],
29+
token_validator: Callable[[T], None | Awaitable[None]] | None = None,
30+
) -> Callable[..., Awaitable[T]]:
31+
async def get_user(
32+
security_scopes: SecurityScopes,
33+
auth: HTTPAuthorizationCredentials = Depends(HTTPBearer(
34+
auto_error=True
35+
)),
36+
jwt_secret: str = Depends(jwt_secret_dependency),
37+
alg: str = Depends(alg_dependency),
38+
project: str = Depends(project_dependency),
39+
) -> T:
40+
try:
41+
decoded_token = jwt.decode(auth.credentials, jwt_secret, algorithms=[alg])
42+
except PyJWTError as exc:
43+
raise HTTPException(
44+
status_code=status.HTTP_401_UNAUTHORIZED,
45+
detail=exc_detail(
46+
code=Error.jwt_validation_error,
47+
error=str(exc)
48+
)
49+
) from exc
50+
else:
51+
decoded_token = user_model.model_validate(decoded_token)
52+
53+
if token_validator is not None:
54+
if iscoroutinefunction(token_validator):
55+
await token_validator(decoded_token)
56+
else:
57+
token_validator(decoded_token)
58+
59+
user_permissions = decoded_token.permissions.get(project, 0)
60+
for scope in (security_scopes.scopes or []):
61+
scope = int(scope)
62+
if not (user_permissions & scope == scope):
63+
raise HTTPException(
64+
status_code=status.HTTP_403_FORBIDDEN,
65+
detail=exc_detail(
66+
code=Error.permissions_error,
67+
error='Permissions required',
68+
info={
69+
'project': project,
70+
'required_permission': scope,
71+
'user_permissions': user_permissions
72+
}
73+
)
74+
)
75+
return decoded_token
76+
return get_user

fastapi_toolkit/exceptions.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import enum
2+
from typing import Any
3+
4+
5+
class Error(enum.StrEnum):
6+
auth_error = 'auth_error'
7+
request_validation_error = 'request_validation_error'
8+
jwt_validation_error = 'jwt_validation_error'
9+
permissions_error = 'permissions_error'
10+
object_not_found = 'object_not_found'
11+
integrity_error = 'integrity_error'
12+
13+
14+
def exc_detail(code: enum.StrEnum, error: str | None = None, info: Any | None = None) -> dict[str, Any]:
15+
return {
16+
'code': code.value,
17+
'error': error,
18+
'info': info
19+
}

fastapi_toolkit/schemas/__init__.py

Whitespace-only changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Any
2+
3+
from pydantic import BaseModel
4+
5+
6+
class DetailModel(BaseModel):
7+
code: str
8+
error: str | None = None
9+
info: Any | None = None
10+
11+
12+
class ErrorResponseModel(BaseModel):
13+
detail: DetailModel

fastapi_toolkit/schemas/user.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from uuid import UUID
2+
3+
from pydantic import (
4+
BaseModel,
5+
Field,
6+
)
7+
8+
9+
class DecodedUserModel(BaseModel):
10+
id: UUID = Field(..., alias="sub")
11+
permissions: dict[str, int] = Field(default_factory=dict)
12+
version: int = 0
13+
jid: UUID | None = None

fastapi_toolkit/security.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Standard Library
2+
import enum
3+
from typing import Literal
4+
5+
# Third Party Library
6+
from fastapi import (
7+
HTTPException,
8+
Request,
9+
status,
10+
)
11+
from fastapi.security.http import (
12+
HTTPAuthorizationCredentials,
13+
)
14+
from fastapi.security.http import (
15+
HTTPBearer as BaseHTTPBearer,
16+
)
17+
from fastapi.security.utils import get_authorization_scheme_param
18+
19+
from fastapi_toolkit.exceptions import Error, exc_detail
20+
from fastapi_toolkit.schemas.response import DetailModel
21+
22+
23+
def get_scopes(*scopes: enum.IntEnum | enum.Flag) -> list[str]:
24+
return [str(scope.value) for scope in scopes]
25+
26+
27+
class HTTPBearer(BaseHTTPBearer):
28+
async def __call__(
29+
self, request: Request
30+
) -> HTTPAuthorizationCredentials | None:
31+
authorization = request.headers.get('Authorization')
32+
scheme, credentials = get_authorization_scheme_param(authorization)
33+
if not (authorization and scheme and credentials):
34+
if self.auto_error:
35+
raise HTTPException(
36+
status_code=status.HTTP_403_FORBIDDEN,
37+
detail=exc_detail(
38+
code=Error.auth_error,
39+
error='Not authenticated'
40+
)
41+
)
42+
else:
43+
return None
44+
if scheme.lower() != 'bearer':
45+
if self.auto_error:
46+
raise HTTPException(
47+
status_code=status.HTTP_403_FORBIDDEN,
48+
detail=exc_detail(
49+
code=Error.auth_error,
50+
error='Invalid authentication credentials'
51+
),
52+
)
53+
else:
54+
return None
55+
return HTTPAuthorizationCredentials(
56+
scheme=scheme,
57+
credentials=credentials
58+
)
59+
60+
61+
ERROR_AUTH_RESPONSES = {
62+
status.HTTP_403_FORBIDDEN: {
63+
'model': DetailModel,
64+
'description': 'Not authenticated / Invalid authentication credentials',
65+
}
66+
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "fastapi-toolkit"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
description = "Common toolkit for FastAPI + SQLAlchemy projects: async DB sessions, base CRUD, settings with AWS Secrets Manager"
99
readme = "README.md"
1010
license = "MIT"

0 commit comments

Comments
 (0)