first commit
This commit is contained in:
28
.dockerignore
Normal file
28
.dockerignore
Normal file
@ -0,0 +1,28 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# UV
|
||||
uv.lock
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Ruff
|
||||
.ruff_cache
|
||||
|
||||
# Pytest
|
||||
.pytest_cache
|
||||
|
||||
# SQLite
|
||||
*.db
|
||||
|
||||
# Postgres
|
||||
postgres
|
||||
21
.env.example
Normal file
21
.env.example
Normal file
@ -0,0 +1,21 @@
|
||||
# Port
|
||||
APP_PORT=9000 # Optional
|
||||
|
||||
# Application
|
||||
SECRET_KEY=secret_key
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:example@localhost:5432/postgres
|
||||
POSTGRES_PORT=5432 # Optional
|
||||
POSTGRES_DATA=./postgres # Optional
|
||||
|
||||
# Valkey
|
||||
VALKEY_URL=redis://localhost:6379/0
|
||||
VALKEY_PORT=6379 # Optional
|
||||
|
||||
# VK
|
||||
VK_CLIENT_ID=
|
||||
VK_REDIRECT_URI=
|
||||
|
||||
# Logging (Optional)
|
||||
LOKI_URL=
|
||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# UV
|
||||
uv.lock
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Ruff
|
||||
.ruff_cache
|
||||
|
||||
# Pytest
|
||||
.pytest_cache
|
||||
|
||||
# SQLite
|
||||
*.db
|
||||
|
||||
# Postgres
|
||||
postgres
|
||||
34
.pre-commit-config.yaml
Normal file
34
.pre-commit-config.yaml
Normal file
@ -0,0 +1,34 @@
|
||||
repos:
|
||||
- repo: https://github.com/crate-ci/typos
|
||||
rev: v1.31.1
|
||||
hooks:
|
||||
- id: typos
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ --fix ]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/RobertCraigie/pyright-python
|
||||
rev: v1.1.398
|
||||
hooks:
|
||||
- id: pyright
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: check-docstring-first
|
||||
- id: check-added-large-files
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
- id: check-merge-conflict
|
||||
- id: double-quote-string-fixer
|
||||
- id: end-of-file-fixer
|
||||
|
||||
- repo: meta
|
||||
hooks:
|
||||
- id: check-hooks-apply
|
||||
- id: check-useless-excludes
|
||||
53
Dockerfile
Normal file
53
Dockerfile
Normal file
@ -0,0 +1,53 @@
|
||||
#################################################
|
||||
FROM debian:bookworm-slim AS builder-base
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -y \
|
||||
libpq-dev \
|
||||
ca-certificates \
|
||||
&& groupadd --gid 1001 appuser \
|
||||
&& useradd --uid 1001 --gid appuser --shell /bin/bash --create-home appuser
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
UV_VERSION="0.7.12" \
|
||||
UV_PYTHON="3.13.4" \
|
||||
UV_PYTHON_INSTALL_DIR="/app/.python" \
|
||||
UV_PYTHON_PREFERENCE="only-managed" \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_NO_INSTALLER_METADATA=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
PATH="$PATH:/root/.local/bin/:/app/.venv/bin"
|
||||
#################################################
|
||||
FROM builder-base AS python-base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get install --no-install-recommends -y \
|
||||
curl \
|
||||
clang \
|
||||
&& curl -LsSf https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-installer.sh | sh \
|
||||
&& uv python install
|
||||
|
||||
COPY pyproject.toml ./
|
||||
|
||||
RUN uv sync --no-dev -n
|
||||
RUN uv version --short > .version
|
||||
#################################################
|
||||
FROM builder-base AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN chown -R appuser:appuser /app
|
||||
|
||||
COPY --from=python-base /app/.python /app/.python
|
||||
COPY --from=python-base /app/.venv /app/.venv
|
||||
COPY --from=python-base /app/.version /app/
|
||||
COPY /src/ /app/
|
||||
COPY /scripts/ /app/scripts
|
||||
RUN chmod -R 755 /app/scripts
|
||||
|
||||
USER appuser
|
||||
|
||||
CMD ["sh", "./scripts/boot.sh"]
|
||||
#################################################
|
||||
81
docker-compose.yml
Normal file
81
docker-compose.yml
Normal file
@ -0,0 +1,81 @@
|
||||
name: VKResenderAPI
|
||||
|
||||
x-app-common: &app-common
|
||||
build:
|
||||
context: .
|
||||
target: production
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
stop_signal: SIGINT
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DEBUG: true
|
||||
DATABASE_URL: "postgresql://postgres:example@pgbouncer:5432/postgres"
|
||||
REDIS_URL: "redis://valkey:6379/0"
|
||||
depends_on:
|
||||
pgbouncer:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17.2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${POSTGRES_PORT:-5432}:5432
|
||||
environment:
|
||||
POSTGRES_PASSWORD: example
|
||||
volumes:
|
||||
- "${POSTGRES_DATA:-./postgres}:/var/lib/postgresql/data/"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
pgbouncer:
|
||||
image: edoburu/pgbouncer:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POOL_MODE=transaction
|
||||
- MAX_DB_CONNECTIONS=3000
|
||||
- DEFAULT_POOL_SIZE=100
|
||||
- AUTH_TYPE=md5
|
||||
- AUTH_FILE=/etc/pgbouncer/userlist.txt
|
||||
- DATABASE_URL=postgresql://postgres:example@db:5432/postgres
|
||||
volumes:
|
||||
- ./userlist.txt:/etc/pgbouncer/userlist.txt
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-p", "5432"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${VALKEY_PORT:-6379}:6379
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
<<: *app-common
|
||||
ports:
|
||||
- "${APP_PORT:-9000}:${APP_PORT:-9000}"
|
||||
|
||||
worker:
|
||||
<<: *app-common
|
||||
command: celery -A core.worker worker --autoscale=0,4 -E --loglevel=info
|
||||
|
||||
beat:
|
||||
<<: *app-common
|
||||
command: celery -A core.worker beat --loglevel=info
|
||||
123
pyproject.toml
Normal file
123
pyproject.toml
Normal file
@ -0,0 +1,123 @@
|
||||
[project]
|
||||
name = "VKResenderAPI"
|
||||
version = "0.1.0"
|
||||
description = "API for the VK Resender"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
# Server
|
||||
"celery==5.5.3",
|
||||
"fastapi==0.115.12",
|
||||
"gunicorn==23.0.0",
|
||||
"uvicorn-worker==0.3.0",
|
||||
"uvicorn[standard]==0.34.3",
|
||||
"orjson==3.10.18",
|
||||
"redis[hiredis]==6.2.0",
|
||||
# Logging & Metrics
|
||||
"python-logging-loki==0.3.1",
|
||||
# Requests
|
||||
"httpx==0.28.1",
|
||||
"aiohttp[speedups]==3.12.11",
|
||||
# Database
|
||||
"alembic==1.16.1",
|
||||
"asyncpg==0.30.0",
|
||||
"psycopg==3.2.9",
|
||||
"psycopg-c==3.2.9; sys_platform != 'win32'",
|
||||
"sqlmodel==0.0.24",
|
||||
# Types
|
||||
"pydantic[email]==2.11.5",
|
||||
"pydantic-extra-types==2.10.5",
|
||||
"pydantic-settings==2.9.1",
|
||||
"semver==3.0.4",
|
||||
# CLI
|
||||
"typer-slim==0.16.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"celery-types==0.23.0",
|
||||
"poethepoet==0.34.0",
|
||||
"pre-commit==4.2.0",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"pyright==1.1.401",
|
||||
"ruff==0.11.13",
|
||||
"types-pyjwt==1.7.1",
|
||||
"types-redis==4.6.0.20241004",
|
||||
]
|
||||
|
||||
[tool.poe.tasks]
|
||||
_git = "git add ."
|
||||
_lint = "pre-commit run --all-files"
|
||||
|
||||
lint = ["_git", "_lint"]
|
||||
check = "uv pip ls --outdated"
|
||||
run = "uv run --directory ./src/ server.py"
|
||||
manage = "uv run --directory ./src/ manage.py"
|
||||
migrate = "uv run --directory ./src/ alembic revision --autogenerate"
|
||||
|
||||
[tool.uv]
|
||||
required-version = ">=0.7.0"
|
||||
dependency-metadata = [
|
||||
{ name = "psycopg-c", version = "3.2.9", python = ">=3.8", requires = [
|
||||
"psycopg==3.2.9",
|
||||
] },
|
||||
]
|
||||
|
||||
[tool.typos.files]
|
||||
extend-exclude = ["**/migrations/versions"]
|
||||
|
||||
[tool.pyright]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
exclude = ["**/migrations/versions"]
|
||||
strictListInference = true
|
||||
strictDictionaryInference = true
|
||||
strictSetInference = true
|
||||
deprecateTypingAliases = true
|
||||
typeCheckingMode = "strict"
|
||||
pythonPlatform = "All"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
line-length = 79
|
||||
fix = true
|
||||
|
||||
[tool.ruff.lint]
|
||||
preview = true
|
||||
select = [
|
||||
"E",
|
||||
"W",
|
||||
"F",
|
||||
"UP",
|
||||
"A",
|
||||
"B",
|
||||
"C4",
|
||||
"SIM",
|
||||
"I",
|
||||
"S",
|
||||
"G",
|
||||
"FAST",
|
||||
"ASYNC",
|
||||
"BLE",
|
||||
"INT",
|
||||
"ISC",
|
||||
"ICN",
|
||||
"PYI",
|
||||
"INP",
|
||||
"RSE",
|
||||
"PIE",
|
||||
"SLOT",
|
||||
"TID",
|
||||
"LOG",
|
||||
"FBT",
|
||||
"DTZ",
|
||||
"EM",
|
||||
"PERF",
|
||||
"RUF",
|
||||
]
|
||||
ignore = ["RUF029", "S104"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "single"
|
||||
indent-style = "space"
|
||||
docstring-code-format = true
|
||||
4
scripts/boot.sh
Normal file
4
scripts/boot.sh
Normal file
@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
python -m alembic upgrade head
|
||||
python -m gunicorn
|
||||
6
src/alembic.ini
Normal file
6
src/alembic.ini
Normal file
@ -0,0 +1,6 @@
|
||||
[alembic]
|
||||
file_template = %%(year)d.%%(month).2d.%%(day).2d_%%(hour).2d-%%(minute).2d-%%(second).2d_%%(rev)s
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
output_encoding = utf-8
|
||||
0
src/apps/__init__.py
Normal file
0
src/apps/__init__.py
Normal file
0
src/apps/vk/__init__.py
Normal file
0
src/apps/vk/__init__.py
Normal file
1
src/apps/vk/constants.py
Normal file
1
src/apps/vk/constants.py
Normal file
@ -0,0 +1 @@
|
||||
SCOPES = ['wall', 'groups', 'photos']
|
||||
22
src/apps/vk/models.py
Normal file
22
src/apps/vk/models.py
Normal file
@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
__tablename__ = 'vk_users' # type: ignore
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
first_name: str | None = Field(default=None)
|
||||
last_name: str | None = Field(default=None)
|
||||
screen_name: str | None = Field(default=None)
|
||||
|
||||
|
||||
class Tokens(SQLModel, table=True):
|
||||
__tablename__ = 'vk_tokens' # type: ignore
|
||||
|
||||
user_id: int = Field(foreign_key='vk_users.id', primary_key=True)
|
||||
access_token: str = Field()
|
||||
refresh_token: str = Field()
|
||||
device_id: str = Field()
|
||||
expires_at: datetime = Field()
|
||||
24
src/clients/__init__.py
Normal file
24
src/clients/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
from .vk.api import VKAPIClient
|
||||
from .vk.auth import VKAuthClient
|
||||
|
||||
|
||||
class ClientsObject:
|
||||
_vk_api = None
|
||||
_vk_auth = None
|
||||
|
||||
@property
|
||||
def vk_api(self):
|
||||
if not self._vk_api:
|
||||
self._vk_api = VKAPIClient()
|
||||
|
||||
return self._vk_api
|
||||
|
||||
@property
|
||||
def vk_auth(self):
|
||||
if not self._vk_auth:
|
||||
self._vk_auth = VKAuthClient()
|
||||
|
||||
return self._vk_auth
|
||||
|
||||
|
||||
clients = ClientsObject()
|
||||
0
src/clients/base/__init__.py
Normal file
0
src/clients/base/__init__.py
Normal file
61
src/clients/base/clients/__init__.py
Normal file
61
src/clients/base/clients/__init__.py
Normal file
@ -0,0 +1,61 @@
|
||||
from collections.abc import Callable, Mapping
|
||||
from logging import getLogger
|
||||
from ssl import SSLContext
|
||||
from typing import Any
|
||||
|
||||
from httpx import URL, Limits
|
||||
from httpx import AsyncClient as AsyncHTTPXClient
|
||||
from httpx import _config as c # type: ignore
|
||||
from httpx import _types as t # type: ignore
|
||||
|
||||
from clients.base.transports._aiohttp import AiohttpTransport
|
||||
from clients.base.transports._cache import AsyncCacheTransport
|
||||
from clients.base.transports._rate_limit import AsyncRateLimit
|
||||
|
||||
|
||||
class AsyncClient(AsyncHTTPXClient):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
auth: t.AuthTypes | None = None,
|
||||
params: t.QueryParamTypes | None = None,
|
||||
headers: t.HeaderTypes | None = None,
|
||||
cookies: t.CookieTypes | None = None,
|
||||
verify: SSLContext | str | bool = True,
|
||||
cert: t.CertTypes | None = None,
|
||||
proxy: t.ProxyTypes | None = None,
|
||||
mounts: Mapping[str, AiohttpTransport | AsyncRateLimit | None]
|
||||
| None = None,
|
||||
timeout: t.TimeoutTypes = c.DEFAULT_TIMEOUT_CONFIG,
|
||||
follow_redirects: bool = False,
|
||||
limits: Limits = c.DEFAULT_LIMITS,
|
||||
max_redirects: int = c.DEFAULT_MAX_REDIRECTS,
|
||||
event_hooks: Mapping[str, list[Callable[..., Any]]] | None = None,
|
||||
base_url: URL | str = '',
|
||||
trust_env: bool = True,
|
||||
default_encoding: str | Callable[[bytes], str] = 'utf-8',
|
||||
key: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
auth=auth,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
verify=verify,
|
||||
cert=cert,
|
||||
http1=True,
|
||||
http2=False,
|
||||
proxy=proxy,
|
||||
mounts=mounts,
|
||||
timeout=timeout,
|
||||
follow_redirects=follow_redirects,
|
||||
limits=limits,
|
||||
max_redirects=max_redirects,
|
||||
event_hooks=event_hooks,
|
||||
base_url=base_url,
|
||||
transport=AsyncCacheTransport(key, limit),
|
||||
trust_env=trust_env,
|
||||
default_encoding=default_encoding,
|
||||
)
|
||||
self.logger = getLogger(__name__)
|
||||
9
src/clients/base/responses/__init__.py
Normal file
9
src/clients/base/responses/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
from httpx import Response as HTTPXResponse
|
||||
from orjson import loads
|
||||
|
||||
|
||||
class Response(HTTPXResponse):
|
||||
def json(self, **kwargs: Any):
|
||||
return loads(self.content, **kwargs)
|
||||
0
src/clients/base/transports/__init__.py
Normal file
0
src/clients/base/transports/__init__.py
Normal file
102
src/clients/base/transports/_aiohttp.py
Normal file
102
src/clients/base/transports/_aiohttp.py
Normal file
@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
from logging import getLogger
|
||||
from types import TracebackType
|
||||
|
||||
import aiohttp
|
||||
import httpx
|
||||
|
||||
from clients.base.responses import Response
|
||||
|
||||
EXCEPTIONS = {
|
||||
aiohttp.ClientError: httpx.RequestError,
|
||||
aiohttp.ClientConnectionError: httpx.NetworkError,
|
||||
aiohttp.ClientConnectorError: httpx.ConnectError,
|
||||
aiohttp.ClientOSError: httpx.ConnectError,
|
||||
aiohttp.ClientConnectionResetError: httpx.ConnectError,
|
||||
aiohttp.ClientConnectorDNSError: httpx.ConnectError,
|
||||
aiohttp.ClientSSLError: httpx.ProtocolError,
|
||||
aiohttp.ClientConnectorCertificateError: httpx.ProtocolError,
|
||||
aiohttp.ServerFingerprintMismatch: httpx.ProtocolError,
|
||||
aiohttp.ClientProxyConnectionError: httpx.ProxyError,
|
||||
aiohttp.ClientResponseError: httpx.HTTPStatusError,
|
||||
aiohttp.ContentTypeError: httpx.DecodingError,
|
||||
aiohttp.ClientPayloadError: httpx.ReadError,
|
||||
aiohttp.ServerDisconnectedError: httpx.ReadError,
|
||||
aiohttp.InvalidURL: httpx.InvalidURL,
|
||||
aiohttp.TooManyRedirects: httpx.TooManyRedirects,
|
||||
}
|
||||
|
||||
|
||||
class AiohttpTransport(httpx.AsyncBaseTransport):
|
||||
def __init__(
|
||||
self,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
):
|
||||
self.logger = getLogger(__name__)
|
||||
self._session = session or aiohttp.ClientSession()
|
||||
self._closed = False
|
||||
|
||||
def map_aiohttp_exception(self, exc: Exception):
|
||||
for aiohttp_exc, httpx_exc in EXCEPTIONS.items():
|
||||
if isinstance(exc, aiohttp_exc):
|
||||
return httpx_exc(message=str(exc)) # type: ignore
|
||||
|
||||
if isinstance(exc, asyncio.TimeoutError):
|
||||
return httpx.TimeoutException(str(exc))
|
||||
|
||||
return httpx.HTTPError(f'Unknown error: {exc!s}')
|
||||
|
||||
async def __aenter__(self):
|
||||
await self._session.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: TracebackType | None = None,
|
||||
):
|
||||
await self._session.__aexit__(exc_type, exc_value, traceback)
|
||||
self._closed = True
|
||||
|
||||
async def aclose(self):
|
||||
if not self._closed:
|
||||
self._closed = True
|
||||
await self._session.close()
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request):
|
||||
if self._closed:
|
||||
msg = 'Transport is closed'
|
||||
raise RuntimeError(msg)
|
||||
|
||||
try:
|
||||
headers = dict(request.headers)
|
||||
method = request.method
|
||||
url = str(request.url)
|
||||
content = request.content
|
||||
|
||||
async with self._session.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
data=content,
|
||||
allow_redirects=True,
|
||||
) as aiohttp_response:
|
||||
content = await aiohttp_response.read()
|
||||
|
||||
httpx_headers = [
|
||||
(k.lower(), v)
|
||||
for k, v in aiohttp_response.headers.items()
|
||||
if k.lower() != 'content-encoding'
|
||||
]
|
||||
|
||||
return Response(
|
||||
status_code=aiohttp_response.status,
|
||||
headers=httpx_headers,
|
||||
content=content,
|
||||
request=request,
|
||||
)
|
||||
except Exception as e:
|
||||
exception = self.map_aiohttp_exception(e)
|
||||
self.logger.error(exception)
|
||||
raise exception from e
|
||||
84
src/clients/base/transports/_cache.py
Normal file
84
src/clients/base/transports/_cache.py
Normal file
@ -0,0 +1,84 @@
|
||||
from httpx import Request
|
||||
from httpx import Response as HTTPXResponse
|
||||
from httpx import _models as m # type: ignore
|
||||
from orjson import dumps, loads
|
||||
|
||||
from clients.base.responses import Response
|
||||
from shared.redis import client
|
||||
|
||||
from ._rate_limit import AsyncRateLimit
|
||||
|
||||
|
||||
def generate_cache_key(request: Request):
|
||||
request_data = {
|
||||
'method': request.method,
|
||||
'url': str(request.url),
|
||||
}
|
||||
return f'cache:{hash(str(dumps(request_data)))}'
|
||||
|
||||
|
||||
def cache_response(
|
||||
cache_key: str,
|
||||
request: Request,
|
||||
response: Response | HTTPXResponse,
|
||||
) -> None:
|
||||
serialized_response = serialize_response(response)
|
||||
ttl = get_ttl_from_headers(request.headers)
|
||||
|
||||
if ttl:
|
||||
client.set(cache_key, serialized_response, ex=ttl)
|
||||
|
||||
|
||||
def get_ttl_from_headers(headers: m.Headers):
|
||||
if 'X-Cache-TTL' in headers:
|
||||
try:
|
||||
return int(headers['X-Cache-TTL'])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def get_cached_response(cache_key: str):
|
||||
cached_data = client.get(cache_key)
|
||||
|
||||
if cached_data:
|
||||
return deserialize_response(cached_data)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def serialize_response(response: Response | HTTPXResponse):
|
||||
response_data = {
|
||||
'status_code': response.status_code,
|
||||
'headers': dict(response.headers),
|
||||
'content': response.content.decode(),
|
||||
}
|
||||
|
||||
return dumps(response_data)
|
||||
|
||||
|
||||
def deserialize_response(serialized_response: bytes):
|
||||
response_data = loads(serialized_response)
|
||||
|
||||
return Response(
|
||||
status_code=response_data['status_code'],
|
||||
headers=response_data['headers'],
|
||||
content=response_data['content'].encode(),
|
||||
)
|
||||
|
||||
|
||||
class AsyncCacheTransport(AsyncRateLimit):
|
||||
def __init__(self, key: str | None, limit: int | None):
|
||||
self.transport = AsyncRateLimit(key, limit)
|
||||
|
||||
async def handle_async_request(self, request: Request):
|
||||
cache_key = generate_cache_key(request)
|
||||
cached_response = get_cached_response(cache_key)
|
||||
|
||||
if cached_response:
|
||||
return cached_response
|
||||
|
||||
response = await self.transport.handle_async_request(request)
|
||||
|
||||
cache_response(cache_key, request, response)
|
||||
|
||||
return response
|
||||
45
src/clients/base/transports/_rate_limit.py
Normal file
45
src/clients/base/transports/_rate_limit.py
Normal file
@ -0,0 +1,45 @@
|
||||
from asyncio import sleep as async_sleep
|
||||
|
||||
from httpx import Request
|
||||
|
||||
from clients.base.responses import Response
|
||||
from shared.redis import client
|
||||
|
||||
from ._aiohttp import AiohttpTransport
|
||||
|
||||
|
||||
class AsyncRateLimit(AiohttpTransport):
|
||||
def __init__(self, key: str | None, limit: int | None):
|
||||
self.transport = AiohttpTransport()
|
||||
self.key = key
|
||||
self.limit = limit
|
||||
|
||||
if (self.key and not self.limit) or (not self.key and self.limit):
|
||||
msg = 'Incorrectly configured key or limit for rate limiting'
|
||||
raise Exception(msg)
|
||||
|
||||
async def request_is_limited(self):
|
||||
if self.key and self.limit:
|
||||
t: int = int(client.time()[0]) # type: ignore
|
||||
separation = round(60 / self.limit)
|
||||
|
||||
value = client.get(self.key) or t
|
||||
client.setnx(self.key, value)
|
||||
|
||||
tat = max(int(value), t)
|
||||
|
||||
if tat - t <= 60 - separation:
|
||||
new_tat = max(tat, t) + separation
|
||||
client.set(self.key, new_tat)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def handle_async_request(self, request: Request) -> Response:
|
||||
if await self.request_is_limited():
|
||||
await async_sleep(0.25)
|
||||
return await self.handle_async_request(request)
|
||||
|
||||
return await self.transport.handle_async_request(request)
|
||||
0
src/clients/vk/__init__.py
Normal file
0
src/clients/vk/__init__.py
Normal file
170
src/clients/vk/api.py
Normal file
170
src/clients/vk/api.py
Normal file
@ -0,0 +1,170 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic.types import PositiveInt
|
||||
|
||||
from clients.base.clients import AsyncClient
|
||||
|
||||
from . import schema as s
|
||||
|
||||
|
||||
class VKAPIClient(AsyncClient):
|
||||
def __init__(self):
|
||||
self.base_uri = 'https://api.vk.com/method'
|
||||
self.version = '5.215'
|
||||
|
||||
super().__init__(base_url=self.base_uri)
|
||||
|
||||
async def account_getProfileInfo(self, access_token: str):
|
||||
"""
|
||||
https://dev.vk.com/ru/method/account.getProfileInfo
|
||||
"""
|
||||
req = await self.post(
|
||||
'/account.getProfileInfo',
|
||||
data={'access_token': access_token, 'v': self.version},
|
||||
)
|
||||
|
||||
return s.ProfileInfo.model_validate(req.json()['response'])
|
||||
|
||||
async def photos_getWallUploadServer(
|
||||
self, access_token: str, group_id: int | None = None
|
||||
):
|
||||
"""
|
||||
https://dev.vk.com/ru/method/photos.getWallUploadServer
|
||||
"""
|
||||
req = await self.post(
|
||||
'/photos.getWallUploadServer',
|
||||
data={
|
||||
'group_id': group_id,
|
||||
'access_token': access_token,
|
||||
'v': self.version,
|
||||
},
|
||||
)
|
||||
|
||||
if 'response' not in req.json():
|
||||
self.logger.error(req.json())
|
||||
|
||||
return s.UploadServerResponse.model_validate(req.json()['response'])
|
||||
|
||||
async def photos_saveWallPhoto(
|
||||
self,
|
||||
access_token: str,
|
||||
photo: tuple[bytes, str],
|
||||
server: str,
|
||||
_hash: str,
|
||||
user_id: PositiveInt | None = None,
|
||||
group_id: PositiveInt | None = None,
|
||||
latitude: float | None = None,
|
||||
longitude: float | None = None,
|
||||
caption: str | None = None,
|
||||
):
|
||||
"""
|
||||
https://dev.vk.com/ru/method/photos.saveWallPhoto
|
||||
"""
|
||||
data = {
|
||||
'server': server,
|
||||
'hash': _hash,
|
||||
'user_id': user_id,
|
||||
'group_id': group_id,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'caption': caption,
|
||||
'access_token': access_token,
|
||||
'photo': photo,
|
||||
'v': self.version,
|
||||
}
|
||||
|
||||
req = await self.post(
|
||||
'/photos.saveWallPhoto',
|
||||
data=data,
|
||||
)
|
||||
|
||||
if 'error' in req.json():
|
||||
self.logger.error(req.json())
|
||||
|
||||
return s.SaveWallPhotoResponse.model_validate(req.json())
|
||||
|
||||
async def groups_getById(
|
||||
self,
|
||||
access_token: str,
|
||||
group_ids: list[str] | None = None,
|
||||
group_id: str | None = None,
|
||||
fields: list[Literal['can_post']] | None = None,
|
||||
):
|
||||
"""
|
||||
https://dev.vk.com/ru/method/groups.getById
|
||||
"""
|
||||
req = await self.post(
|
||||
'/groups.getById',
|
||||
data={
|
||||
'group_id': group_id,
|
||||
'group_ids': ', '.join(group_ids) if group_ids else None,
|
||||
'fields': fields,
|
||||
'access_token': access_token,
|
||||
'v': self.version,
|
||||
},
|
||||
)
|
||||
|
||||
return [
|
||||
s.Group.model_validate(group)
|
||||
for group in req.json()['response']['groups']
|
||||
]
|
||||
|
||||
async def wall_post(
|
||||
self,
|
||||
access_token: str,
|
||||
owner_id: int | None = None,
|
||||
friends_only: Literal[0, 1] = 0,
|
||||
from_group: Literal[0, 1] = 0,
|
||||
message: str | None = None,
|
||||
attachments: list[str] | None = None,
|
||||
services: list[str] | None = None,
|
||||
signed: Literal[0, 1] = 0,
|
||||
publish_date: str | None = None,
|
||||
lat: int | None = None,
|
||||
long: int | None = None,
|
||||
place_id: int | None = None,
|
||||
post_id: int | None = None,
|
||||
guid: str | None = None,
|
||||
mark_as_ads: Literal[0, 1] = 0,
|
||||
link_title: str | None = None,
|
||||
link_photo_id: str | None = None,
|
||||
close_comments: Literal[0, 1] = 0,
|
||||
donut_paid_duration: int | None = None,
|
||||
mute_notifications: Literal[0, 1] | None = None,
|
||||
_copyright: str | None = None,
|
||||
):
|
||||
"""
|
||||
https://dev.vk.com/ru/method/wall.post
|
||||
"""
|
||||
req = await self.post(
|
||||
'/wall.post',
|
||||
data={
|
||||
'owner_id': owner_id,
|
||||
'friends_only': friends_only,
|
||||
'from_group': from_group,
|
||||
'message': message,
|
||||
'attachments': ','.join(attachments) if attachments else None,
|
||||
'services': ','.join(services) if services else None,
|
||||
'signed': signed,
|
||||
'publish_date': publish_date,
|
||||
'lat': lat,
|
||||
'long': long,
|
||||
'place_id': place_id,
|
||||
'post_id': post_id,
|
||||
'guid': guid,
|
||||
'mark_as_ads': mark_as_ads,
|
||||
'link_title': link_title,
|
||||
'link_photo_id': link_photo_id,
|
||||
'close_comments': close_comments,
|
||||
'donut_paid_duration': donut_paid_duration,
|
||||
'mute_notifications': mute_notifications,
|
||||
'copyright': _copyright,
|
||||
'access_token': access_token,
|
||||
'v': self.version,
|
||||
},
|
||||
)
|
||||
|
||||
if 'response' not in req.json():
|
||||
self.logger.error(req.json())
|
||||
|
||||
return s.WallPostResponse.model_validate(req.json()['response'])
|
||||
77
src/clients/vk/auth.py
Normal file
77
src/clients/vk/auth.py
Normal file
@ -0,0 +1,77 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
from hashlib import sha256
|
||||
from secrets import token_urlsafe
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from apps.vk.constants import SCOPES
|
||||
from clients.base.clients import AsyncClient
|
||||
from core.config import settings
|
||||
|
||||
from .schema import AuthModel
|
||||
|
||||
|
||||
class VKAuthClient(AsyncClient):
|
||||
def __init__(self):
|
||||
self.base_uri = 'https://id.vk.com'
|
||||
|
||||
super().__init__(base_url=self.base_uri)
|
||||
|
||||
def get_login_url(self):
|
||||
code_verifier = token_urlsafe(16)
|
||||
code_challenge = (
|
||||
urlsafe_b64encode(sha256(code_verifier.encode()).digest())
|
||||
.rstrip(b'=')
|
||||
.decode()
|
||||
)
|
||||
|
||||
scopes_str = ' '.join(SCOPES)
|
||||
|
||||
base_redirect_url = f'{self.base_uri}/authorize?'
|
||||
query = {
|
||||
'response_type': 'code',
|
||||
'client_id': settings.VK_CLIENT_ID,
|
||||
'code_challenge': code_challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'redirect_uri': settings.VK_REDIRECT_URI,
|
||||
'scope': scopes_str,
|
||||
'lang_id': 0,
|
||||
'scheme': 'dark',
|
||||
'state': code_verifier,
|
||||
}
|
||||
|
||||
return base_redirect_url + urlencode(query)
|
||||
|
||||
async def get_access_token(
|
||||
self, code: str, code_verifier: str, state: str, device_id: str
|
||||
):
|
||||
req = await self.post(
|
||||
'/oauth2/auth',
|
||||
json={
|
||||
'grant_type': 'authorization_code',
|
||||
'code_verifier': code_verifier,
|
||||
'redirect_uri': settings.VK_REDIRECT_URI,
|
||||
'code': code,
|
||||
'state': state,
|
||||
'client_id': settings.VK_CLIENT_ID,
|
||||
'device_id': device_id,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
return AuthModel.model_validate(req.json())
|
||||
except Exception:
|
||||
self.logger.error(req.json())
|
||||
raise
|
||||
|
||||
async def refresh_access_token(self, refresh_token: str, device_id: str):
|
||||
req = await self.post(
|
||||
'/oauth2/auth',
|
||||
json={
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refresh_token,
|
||||
'client_id': settings.VK_CLIENT_ID,
|
||||
'device_id': device_id,
|
||||
},
|
||||
)
|
||||
|
||||
return AuthModel.model_validate(req.json())
|
||||
81
src/clients/vk/schema.py
Normal file
81
src/clients/vk/schema.py
Normal file
@ -0,0 +1,81 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic.types import PositiveInt
|
||||
|
||||
|
||||
class AuthModel(BaseModel):
|
||||
refresh_token: str
|
||||
access_token: str
|
||||
id_token: str
|
||||
token_type: Literal['Bearer']
|
||||
expires_in: PositiveInt
|
||||
user_id: PositiveInt
|
||||
scope: str
|
||||
|
||||
|
||||
class RefreshModel(BaseModel):
|
||||
refresh_token: str
|
||||
access_token: str
|
||||
token_type: Literal['Bearer']
|
||||
expires_in: PositiveInt
|
||||
user_id: PositiveInt
|
||||
scope: str
|
||||
|
||||
|
||||
class City(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
|
||||
class ProfileInfo(BaseModel):
|
||||
id: int
|
||||
home_town: str
|
||||
status: str
|
||||
photo_200: str
|
||||
is_service_account: bool
|
||||
bdate: str
|
||||
verification_status: str
|
||||
promo_verifications: list[str]
|
||||
first_name: str
|
||||
last_name: str
|
||||
bdate_visibility: int
|
||||
city: City | None = None
|
||||
phone: str
|
||||
relation: int
|
||||
screen_name: str | None = None
|
||||
sex: int
|
||||
|
||||
|
||||
class Group(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
screen_name: str
|
||||
is_closed: int
|
||||
type: str
|
||||
photo_50: str
|
||||
photo_100: str
|
||||
photo_200: str
|
||||
|
||||
|
||||
class UploadServerResponse(BaseModel):
|
||||
upload_url: str
|
||||
album_id: int
|
||||
user_id: int
|
||||
|
||||
|
||||
class Photo(BaseModel):
|
||||
id: int
|
||||
album_id: int
|
||||
owner_id: int
|
||||
text: str
|
||||
date: datetime
|
||||
|
||||
|
||||
class SaveWallPhotoResponse(BaseModel):
|
||||
response: list[Photo]
|
||||
|
||||
|
||||
class WallPostResponse(BaseModel):
|
||||
post_id: int
|
||||
0
src/core/__init__.py
Normal file
0
src/core/__init__.py
Normal file
56
src/core/config.py
Normal file
56
src/core/config.py
Normal file
@ -0,0 +1,56 @@
|
||||
from os import environ
|
||||
from os.path import exists
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_extra_types.semantic_version import SemanticVersion
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def get_version():
|
||||
if exists('.version'):
|
||||
with open('.version', encoding='utf-8') as f:
|
||||
return SemanticVersion.parse(f.read().strip())
|
||||
|
||||
return SemanticVersion.parse('0.0.0')
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file='.env',
|
||||
validate_default=False,
|
||||
extra='ignore',
|
||||
)
|
||||
|
||||
# App info
|
||||
APP_NAME: str = 'VK Resender API'
|
||||
APP_DESCRIPTION: str = 'API for the VK Resender'
|
||||
APP_PORT: int = Field(default=9000)
|
||||
VERSION: SemanticVersion = Field(default_factory=get_version)
|
||||
DEBUG: bool = Field(default=False)
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = Field(default='secret')
|
||||
ALGORITHM: str = 'HS256'
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = Field(default='sqlite:///sql.db')
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = Field(default='redis://localhost:6379/0')
|
||||
|
||||
# Loki Logging
|
||||
LOKI_URL: str | None = Field(default=None)
|
||||
|
||||
# VK
|
||||
VK_CLIENT_ID: str = Field(default='')
|
||||
VK_REDIRECT_URI: str = Field(default='')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def celery_env(self):
|
||||
environ['CELERY_BROKER_URL'] = self.REDIS_URL
|
||||
environ['CELERY_RESULT_BACKEND'] = self.REDIS_URL
|
||||
|
||||
return self
|
||||
|
||||
|
||||
settings = Settings()
|
||||
54
src/core/exceptions.py
Normal file
54
src/core/exceptions.py
Normal file
@ -0,0 +1,54 @@
|
||||
from logging import getLogger
|
||||
|
||||
from fastapi import FastAPI, Request, Response, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import (
|
||||
RequestValidationError,
|
||||
WebSocketRequestValidationError,
|
||||
)
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from fastapi.utils import is_body_allowed_for_status_code
|
||||
from fastapi.websockets import WebSocket
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger_format = '%s: %s'
|
||||
|
||||
|
||||
def register_exceptions(app: FastAPI):
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException): # type: ignore
|
||||
headers = getattr(exc, 'headers', None)
|
||||
if not is_body_allowed_for_status_code(exc.status_code):
|
||||
return Response(status_code=exc.status_code, headers=headers)
|
||||
return ORJSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={'detail': exc.detail},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler( # type: ignore
|
||||
request: Request,
|
||||
exc: RequestValidationError,
|
||||
):
|
||||
logger.warning(logger_format, 'Validation Error', exc.body)
|
||||
return ORJSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content=jsonable_encoder({'detail': exc.errors()}),
|
||||
)
|
||||
|
||||
@app.exception_handler(WebSocketRequestValidationError)
|
||||
async def websocket_validation_exception_handler( # type: ignore
|
||||
websocket: WebSocket,
|
||||
exc: WebSocketRequestValidationError,
|
||||
):
|
||||
logger.warning(
|
||||
logger_format, 'WebSocket Validation Error', exc.errors()
|
||||
)
|
||||
return await websocket.close(
|
||||
code=status.WS_1008_POLICY_VIOLATION,
|
||||
reason=jsonable_encoder(exc.errors()),
|
||||
)
|
||||
|
||||
return app
|
||||
67
src/core/log.py
Normal file
67
src/core/log.py
Normal file
@ -0,0 +1,67 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from logging_loki import LokiHandler as Loki # type: ignore
|
||||
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class LokiHandler(Loki):
|
||||
def __init__(self):
|
||||
if not settings.LOKI_URL:
|
||||
msg = 'LOKI_URL is not set'
|
||||
raise ValueError(msg)
|
||||
|
||||
super().__init__( # type: ignore
|
||||
settings.LOKI_URL,
|
||||
tags={
|
||||
'application': settings.APP_NAME,
|
||||
'version': str(settings.VERSION),
|
||||
},
|
||||
version='1',
|
||||
)
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self):
|
||||
self.version = 1
|
||||
self.disable_existing_loggers = False
|
||||
self.handlers = self._get_handlers()
|
||||
self.loggers = self._get_loggers()
|
||||
|
||||
@staticmethod
|
||||
def _get_handlers():
|
||||
handlers: dict[str, Any] = {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'level': logging.INFO,
|
||||
'stream': 'ext://sys.stderr',
|
||||
}
|
||||
}
|
||||
|
||||
if settings.LOKI_URL:
|
||||
handlers['loki'] = {'class': LokiHandler}
|
||||
|
||||
return handlers
|
||||
|
||||
def _get_loggers(self):
|
||||
loggers = {
|
||||
'': {
|
||||
'level': logging.INFO,
|
||||
'handlers': list(self.handlers.keys()),
|
||||
'propagate': False,
|
||||
},
|
||||
}
|
||||
|
||||
return loggers
|
||||
|
||||
def render(self):
|
||||
return {
|
||||
'version': self.version,
|
||||
'disable_existing_loggers': self.disable_existing_loggers,
|
||||
'handlers': self.handlers,
|
||||
'loggers': self.loggers,
|
||||
}
|
||||
|
||||
|
||||
config = Config().render()
|
||||
28
src/core/main.py
Normal file
28
src/core/main.py
Normal file
@ -0,0 +1,28 @@
|
||||
from logging import getLogger
|
||||
|
||||
from database import lifespan
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from middlewares import register_middlewares
|
||||
|
||||
from .config import settings
|
||||
from .exceptions import register_exceptions
|
||||
from .routers.v1 import router as v1_router
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
debug=settings.DEBUG,
|
||||
title=settings.APP_NAME,
|
||||
description=settings.APP_DESCRIPTION,
|
||||
version=str(settings.VERSION),
|
||||
openapi_url=None,
|
||||
default_response_class=ORJSONResponse,
|
||||
lifespan=lifespan,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app = register_middlewares(app)
|
||||
app = register_exceptions(app)
|
||||
app.include_router(v1_router)
|
||||
32
src/core/routers/__init__.py
Normal file
32
src/core/routers/__init__.py
Normal file
@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import ORJSONResponse
|
||||
|
||||
from core.config import settings
|
||||
|
||||
|
||||
def get_openapi_schema(router: APIRouter):
|
||||
if not settings.DEBUG:
|
||||
return None
|
||||
|
||||
return ORJSONResponse(
|
||||
get_openapi(
|
||||
title=settings.APP_NAME,
|
||||
version=str(settings.VERSION),
|
||||
description=settings.APP_DESCRIPTION,
|
||||
routes=router.routes,
|
||||
servers=[],
|
||||
contact={'Miwory': 'admin@meowly.ru'},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_swagger_html(router: APIRouter):
|
||||
if not settings.DEBUG:
|
||||
return None
|
||||
|
||||
return get_swagger_ui_html(
|
||||
openapi_url=f'{router.prefix}/openapi.json',
|
||||
title='Docs',
|
||||
)
|
||||
24
src/core/routers/v1.py
Normal file
24
src/core/routers/v1.py
Normal file
@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from . import get_openapi_schema, get_swagger_html
|
||||
|
||||
router = APIRouter(prefix='/v1')
|
||||
|
||||
openapi_schema = get_openapi_schema(router)
|
||||
swagger_ui_html = get_swagger_html(router)
|
||||
|
||||
|
||||
@router.get('/openapi.json', include_in_schema=False)
|
||||
async def openapi():
|
||||
if openapi_schema is None:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return openapi_schema
|
||||
|
||||
|
||||
@router.get('/docs', include_in_schema=False)
|
||||
async def docs():
|
||||
if swagger_ui_html is None:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return swagger_ui_html
|
||||
28
src/core/worker.py
Normal file
28
src/core/worker.py
Normal file
@ -0,0 +1,28 @@
|
||||
from logging.config import dictConfig
|
||||
from typing import Any
|
||||
|
||||
from celery import Celery
|
||||
from celery.signals import setup_logging
|
||||
|
||||
from .config import settings
|
||||
from .log import config as log_config
|
||||
|
||||
celery = Celery(__name__)
|
||||
|
||||
celery.conf.update(
|
||||
timezone='UTC',
|
||||
broker_url=settings.REDIS_URL,
|
||||
result_backend=settings.REDIS_URL,
|
||||
broker_connection_retry_on_startup=True,
|
||||
broker_transport_options={
|
||||
'visibility_timeout': 3600,
|
||||
'retry_policy': {'timeout': 5},
|
||||
},
|
||||
worker_hijack_root_logger=False,
|
||||
beat_schedule={},
|
||||
)
|
||||
|
||||
|
||||
@setup_logging.connect
|
||||
def setup_loggers(*args: Any, **kwargs: Any):
|
||||
dictConfig(log_config)
|
||||
34
src/database/__init__.py
Normal file
34
src/database/__init__.py
Normal file
@ -0,0 +1,34 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from logging import getLogger
|
||||
from typing import Annotated
|
||||
|
||||
from alembic.command import upgrade
|
||||
from alembic.config import Config
|
||||
from fastapi import Depends, FastAPI
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Session
|
||||
|
||||
from core.config import settings
|
||||
from database.manager import DBManager
|
||||
|
||||
logger = getLogger(__name__)
|
||||
db_manager = DBManager(settings.DATABASE_URL)
|
||||
|
||||
SyncSessionDep = Annotated[Session, Depends(db_manager.sync_session)]
|
||||
AsyncSessionDep = Annotated[
|
||||
AsyncSession,
|
||||
Depends(db_manager.async_session),
|
||||
]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
log_format = '%s: %s'
|
||||
logger.info(log_format, 'App Name', settings.APP_NAME)
|
||||
logger.info(log_format, 'App Description', settings.APP_DESCRIPTION)
|
||||
logger.info(log_format, 'App Version', settings.VERSION)
|
||||
|
||||
config = Config('alembic.ini')
|
||||
upgrade(config, 'head')
|
||||
|
||||
yield
|
||||
41
src/database/manager.py
Normal file
41
src/database/manager.py
Normal file
@ -0,0 +1,41 @@
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlmodel import Session, create_engine
|
||||
|
||||
|
||||
class DBManager:
|
||||
sync_url = ''
|
||||
async_url = ''
|
||||
|
||||
def __init__(self, database_url: str):
|
||||
self.sync_url, self.async_url = self._initialize_urls(database_url)
|
||||
|
||||
self.sync_engine = create_engine(self.sync_url)
|
||||
self.async_engine = create_async_engine(self.async_url)
|
||||
|
||||
def _initialize_urls(self, database_url: str):
|
||||
url_parts = database_url.split('://')
|
||||
|
||||
return (
|
||||
f'postgresql+psycopg://{url_parts[1]}',
|
||||
f'postgresql+asyncpg://{url_parts[1]}',
|
||||
)
|
||||
|
||||
def sync_session(self):
|
||||
with Session(self.sync_engine) as session:
|
||||
yield session
|
||||
|
||||
async def async_session(self):
|
||||
async with AsyncSession(self.async_engine) as session:
|
||||
yield session
|
||||
|
||||
@contextmanager
|
||||
def sync_context_session(self):
|
||||
with Session(self.sync_engine) as session:
|
||||
yield session
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_context_session(self):
|
||||
async with AsyncSession(self.async_engine) as session:
|
||||
yield session
|
||||
16
src/gunicorn.conf.py
Normal file
16
src/gunicorn.conf.py
Normal file
@ -0,0 +1,16 @@
|
||||
import multiprocessing
|
||||
|
||||
from core.config import settings
|
||||
from core.log import config as log_config
|
||||
|
||||
wsgi_app = 'core.main:app'
|
||||
bind = f'0.0.0.0:{settings.APP_PORT}'
|
||||
workers = 1 if settings.DEBUG else multiprocessing.cpu_count() * 2 + 1
|
||||
worker_class = 'uvicorn_worker.UvicornWorker'
|
||||
logconfig_dict = log_config
|
||||
threads = 2
|
||||
max_requests = 10000
|
||||
max_requests_jitter = 2000
|
||||
timeout = 30
|
||||
keepalive = 2
|
||||
preload_app = True
|
||||
20
src/middlewares/__init__.py
Normal file
20
src/middlewares/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
from logging import getLogger
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .access_log_middleware import AccessLogMiddleware
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
def register_middlewares(app: FastAPI):
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=['*'],
|
||||
allow_methods=['*'],
|
||||
allow_headers=['*'],
|
||||
)
|
||||
|
||||
return app
|
||||
108
src/middlewares/access_log_middleware.py
Normal file
108
src/middlewares/access_log_middleware.py
Normal file
@ -0,0 +1,108 @@
|
||||
from logging import getLogger
|
||||
from re import findall
|
||||
from time import perf_counter
|
||||
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from core.config import settings
|
||||
|
||||
LOCALHOST = '127.0.0.1'
|
||||
BROWSERS = {
|
||||
'firefox': 'Firefox',
|
||||
'yabrowser': 'Yandex',
|
||||
'samsungbrowser': 'Samsung Internet',
|
||||
'trident': 'Internet Explorer',
|
||||
'opera': 'Opera',
|
||||
'vivaldi': 'Vivaldi',
|
||||
'brave': 'Brave',
|
||||
'edg': 'Edge',
|
||||
'chrome': 'Chrome',
|
||||
'safari': 'Safari',
|
||||
'chromium': 'Chromium',
|
||||
'msie': 'Internet Explorer',
|
||||
}
|
||||
|
||||
|
||||
class AccessLogMiddleware:
|
||||
def __init__(self, app: ASGIApp):
|
||||
self.app = app
|
||||
self.logger = getLogger(__name__)
|
||||
|
||||
self.version = (
|
||||
b'Version',
|
||||
f'{settings.VERSION}'.encode(),
|
||||
)
|
||||
|
||||
async def detect_browser(self, headers: dict[bytes, bytes]):
|
||||
if b'user-agent' not in headers:
|
||||
return 'unknown'
|
||||
|
||||
user_agent = headers[b'user-agent'].decode().lower()
|
||||
|
||||
for k, v in BROWSERS.items():
|
||||
if findall(k, user_agent):
|
||||
return v
|
||||
|
||||
return 'unknown'
|
||||
|
||||
@staticmethod
|
||||
async def get_client_ip(
|
||||
headers: dict[bytes, bytes],
|
||||
default_ip: str = LOCALHOST,
|
||||
):
|
||||
if b'x-forwarded-for' not in headers:
|
||||
return default_ip
|
||||
|
||||
ips = headers[b'x-forwarded-for'].decode().split(',')
|
||||
|
||||
if len(ips) > 1:
|
||||
return ips[-1].strip()
|
||||
|
||||
return ips[0]
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
if scope['type'] != 'http':
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
start_time = perf_counter()
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
if message['type'] != 'http.response.start':
|
||||
return await send(message)
|
||||
|
||||
headers = dict(scope.get('headers', []))
|
||||
|
||||
client_ip = await self.get_client_ip(headers, scope['client'][0])
|
||||
browser = await self.detect_browser(headers)
|
||||
|
||||
response_time = (perf_counter() - start_time) * 1000
|
||||
response_data = f'dur={response_time:.2f}'
|
||||
response = (
|
||||
b'Server-Timing',
|
||||
f'resp;{response_data};desc="Response Time"'.encode(),
|
||||
)
|
||||
|
||||
message['headers'] = message['headers'] + [response, self.version]
|
||||
|
||||
self.logger.info(
|
||||
'%s - %s %s %d [%0.2fms]',
|
||||
client_ip,
|
||||
scope['method'],
|
||||
scope['path'],
|
||||
message['status'],
|
||||
response_time,
|
||||
extra={
|
||||
'tags': {
|
||||
'method': scope['method'],
|
||||
'path': scope['path'],
|
||||
'status': message['status'],
|
||||
'response_time': response_time,
|
||||
'client_ip': client_ip,
|
||||
'browser': browser,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await send(message)
|
||||
|
||||
return await self.app(scope, receive, send_wrapper)
|
||||
1
src/migrations/README
Normal file
1
src/migrations/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
0
src/migrations/__init__.py
Normal file
0
src/migrations/__init__.py
Normal file
54
src/migrations/env.py
Normal file
54
src/migrations/env.py
Normal file
@ -0,0 +1,54 @@
|
||||
from logging.config import dictConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from apps.vk.models import * # noqa: F403
|
||||
from core.log import config as log_config
|
||||
from database import db_manager
|
||||
|
||||
dictConfig(log_config)
|
||||
|
||||
|
||||
config = context.config
|
||||
url = db_manager.sync_url
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={'paramstyle': 'named'},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
cfg = config.get_section(config.config_ini_section, {})
|
||||
cfg['sqlalchemy.url'] = url
|
||||
connectable = engine_from_config(
|
||||
cfg,
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
32
src/migrations/script.py.mako
Normal file
32
src/migrations/script.py.mako
Normal file
@ -0,0 +1,32 @@
|
||||
"""
|
||||
${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
if not sqlmodel.sql:
|
||||
msg = 'Something went wrong'
|
||||
raise Exception(msg)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
70
src/migrations/versions/2025.06.08_15-39-46_5afca0f35b4c.py
Normal file
70
src/migrations/versions/2025.06.08_15-39-46_5afca0f35b4c.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""
|
||||
empty message
|
||||
|
||||
Revision ID: 5afca0f35b4c
|
||||
Revises:
|
||||
Create Date: 2025-06-08 15:39:46.562682
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5afca0f35b4c'
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
if not sqlmodel.sql:
|
||||
msg = 'Something went wrong'
|
||||
raise Exception(msg)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
'vk_users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
'first_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||
),
|
||||
sa.Column(
|
||||
'last_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||
),
|
||||
sa.Column(
|
||||
'screen_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_table(
|
||||
'vk_tokens',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
'access_token', sqlmodel.sql.sqltypes.AutoString(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
'refresh_token', sqlmodel.sql.sqltypes.AutoString(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
'device_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False
|
||||
),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'],
|
||||
['vk_users.id'],
|
||||
),
|
||||
sa.PrimaryKeyConstraint('user_id'),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('vk_tokens')
|
||||
op.drop_table('vk_users')
|
||||
# ### end Alembic commands ###
|
||||
0
src/migrations/versions/__init__.py
Normal file
0
src/migrations/versions/__init__.py
Normal file
22
src/server.py
Normal file
22
src/server.py
Normal file
@ -0,0 +1,22 @@
|
||||
from uvicorn import Config, Server
|
||||
|
||||
from core.config import settings
|
||||
from core.log import config as log_config
|
||||
|
||||
|
||||
def main():
|
||||
config = Config(
|
||||
'core.main:app',
|
||||
host='0.0.0.0',
|
||||
port=settings.APP_PORT,
|
||||
log_config=log_config,
|
||||
log_level='info',
|
||||
reload=settings.DEBUG,
|
||||
access_log=False,
|
||||
)
|
||||
server = Server(config)
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
src/shared/__init__.py
Normal file
0
src/shared/__init__.py
Normal file
12
src/shared/redis.py
Normal file
12
src/shared/redis.py
Normal file
@ -0,0 +1,12 @@
|
||||
from redis import Redis
|
||||
|
||||
from core.config import settings
|
||||
|
||||
client = Redis.from_url(settings.REDIS_URL)
|
||||
|
||||
|
||||
def clear_cache(key: str):
|
||||
with client.pipeline() as pipe:
|
||||
keys = list(client.scan_iter(key))
|
||||
if keys:
|
||||
pipe.delete(*keys).execute()
|
||||
1
userlist.txt
Normal file
1
userlist.txt
Normal file
@ -0,0 +1 @@
|
||||
"postgres" "example"
|
||||
Reference in New Issue
Block a user