INITIAL COMMIT

This commit is contained in:
Alexandre Teles 2022-11-16 20:59:28 -03:00
parent 333b6b3758
commit d4e43feae3
28 changed files with 2817 additions and 2 deletions

31
.gitignore vendored
View File

@ -20,7 +20,6 @@ parts/
sdist/ sdist/
var/ var/
wheels/ wheels/
pip-wheel-metadata/
share/python-wheels/ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
@ -50,6 +49,7 @@ coverage.xml
*.py,cover *.py,cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/
# Translations # Translations
*.mo *.mo
@ -72,6 +72,7 @@ instance/
docs/_build/ docs/_build/
# PyBuilder # PyBuilder
.pybuilder/
target/ target/
# Jupyter Notebook # Jupyter Notebook
@ -82,7 +83,9 @@ profile_default/
ipython_config.py ipython_config.py
# pyenv # pyenv
.python-version # For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv # pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
@ -91,6 +94,13 @@ ipython_config.py
# install all needed dependencies. # install all needed dependencies.
#Pipfile.lock #Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow # PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/ __pypackages__/
@ -127,3 +137,20 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# PROJECT SPECIFIC
setup_env.sh
admin_info.json

0
app/__init__.py Normal file
View File

13
app/controllers/Auth.py Normal file
View File

@ -0,0 +1,13 @@
from datetime import timedelta
import os
import toml
from datetime import timedelta
from pydantic import BaseModel
from fastapi_paseto_auth import AuthPASETO
config: dict = toml.load("config.toml")
class PasetoSettings(BaseModel):
authpaseto_secret_key: str = os.environ['SECRET_KEY']
authpaseto_access_token_expires: int | bool = config['auth']['access_token_expires']
authpaseto_denylist_enabled: bool = True

View File

@ -0,0 +1,47 @@
from time import sleep
from redis import asyncio as aioredis
import uvloop
import app.utils.Logger as Logger
from app.dependencies import load_config
from app.utils.RedisConnector import RedisConnector
config: dict = load_config()
class Clients:
"""Implements a client for ReVanced Polling API."""
redis = RedisConnector.connect(config['tokens']['database'])
UserLogger = Logger.UserLogger()
async def ban_token(self, token: str) -> bool:
"""Ban a token
Args:
token (str): Token to ban
Returns:
bool: True if the token was banned successfully, False otherwise
"""
banned: bool = False
try:
if type(config['auth']['access_token_expires']) is bool:
await self.redis.set(name=token, value="", nx=True)
else:
await self.redis.set(name=token,
value="",
nx=True,
ex=config['auth']['access_token_expires'])
await self.UserLogger.log("BAN_TOKEN", None, token)
banned = True
except aioredis.RedisError as e:
await self.UserLogger.log("BAN_TOKEN", e)
raise e
return banned

View File

9
app/dependencies.py Normal file
View File

@ -0,0 +1,9 @@
import toml
def load_config() -> dict:
"""Loads the config.toml file.
Returns:
dict: the config.toml file as a dict
"""
return toml.load("config.toml")

142
app/main.py Normal file
View File

