From cabcf837f91b3e9459c035bc968dcefc57f8f99e Mon Sep 17 00:00:00 2001 From: abdulhade Date: Sat, 22 Feb 2025 13:42:10 +0300 Subject: [PATCH] Created basic backend structure, auth and CRUD endpoints. --- .gitignore | 3 ++ app/connections.py | 75 +++++++++++++++++++++++++++++++++++++++++++ app/users.py | 76 ++++++++++++++++++++++++++++++++++++++++++++ core/dependencies.py | 40 +++++++++++++++++++++++ core/enums.py | 9 ++++++ core/exceptions.py | 19 +++++++++++ core/scripts.py | 44 +++++++++++++++++++++++++ data/crud.py | 62 ++++++++++++++++++++++++++++++++++++ data/db.py | 22 +++++++++++++ data/models.py | 29 +++++++++++++++++ data/schemas.py | 59 ++++++++++++++++++++++++++++++++++ dbs/mysql.py | 2 ++ main.py | 27 +++++++++++++++- requirements.txt | 6 ++-- 14 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 app/connections.py create mode 100644 app/users.py create mode 100644 core/dependencies.py create mode 100644 core/enums.py create mode 100644 core/exceptions.py create mode 100644 core/scripts.py create mode 100644 data/crud.py create mode 100644 data/db.py create mode 100644 data/models.py create mode 100644 data/schemas.py create mode 100644 dbs/mysql.py diff --git a/.gitignore b/.gitignore index 0062a8c..49eb7dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .devcontainer venv/ +.vscode/ +files/* +__pycache__/ \ No newline at end of file diff --git a/app/connections.py b/app/connections.py new file mode 100644 index 0000000..3a790b3 --- /dev/null +++ b/app/connections.py @@ -0,0 +1,75 @@ +from fastapi.routing import APIRouter +from data.schemas import Connection, ConnectionCreate, ConnectionUpdate +from fastapi import Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from data.crud import ( + read_connection, + read_all_connections, + create_connection, + update_connection, + delete_connection, +) +from core.dependencies import get_db, get_current_user, get_admin_user + +connections_router = APIRouter() + + +@connections_router.post("/", status_code=status.HTTP_201_CREATED) +async def create_connection_endpoint( + connection: ConnectionCreate, + db: AsyncSession = Depends(get_db), + admin=Depends(get_admin_user), +) -> Connection: + return await create_connection(db=db, connection=connection, user_id=admin.id) + + +@connections_router.get( + "/", + response_model=list[Connection], + dependencies=[Depends(get_current_user)], +) +async def read_connections_endpoint( + db: AsyncSession = Depends(get_db), +): + db_connection = await read_all_connections(db) + return db_connection + + +@connections_router.get( + "/{connection_id}", + response_model=Connection, + dependencies=[Depends(get_current_user)], +) +async def read_connection_endpoint(connection_id: int, db: AsyncSession = Depends(get_db)): + db_connection = await read_connection(db, connection_id) + if db_connection is None: + raise HTTPException(status_code=404, detail="Connection not found") + return db_connection + + +@connections_router.put( + "/{connection_id}", + response_model=Connection, + dependencies=[Depends(get_admin_user)], +) +async def update_connection_endpoint( + connection_id: int, connection: ConnectionUpdate, db: AsyncSession = Depends(get_db) +): + db_connection = await update_connection( + db=db, connection_id=connection_id, connection=connection + ) + if db_connection is None: + raise HTTPException(status_code=404, detail="Connection not found") + return db_connection + + +@connections_router.delete( + "/{connection_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(get_admin_user)], +) +async def delete_connection_endpoint(connection_id: int, db: AsyncSession = Depends(get_db)): + db_connection = await delete_connection(db=db, connection_id=connection_id) + if db_connection is None: + raise HTTPException(status_code=404, detail="Connection not found") + return None diff --git a/app/users.py b/app/users.py new file mode 100644 index 0000000..1af92b4 --- /dev/null +++ b/app/users.py @@ -0,0 +1,76 @@ +from fastapi.routing import APIRouter +from data.schemas import UserOut, UserInDBBase, UserCreate +from data.models import UserRole +from fastapi import FastAPI, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from data.crud import read_all_users, read_user, create_user, delete_user +from core.dependencies import get_db, get_current_user, get_admin_user +from sqlalchemy.exc import IntegrityError +from core.exceptions import ObjectNotFoundInDB, UserNotFound +from core.scripts import create_secret + +users_router = APIRouter() + + +@users_router.get("/me") +async def get_me(user=Depends(get_current_user)) -> UserOut: + return user + + +@users_router.get( + "/", + dependencies=[Depends(get_current_user)], +) +async def get_all_users_endpoint(db=Depends(get_db)) -> list[UserOut]: + return await read_all_users(db=db) + + +@users_router.post( + "/", dependencies=[Depends(get_current_user)], status_code=status.HTTP_201_CREATED +) +async def create_user_endpoint( + user_create: UserCreate, db=Depends(get_db) +) -> UserInDBBase: + try: + return await create_user(user=user_create, db=db) + except IntegrityError: + raise HTTPException( + status_code=400, + detail={ + "message": "This username is already taken.", + "code": "duplicated-username", + }, + ) + +@users_router.post('/update-my-api_key/', status_code=status.HTTP_204_NO_CONTENT) +async def update_user_own_api_key(user=Depends(get_current_user), db=Depends(get_db)): + if user.role == UserRole.admin: + raise HTTPException(status_code=400, detail={ + 'message': 'Admins can\'t use this endpoint to update their API key.', + 'code': 'admin-not-allowed' + }) + user.api_key = create_secret() + db.add(user) + await db.commit() + await db.refresh(user) + +@users_router.post('/update-user-api_key/', status_code=status.HTTP_202_ACCEPTED, dependencies=[Depends(get_admin_user)]) +async def update_user_own_api_key(user_id:int, db=Depends(get_db)) -> UserInDBBase: + user = await read_user(db=db, user_id=user_id) + if user is None: + raise UserNotFound() + user.api_key = create_secret() + db.add(user) + await db.commit() + await db.refresh(user) + return user + +@users_router.delete( + "/", dependencies=[Depends(get_admin_user)], status_code=status.HTTP_204_NO_CONTENT +) +async def delete_user_endpoint(user_id: int, db=Depends(get_db)): + try: + await delete_user(db=db, user_id=user_id) + return None + except ObjectNotFoundInDB: + raise UserNotFound() diff --git a/core/dependencies.py b/core/dependencies.py new file mode 100644 index 0000000..e5f6683 --- /dev/null +++ b/core/dependencies.py @@ -0,0 +1,40 @@ +# dependencies.py +from fastapi import Depends, HTTPException, status, Security + +from fastapi.security import APIKeyHeader +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from data.db import SessionLocal +from data.models import User, UserRole +from pydantic import BaseModel + +# class UserInDB(User): +# hashed_password: str + +async def get_db(): + async with SessionLocal() as session: + yield session + +API_KEY_NAME = "Authorization" +api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) + +async def get_current_user(db: AsyncSession = Depends(get_db), api_key:str = Security(api_key_header)): + if api_key_header is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="API key missing" + ) + + if not api_key: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="API key missing, provide it in the header value [Authorization]" + ) + user = await db.execute(select(User).filter(User.api_key == api_key)) + user = user.scalars().first() + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + return user + +async def get_admin_user(current_user: User = Depends(get_current_user)): + if current_user.role != UserRole.admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions") + return current_user diff --git a/core/enums.py b/core/enums.py new file mode 100644 index 0000000..ce2ce26 --- /dev/null +++ b/core/enums.py @@ -0,0 +1,9 @@ +import enum + +class ConnectionTypes(str, enum.Enum): + mysql = "mysql" + postgresql = 'postgresql' + +class UserRole(enum.Enum): + admin = "admin" + user = "user" \ No newline at end of file diff --git a/core/exceptions.py b/core/exceptions.py new file mode 100644 index 0000000..ed7c453 --- /dev/null +++ b/core/exceptions.py @@ -0,0 +1,19 @@ +from fastapi import HTTPException + + +class ObjectNotFoundInDB(Exception): + def __init__(self, *args): + super().__init__(*args) + + +class UserNotFound(HTTPException): + def __init__( + self, + status_code=404, + detail={ + "message": "Didn't find a user with the provided id.", + "code": "user-not-found", + }, + headers=None, + ): + super().__init__(status_code, detail, headers) diff --git a/core/scripts.py b/core/scripts.py new file mode 100644 index 0000000..6b6e9a1 --- /dev/null +++ b/core/scripts.py @@ -0,0 +1,44 @@ +# add_user.py +import asyncio +import secrets +from sqlalchemy.future import select +from sqlalchemy.exc import IntegrityError +from getpass import getpass +from data.db import engine, SessionLocal +from data.models import Base, User, UserRole + +def create_secret(): + return secrets.token_hex(32) + +async def create_user(): + + async with SessionLocal() as session: + username = input("Enter username: ").strip() + role_input = input("Enter role (admin/user): ").strip().lower() + print('\n') + if role_input not in UserRole._value2member_map_: + print("> Invalid role. Please enter 'admin' or 'user'.") + return + + role = UserRole(role_input) + + # Check if username already exists + result = await session.execute(select(User).filter_by(username=username)) + existing_user = result.scalars().first() + if existing_user: + print(f"> Username '{username}' is already taken.") + return + + # Create new user + api_key = create_secret() + new_user = User(username=username, role=role, api_key=api_key) + session.add(new_user) + await session.commit() + await session.refresh(new_user) + + + print(f"> User '{username}' with role '{role.value}' created successfully.") + print(f"> API Key: {api_key}") + +if __name__ == "__main__": + asyncio.run(create_user()) diff --git a/data/crud.py b/data/crud.py new file mode 100644 index 0000000..5f2641b --- /dev/null +++ b/data/crud.py @@ -0,0 +1,62 @@ +# crud.py +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from data.models import Connection, User +from data.schemas import ConnectionCreate, ConnectionUpdate, UserCreate +from core.exceptions import ObjectNotFoundInDB + +async def read_user(db:AsyncSession, user_id:int): + result = await db.execute(select(User).filter(User.id == user_id)) + return result.scalars().first() + +async def read_all_users(db:AsyncSession): + result = await db.execute(select(User)) + return result.scalars().all() + +async def create_user(db: AsyncSession, user: UserCreate): + from core.scripts import create_secret + db_user = User(**user.model_dump(), api_key=create_secret()) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + return db_user + +async def delete_user(db: AsyncSession, user_id: int): + db_user = await read_user(db, user_id) + if db_user: + await db.delete(db_user) + await db.commit() + return db_user + else: + raise ObjectNotFoundInDB + +async def read_connection(db: AsyncSession, connection_id: int): + result = await db.execute(select(Connection).filter(Connection.id == connection_id)) + return result.scalars().first() + +async def read_all_connections(db: AsyncSession): + result = await db.execute(select(Connection)) + return result.scalars().all() + +async def create_connection(db: AsyncSession, connection: ConnectionCreate, user_id: int): + db_connection = Connection(**connection.model_dump(), owner_id=user_id) + db.add(db_connection) + await db.commit() + await db.refresh(db_connection) + return db_connection + +async def update_connection(db: AsyncSession, connection_id: int, connection: ConnectionUpdate): + db_connection = await read_connection(db, connection_id) + if db_connection: + for key, value in connection.model_dump().items(): + setattr(db_connection, key, value) + await db.commit() + await db.refresh(db_connection) + return db_connection + +async def delete_connection(db: AsyncSession, connection_id: int): + db_connection = await read_connection(db, connection_id) + if db_connection: + await db.delete(db_connection) + await db.commit() + return db_connection diff --git a/data/db.py b/data/db.py new file mode 100644 index 0000000..e958bae --- /dev/null +++ b/data/db.py @@ -0,0 +1,22 @@ + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +DATABASE_URL = "sqlite+aiosqlite:///files/db.sqlite" + +engine = create_async_engine(DATABASE_URL, echo=False) +SessionLocal = sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, +) + +Base = declarative_base() + + +import logging + +logging.basicConfig() +logging.getLogger('sqlalchemy').setLevel(logging.WARNING) + +logging.getLogger('sqlalchemy.engine').disabled = True \ No newline at end of file diff --git a/data/models.py b/data/models.py new file mode 100644 index 0000000..a707aaa --- /dev/null +++ b/data/models.py @@ -0,0 +1,29 @@ +# models.py +from sqlalchemy import Column, Integer, String, Enum, ForeignKey +from sqlalchemy.orm import relationship +from data.db import Base +from core.enums import ConnectionTypes, UserRole + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, unique=True, index=True, nullable=False) + role = Column(Enum(UserRole), default=UserRole.user, nullable=False) + api_key = Column(String, unique=True, nullable=False) + + # repos = relationship("Connection", back_populates="owner") + +class Connection(Base): + __tablename__ = "connections" + + id = Column(Integer, primary_key=True, index=True) + db_name = Column(String, nullable=False) + type = Column(Enum(ConnectionTypes), nullable=False) + host = Column(String) + port = Column(Integer) + username = Column(String) + password = Column(String) + owner_id = Column(Integer, ForeignKey("users.id")) + + # owner = relationship("User", back_populates="connections") diff --git a/data/schemas.py b/data/schemas.py new file mode 100644 index 0000000..a713775 --- /dev/null +++ b/data/schemas.py @@ -0,0 +1,59 @@ +# schemas.py +from pydantic import BaseModel +from typing import Optional +from core.enums import ConnectionTypes, UserRole + + +class ConnectionBase(BaseModel): + db_name: str + type: ConnectionTypes + host: str + port: int + username: str + password: str + + +class ConnectionCreate(ConnectionBase): + pass + + +class ConnectionUpdate(ConnectionBase): + pass + + +class ConnectionInDBBase(ConnectionBase): + id: int + owner_id: int + + class Config: + from_attributes = True + + +class Connection(ConnectionInDBBase): + pass + + +class UserBase(BaseModel): + username: str + + +class UserCreate(UserBase): + role: UserRole + + +class UserOut(UserBase): + id: int + role: UserRole + + +class UserInDBBase(UserBase): + id: int + role: UserRole + api_key: str + + class Config: + from_attributes = True + + +class User(UserInDBBase): + pass diff --git a/dbs/mysql.py b/dbs/mysql.py new file mode 100644 index 0000000..83d2f9b --- /dev/null +++ b/dbs/mysql.py @@ -0,0 +1,2 @@ +import aiomysql + diff --git a/main.py b/main.py index 9ca187c..2cc1efc 100644 --- a/main.py +++ b/main.py @@ -1 +1,26 @@ -print(__file__) \ No newline at end of file +from fastapi import FastAPI +from data.db import engine, Base + +from app.connections import connections_router +from app.users import users_router + +app = FastAPI() + +app.include_router(router=users_router, prefix="/users", tags=["Users"]) +app.include_router( + router=connections_router, prefix="/connections", tags=["Connections"] +) + + +# @app.on_event("startup") +async def startup(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +# import asyncio +# asyncio.run(startup()) + +# import uvicorn + +# uvicorn.run(app=app) diff --git a/requirements.txt b/requirements.txt index c4308d5..393f7e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ -fastapi +fastapi[all] uvicorn aiomysql websockets pydantic -python-dotenv \ No newline at end of file +python-dotenv +aiosqlite +sqlalchemy \ No newline at end of file