@ -0,0 +1,142 @@
import os
import toml
import binascii
from redis import Redis
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse, UJSONResponse
from slowapi.util import get_remote_address
from slowapi.middleware import SlowAPIMiddleware
from slowapi import Limiter, _rate_limit_exceeded_handler
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from slowapi.errors import RateLimitExceeded
from fastapi_cache.backends.redis import RedisBackend
from fastapi_paseto_auth import AuthPASETO
from fastapi_paseto_auth.exceptions import AuthPASETOException
import app.controllers.Auth as Auth
from app.dependencies import load_config
from app.utils.RedisConnector import RedisConnector
import app.models.GeneralErrors as GeneralErrors
from app.routers import root
from app.routers import auth
"""Implements an API for our polling app"""
# Load config
config: dict = load_config()
# Create FastAPI instance
app = FastAPI(title=config['docs']['title'],
description=config['docs']['description'],
version=config['docs']['version'],
license_info={"name": config['license']['name'],
"url": config['license']['url']
},
default_response_class=UJSONResponse
)
# Hook up rate limiter
limiter = Limiter(key_func=get_remote_address,
default_limits=[
config['slowapi']['limit']
],
headers_enabled=True,
storage_uri=f"redis://{os.environ['REDIS_URL']}:{os.environ['REDIS_PORT']}/{config['slowapi']['database']}"
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
# Setup routes
app.include_router(root.router)
app.include_router(auth.router)
# Setup cache
@cache()
async def get_cache() -> int:
"""Get cache TTL from config.
Returns:
int: Cache TTL
"""
return 1
# Setup PASETO
@AuthPASETO.load_config
def get_config() -> Auth.PasetoSettings:
"""Get PASETO config from Auth module
Returns:
PasetoSettings: PASETO config
"""
return Auth.PasetoSettings()
@AuthPASETO.token_in_denylist_loader
def check_if_token_in_denylist(decrypted_token):
redis = Redis(host=os.environ['REDIS_URL'],
port=os.environ['REDIS_PORT'],
db=config['tokens']['database'],
decode_responses=True)
return redis.exists(decrypted_token["jti"])
# Setup custom error handlers
@app.exception_handler(AuthPASETOException)
async def authpaseto_exception_handler(request: Request, exc: AuthPASETOException) -> JSONResponse:
"""Handle AuthPASETOException
Args:
request (Request): Request
exc (AuthPASETOException): Exception
Returns:
JSONResponse: Response
"""
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
@app.exception_handler(AttributeError)
async def validation_exception_handler(request, exc) -> JSONResponse:
"""Handle AttributeError
Args:
request (Request): Request
exc (AttributeError): Exception
Returns:
JSONResponse: Response
"""
return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={
"error": "Unprocessable Entity"
})
@app.exception_handler(binascii.Error)
async def invalid_token_exception_handler(request, exc) -> JSONResponse:
"""Handle binascii.Error
Args:
request (Request): Request
exc (binascii.Error): Exception
Returns:
JSONResponse: Response
"""
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={
"error": GeneralErrors.Unauthorized().error,
"message": GeneralErrors.Unauthorized().message
})
@app.on_event("startup")
async def startup() -> None:
"""Startup event handler"""
FastAPICache.init(RedisBackend(RedisConnector.connect(config['cache']['database'])),
prefix="fastapi-cache")
return None

View File

@ -0,0 +1,12 @@
from collections import deque
from pydantic import BaseModel
class BallotFields(BaseModel):
"""Implements the fields for the ballots.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
cid: str
weight: int

12
app/models/BallotModel.py Normal file
View File

@ -0,0 +1,12 @@
from pydantic import BaseModel
from app.models.BallotFields import BallotFields
class BallotModel(BaseModel):
"""Implements the fields for the ballots.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
discord_id_hash: str
ballot: list[BallotFields]

View File

@ -0,0 +1,23 @@
from pydantic import BaseModel
class ClientModel(BaseModel):
"""Implements the fields for the clients.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
id: str
secret: str
discord_id_hash: str
class ClientAuthModel(BaseModel):
"""Implements the fields for client authentication.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
id: str
secret: str
discord_id_hash: str

View File

@ -0,0 +1,71 @@
from pydantic import BaseModel
class InternalServerError(BaseModel):
"""Implements the response fields for when an internal server error occurs.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
error: str = "Internal Server Error"
message: str = "An internal server error occurred. Please try again later."
class AnnouncementNotFound(BaseModel):
"""Implements the response fields for when an item is not found.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
error: str = "Not Found"
message: str = "No announcement was found."
class ClientNotFound(BaseModel):
"""Implements the response fields for when a client is not found.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
error: str = "Not Found"
message: str = "No client matches the given ID"
class IdNotProvided(BaseModel):
"""Implements the response fields for when the id is not provided.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
error: str = "Bad Request"
message: str = "Missing client id"
class Unauthorized(BaseModel):
"""Implements the response fields for when the client is unauthorized.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
error: str = "Unauthorized"
message: str = "The client is unauthorized to access this resource"
class MirrorNotFoundError(BaseModel):
"""Implements the response fields for when a mirror is not found.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
error: str = "Not Found"
message: str = "No mirror was found for the organization, repository, and version provided."
class MirrorAlreadyExistsError(BaseModel):
"""Implements the response fields for when a mirror already exists.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
error: str = "Conflict"
message: str = "A mirror already exists for the organization, repository, and version provided. Please use the PUT method to update the mirror."

View File

@ -0,0 +1,77 @@
from typing import Any
from pydantic import BaseModel
class ToolsResponseFields(BaseModel):
"""Implements the fields for the /tools endpoint.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
repository: str
version: str
timestamp: str
name: str
size: str | None = None
browser_download_url: str
content_type: str
class CompatiblePackagesResponseFields(BaseModel):
"""Implements the fields for compatible packages in the PatchesResponseFields class.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
name: str
versions: list[ str ] | None
class PatchesOptionsResponseFields(BaseModel):
key: str
title: str
description: str
required: bool
choices: list[ Any ] | None
class PatchesResponseFields(BaseModel):
"""Implements the fields for the /patches endpoint.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
name: str
description: str
version: str
excluded: bool
deprecated: bool
dependencies: list[ str ] | None
options: list[ PatchesOptionsResponseFields ] | None
compatiblePackages: list[ CompatiblePackagesResponseFields ]
class ContributorFields(BaseModel):
"""Implements the fields for each contributor in the /contributors endpoint.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
login: str
avatar_url: str
html_url: str
class ContributorsResponseFields(BaseModel):
"""Implements the fields for each repository in the /contributors endpoint
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
name: str
contributors: list[ ContributorFields ]
class ChangelogsResponseFields(BaseModel):
"""Implements the fields for the /changelogs endpoint.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
sha: str
author: str
message: str
html_url: str

View File

@ -0,0 +1,109 @@
from pydantic import BaseModel
import app.models.ResponseFields as ResponseFields
"""Implements pydantic models and model generator for the API's responses."""
class ToolsResponseModel(BaseModel):
"""Implements the JSON response model for the /tools endpoint.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
tools: list[ ResponseFields.ToolsResponseFields ]
class PatchesResponseModel(BaseModel):
"""Implements the JSON response model for the /patches endpoint.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
__root__: list[ ResponseFields.PatchesResponseFields ]
class ContributorsResponseModel(BaseModel):
"""Implements the JSON response model for the /contributors endpoint.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
repositories: list[ ResponseFields.ContributorsResponseFields ]
class PingResponseModel(BaseModel):
"""Implements the JSON response model for the /heartbeat endpoint.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
status: int
detail: str
class ClientDeletedResponse(BaseModel):
"""Implements the response fields for deleted clients.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
id: str
deleted: bool
class ClientSecretUpdatedResponse(BaseModel):
"""Implements the response fields for updated client secrets.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
id: str
secret: str
class ClientAuthTokenResponse(BaseModel):
"""Implements the response fields for client auth tokens.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
access_token: str
class ClientTokenRefreshResponse(BaseModel):
"""Implements the response fields for client token refresh.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
access_token: str
class ClientStatusResponse(BaseModel):
"""Implements the response fields for client status.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
id: str
active: bool
class ChangelogsResponseModel(BaseModel):
"""Implements the JSON response model for the /changelogs endpoint.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
repository: str
path: str
commits: list[ ResponseFields.ChangelogsResponseFields ]
class RevokedTokenResponse(BaseModel):
"""Implements the response fields for token invalidation.
Args:
BaseModel (pydantic.BaseModel): BaseModel from pydantic
"""
revoked: bool

0
app/models/__init__.py Normal file
View File

0
app/routers/__init__.py Normal file
View File

66
app/routers/auth.py Normal file
View File

@ -0,0 +1,66 @@
import os
from fastapi_paseto_auth import AuthPASETO
from fastapi import APIRouter, Request, Response, Depends, status, HTTPException, Header
from app.dependencies import load_config
from app.controllers.Clients import Clients
import app.models.ClientModels as ClientModels
import app.models.GeneralErrors as GeneralErrors
import app.models.ResponseModels as ResponseModels
router = APIRouter(
prefix="/auth",
tags=['Authentication']
)
clients = Clients()
config: dict = load_config()
@router.post('/', response_model=ResponseModels.ClientAuthTokenResponse, status_code=status.HTTP_200_OK)
async def auth(request: Request, response: Response, client: ClientModels.ClientAuthModel, Authorize: AuthPASETO = Depends()) -> dict:
"""Authenticate a client and get an auth token.
Returns:
access_token: auth token
"""
if client.id == os.environ['CLIENT_ID'] and client.secret == os.environ['CLIENT_SECRET']:
authenticated: bool = True
if not authenticated:
raise HTTPException(status_code=401, detail={
"error": GeneralErrors.Unauthorized().error,
"message": GeneralErrors.Unauthorized().message
}
)
else:
user_claims: dict[str, str] = {}
user_claims['discord_id_hash'] = client.discord_id_hash
access_token = Authorize.create_access_token(subject=client.id,
user_claims=user_claims,
fresh=True)
return {"access_token": access_token}
else:
raise HTTPException(status_code=401, detail={
"error": GeneralErrors.Unauthorized().error,
"message": GeneralErrors.Unauthorized().message
}
)
@router.delete("/revoke", response_model=ResponseModels.RevokedTokenResponse, status_code=status.HTTP_200_OK)
async def revoke_token(request: Request, response: Response, Authorize: AuthPASETO = Depends(), Authorization: str = Header(None)) -> dict:
"""Revoke a token.
Returns:
revoked: bool
"""
Authorize.paseto_required()
if await clients.ban_token(Authorize.get_jti()):
return {"revoked": True}
else:
raise HTTPException(status_code=500, detail={
"error": GeneralErrors.InternalServerError().error,
"message": GeneralErrors.InternalServerError().message
}
)

0
app/routers/ballot.py Normal file
View File

14
app/routers/root.py Normal file
View File

@ -0,0 +1,14 @@
from fastapi import APIRouter, Request, Response, status
from fastapi.responses import RedirectResponse
router = APIRouter()
@router.get("/", response_class=RedirectResponse,
status_code=status.HTTP_301_MOVED_PERMANENTLY, tags=['Root'])
async def root(request: Request, response: Response) -> RedirectResponse:
"""Brings up API documentation
Returns:
None: Redirects to /docs
"""
return RedirectResponse(url="/docs")

30
app/utils/Generators.py Normal file
View File

@ -0,0 +1,30 @@
import time
import uuid
import secrets
class Generators:
"""Generates UUIDs and secrets"""
async def generate_secret(self) -> str:
"""Generate a random secret
Returns:
str: A random secret
"""
return secrets.token_urlsafe(32)
async def generate_id(self) -> str:
"""Generate a random UUID
Returns:
str: A random UUID (str instead of UUID object)
"""
return str(uuid.uuid4())
async def generate_timestamp(self) -> int:
"""Generate a timestamp
Returns:
int: A timestamp
"""
return int(time.time())

78
app/utils/Logger.py Normal file
View File

@ -0,0 +1,78 @@
from loguru import logger
from redis import RedisError
from argon2.exceptions import VerifyMismatchError
class HTTPXLogger():
"""Logger adapter for HTTPX."""
async def log_request(self, request) -> None:
"""Logs HTTPX requests
Returns:
None
"""
logger.info(f"[HTTPX] Request: {request.method} {request.url} - Waiting for response")
async def log_response(self, response) -> None:
"""Logs HTTPX responses
Returns:
None
"""
request = response.request
logger.info(f"[HTTPX] Response: {request.method} {request.url} - Status: {response.status_code} {response.reason_phrase}")
class InternalCacheLogger:
async def log(self, operation: str, result: RedisError | None = None, key: str = "",) -> None:
"""Logs internal cache operations
Args:
operation (str): Operation name
key (str): Key used in the operation
"""
if type(result) is RedisError:
logger.error(f"[InternalCache] REDIS {operation} - Failed with error: {result}")
else:
logger.info(f"[InternalCache] REDIS {operation} {key} - OK")
class UserLogger:
async def log(self, operation: str, result: RedisError | VerifyMismatchError | None = None,
key: str = "",) -> None:
"""Logs internal cache operations
Args:
operation (str): Operation name
key (str): Key used in the operation
"""
if type(result) is RedisError:
logger.error(f"[User] REDIS {operation} - Failed with error: {result}")
else:
logger.info(f"[User] REDIS {operation} {key} - OK")
class AnnouncementsLogger:
async def log(self, operation: str, result: RedisError | None = None, key: str = "") -> None:
"""Logs internal cache operations
Args:
operation (str): Operation name
key (str): Key used in the operation
"""
if type(result) is RedisError:
logger.error(f"[ANNOUNCEMENT] REDIS {operation} - Failed with error: {result}")
else:
logger.info(f"[ANNOUNCEMENT] REDIS {operation} {key} - OK")
class MirrorsLogger:
async def log(self, operation: str, result: RedisError | None = None, key: str = "") -> None:
"""Logs internal cache operations
Args:
operation (str): Operation name
key (str): Key used in the operation
"""
if type(result) is RedisError:
logger.error(f"[MIRRORS] REDIS {operation} - Failed with error: {result}")
else:
logger.info(f"[MIRRORS] REDIS {operation} {key} - OK")

View File

@ -0,0 +1,23 @@
import os
import toml
from redis import asyncio as aioredis
# Load config
config: dict = toml.load("config.toml")
# Redis connection parameters
redis_config: dict[ str, str | int ] = {
"url": f"redis://{os.environ['REDIS_URL']}",
"port": os.environ['REDIS_PORT'],
}
class RedisConnector:
"""Implements the RedisConnector class for the ReVanced API"""
@staticmethod
def connect(database: str) -> aioredis.Redis:
"""Connect to Redis"""
redis_url = f"{redis_config['url']}:{redis_config['port']}/{database}"
return aioredis.from_url(redis_url, encoding="utf-8", decode_responses=True)

0
app/utils/__init__.py Normal file
View File

46
config.toml Normal file
View File

@ -0,0 +1,46 @@
[docs]
title = "ReVanced Polling API"
description = """We do a little polling
## The official JSON API for ReVanced polls 🚀
### Important Information
* Rate Limiting - 60 requests per minute
* Cache - 5 minutes
* Token duration - 5 minutes
### Additional Notes
1. Breaking changes are to be expected
2. Client side caching is advised to avoid unnecessary requests
3. Abuse of the API will result in IP blocks
"""
version = "0.0.1"
[license]
name = "AGPL-3.0"
url = "https://www.gnu.org/licenses/agpl-3.0.en.html"
[logging]
level = "INFO"
json_logs = false
[cache]
expire = 300
database = 0
[slowapi]
limit = "60/minute"
database = 1
[tokens]
database = 2
[ballots]
database = 3
[auth]
access_token_expires = 300

78
mypy.ini Normal file
View File

@ -0,0 +1,78 @@
[mypy]
python_version = 3.10
pretty = true
follow_imports = normal
namespace_packages = true
show_column_numbers = true
show_error_codes = true
allow_redefinition = false
check_untyped_defs = true
implicit_reexport = false
strict_optional = true
strict_equality = true
warn_no_return = true
warn_redundant_casts = true
warn_unused_configs = true
warn_unused_ignores = true
warn_unreachable = true
plugins = pydantic.mypy
[mypy-toml.*]
# Current stubs are not compatible with python 3.10
ignore_missing_imports = True
[mypy-uvicorn.*]
# No stubs available
ignore_missing_imports = True
[mypy-aioredis.*]
# No stubs available
ignore_missing_imports = True
[mypy-fastapi.*]
# No stubs available
ignore_missing_imports = True
[mypy-slowapi.*]
# No stubs available
ignore_missing_imports = True
[mypy-fastapi_cache.*]
# No stubs available
ignore_missing_imports = True
[mypy-msgpack.*]
# No stubs available
ignore_missing_imports = True
[mypy-orjson.*]
# No stubs available
ignore_missing_imports = True
[mypy-httpx_cache.*]
# No stubs available
ignore_missing_imports = True
[mypy-redis.*]
# No stubs available
ignore_missing_imports = True
[mypy-toolz.*]
# No stubs available
ignore_missing_imports = True
[mypy-fastapi_paseto_auth.*]
# No stubs available
ignore_missing_imports = True
[mypy-aiofiles.*]
# No stubs available
ignore_missing_imports = True
[mypy-gunicorn.*]
# No stubs available
ignore_missing_imports = True
[mypy-asgiref.*]
# No stubs available
ignore_missing_imports = True

1686
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

40
pyproject.toml Normal file
View File

@ -0,0 +1,40 @@
[tool.poetry]
name = "revanced-polling-api"
version = "0.0.1"
description = "We do a little polling ඞ"
authors = ["Alexandre Teles <alexandre.teles@ufba.br>"]
license = "AGPLv3"
[tool.poetry.dependencies]
python = "^3.10"
fastapi = ">=0.85.0"
httpx = {version = ">=0.23.0", extras = ["http2"]}
httpx-cache = ">=0.6.0"
toml = ">=0.10.2"
slowapi = ">=0.1.6"
orjson = ">=3.8.0"
fastapi-cache2 = ">=0.1.9"
redis = ">=4.3.4"
loguru = ">=0.6.0"
sentry-sdk = ">=1.9.8"
argon2-cffi = ">=21.3.0"
hypercorn = {extras = ["uvloop"], version = ">=0.14.3"}
cytoolz = ">=0.12.0"
fastapi-paseto-auth = "^0.6.0"
ujson = ">=5.5.0"
hiredis = ">=2.0.0"
aiofiles = ">=22.1.0"
uvicorn = ">=0.18.3"
gunicorn = ">=20.1.0"
[tool.poetry.dev-dependencies]
mypy = ">=0.971"
types-toml = ">=0.10.8"
types-redis = ">=4.3.21.1"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[virtualenvs]
create = true

64
requirements.txt Normal file
View File

@ -0,0 +1,64 @@
aiofiles==22.1.0 ; python_version >= "3.10" and python_version < "4.0"
aiorwlock==1.3.0 ; python_version >= "3.10" and python_version < "4.0"
anyio==3.6.2 ; python_version >= "3.10" and python_version < "4.0"
argon2-cffi-bindings==21.2.0 ; python_version >= "3.10" and python_version < "4.0"
argon2-cffi==21.3.0 ; python_version >= "3.10" and python_version < "4.0"
async-timeout==4.0.2 ; python_version >= "3.10" and python_version < "4.0"
attrs==21.4.0 ; python_version >= "3.10" and python_version < "4.0"
certifi==2022.9.24 ; python_version >= "3.10" and python_version < "4.0"
cffi==1.15.1 ; python_version >= "3.10" and python_version < "4.0"
click==8.1.3 ; python_version >= "3.10" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" or python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
cryptography==37.0.4 ; python_version >= "3.10" and python_version < "4.0"
cytoolz==0.12.0 ; python_version >= "3.10" and python_version < "4.0"
deprecated==1.2.13 ; python_version >= "3.10" and python_version < "4.0"
fastapi-cache2==0.1.9 ; python_version >= "3.10" and python_version < "4.0"
fastapi-paseto-auth==0.6.0 ; python_version >= "3.10" and python_version < "4.0"
fastapi==0.85.0 ; python_version >= "3.10" and python_version < "4.0"
fasteners==0.17.3 ; python_version >= "3.10" and python_version < "4.0"
gunicorn==20.1.0 ; python_version >= "3.10" and python_version < "4.0"
h11==0.12.0 ; python_version >= "3.10" and python_version < "4.0"
h2==4.1.0 ; python_version >= "3.10" and python_version < "4.0"
hiredis==2.0.0 ; python_version >= "3.10" and python_version < "4.0"
hpack==4.0.0 ; python_version >= "3.10" and python_version < "4.0"
httpcore==0.15.0 ; python_version >= "3.10" and python_version < "4.0"
httpx-cache==0.6.1 ; python_version >= "3.10" and python_version < "4.0"
httpx==0.23.0 ; python_version >= "3.10" and python_version < "4.0"
httpx[http2]==0.23.0 ; python_version >= "3.10" and python_version < "4.0"
hypercorn[uvloop]==0.14.3 ; python_version >= "3.10" and python_version < "4.0"
hyperframe==6.0.1 ; python_version >= "3.10" and python_version < "4.0"
idna==3.4 ; python_version >= "3.10" and python_version < "4.0"
iso8601==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
limits==1.6 ; python_version >= "3.10" and python_version < "4.0"
loguru==0.6.0 ; python_version >= "3.10" and python_version < "4.0"
msgpack==1.0.4 ; python_version >= "3.10" and python_version < "4.0"
orjson==3.8.1 ; python_version >= "3.10" and python_version < "4.0"
packaging==21.3 ; python_version >= "3.10" and python_version < "4.0"
passlib[argon2]==1.7.4 ; python_version >= "3.10" and python_version < "4.0"
pendulum==2.1.2 ; python_version >= "3.10" and python_version < "4.0"
priority==2.0.0 ; python_version >= "3.10" and python_version < "4.0"
pycparser==2.21 ; python_version >= "3.10" and python_version < "4.0"
pycryptodomex==3.15.0 ; python_version >= "3.10" and python_version < "4.0"
pydantic==1.10.2 ; python_version >= "3.10" and python_version < "4.0"
pyparsing==3.0.9 ; python_version >= "3.10" and python_version < "4.0"
pyseto==1.6.10 ; python_version >= "3.10" and python_version < "4.0"
python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
pytzdata==2020.1 ; python_version >= "3.10" and python_version < "4.0"
redis==4.3.4 ; python_version >= "3.10" and python_version < "4.0"
rfc3986[idna2008]==1.5.0 ; python_version >= "3.10" and python_version < "4.0"
sentry-sdk==1.10.1 ; python_version >= "3.10" and python_version < "4.0"
setuptools==65.5.1 ; python_version >= "3.10" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
slowapi==0.1.6 ; python_version >= "3.10" and python_version < "4.0"
sniffio==1.3.0 ; python_version >= "3.10" and python_version < "4.0"
starlette==0.20.4 ; python_version >= "3.10" and python_version < "4.0"
toml==0.10.2 ; python_version >= "3.10" and python_version < "4.0"
toolz==0.12.0 ; python_version >= "3.10" and python_version < "4.0"
typing-extensions==4.4.0 ; python_version >= "3.10" and python_version < "4.0"
ujson==5.5.0 ; python_version >= "3.10" and python_version < "4.0"
urllib3==1.26.12 ; python_version >= "3.10" and python_version < "4"
uvicorn==0.19.0 ; python_version >= "3.10" and python_version < "4.0"
uvloop==0.17.0 ; platform_system != "Windows" and python_version >= "3.10" and python_version < "4.0"
win32-setctime==1.1.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
wrapt==1.14.1 ; python_version >= "3.10" and python_version < "4.0"
wsproto==1.2.0 ; python_version >= "3.10" and python_version < "4.0"

148
run.py Normal file
View File

@ -0,0 +1,148 @@
import os
import sys
import toml
import logging
import sentry_sdk
from app.main import app
from loguru import logger
from fastapi import FastAPI
from types import FrameType
from typing import Any, Optional
from multiprocessing import cpu_count
from gunicorn.glogging import Logger
from gunicorn.app.base import BaseApplication
from sentry_sdk.integrations.redis import RedisIntegration
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.gnu_backtrace import GnuBacktraceIntegration
config: dict = toml.load("config.toml")
# Enable sentry logging
sentry_sdk.init(os.environ['SENTRY_DSN'], traces_sample_rate=1.0, integrations=[
RedisIntegration(),
HttpxIntegration(),
GnuBacktraceIntegration(),
],)
LOG_LEVEL: Any = logging.getLevelName(config['logging']['level'])
JSON_LOGS: bool = config['logging']['json_logs']
WORKERS: int = int(cpu_count() + 1)
BIND: str = f'{os.environ.get("HYPERCORN_HOST")}:{os.environ.get("HYPERCORN_PORT")}'
class InterceptHandler(logging.Handler):
"""Intercept logs and forward them to Loguru.
Args:
logging.Handler (Filterer): Handler to filter logs
"""
def emit(self, record: logging.LogRecord) -> None:
"""Emit a log record."""
# Get corresponding Loguru level if it exists
level: str | int
frame: FrameType
depth: int
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
# Find caller from where originated the logged message
frame, depth = logging.currentframe(), 2
while frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
class StubbedGunicornLogger(Logger):
"""Defining a custom logger class to prevent gunicorn from logging to stdout
Args:
Logger (object): Gunicon logger class
"""
def setup(self, cfg) -> None:
"""Setup logger."""
handler: logging.NullHandler = logging.NullHandler()
self.error_logger: Logger = logging.getLogger("gunicorn.error")
self.error_logger.addHandler(handler)
self.access_logger: Logger = logging.getLogger("gunicorn.access")
self.access_logger.addHandler(handler)
self.error_logger.setLevel(LOG_LEVEL)
self.access_logger.setLevel(LOG_LEVEL)
class StandaloneApplication(BaseApplication):
"""Defines a Guicorn application
Args:
BaseApplication (object): Base class for Gunicorn applications
"""
def __init__(self, app: FastAPI, options: dict | None = None):
"""Initialize the application
Args:
app (fastapi.FastAPI): FastAPI application
options (dict, optional): Gunicorn options. Defaults to None.
"""
self.options: dict = options or {}
self.application: FastAPI = app
super().__init__()
def load_config(self) -> None:
"""Load Gunicorn configuration."""
config: dict = {
key: value for key, value in self.options.items()
if key in self.cfg.settings and value is not None
}
for key, value in config.items():
self.cfg.set(key.lower(), value)
def load(self) -> FastAPI:
"""Load the application
Returns:
FastAPI: FastAPI application
"""
return self.application
if __name__ == '__main__':
intercept_handler = InterceptHandler()
logging.root.setLevel(LOG_LEVEL)
seen: set = set()
for name in [
*logging.root.manager.loggerDict.keys(),
"gunicorn",
"gunicorn.access",
"gunicorn.error",
"uvicorn",
"uvicorn.access",
"uvicorn.error",
]:
if name not in seen:
seen.add(name.split(".")[0])
logging.getLogger(name).handlers = [intercept_handler]
logger.configure(handlers=[{"sink": sys.stdout, "serialize": JSON_LOGS}])
options: dict = {
"bind": BIND,
"workers": WORKERS,
"accesslog": "-",
"errorlog": "-",
"worker_class": "uvicorn.workers.UvicornWorker",
"logger_class": StubbedGunicornLogger,
"preload": True,
}
StandaloneApplication(app, options).run()