import io from typing import Any from fastapi import FastAPI, status, Header, UploadFile, Response from starlette.responses import StreamingResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware from based import db import psycopg from secrets import token_hex from minio import Minio from minio.helpers import ObjectWriteResult from urllib3 import HTTPResponse import uvicorn from dba import * from models import ( AuthModel, ColumnConditionCompat, CreateUserDefinition, TableDefinition, UserUpdateDefinition, OkResponse, ErrorResponse, AccessTokenResponse, CreateAssetResponse, ) from utils import ( check_if_admin_access_token, parse_columns_from_definition, ) conninfo = "postgresql://postgres:asarch6122@localhost" connector = db.DBConnector(conninfo) bootstrapDB(connector) BUCKET_NAME = "tuuli-files" minioClient = Minio( "localhost:8090", access_key="mxR0F5PK8CpCM8SA", secret_key="yFJsG70xLU3BiIMslinz6dhqKHqNpUc6", secure=False, ) found = minioClient.bucket_exists(BUCKET_NAME) if found: print(f"Bucket '{BUCKET_NAME}' already exists") else: minioClient.make_bucket(BUCKET_NAME) app = FastAPI( title="Tuuli API", description="Tuuli API for Tuuli frontend\n\nUse `c2316f9686e7a764688b8c1b4c60c5a088b07a3c23a2f6b7c25915a5118d7acc` as access token to test the API", version="0.1.0", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.post( "/api/getAccessToken", name="Get access token", responses={ 200: {"model": AccessTokenResponse, "description": "Successful response"}, 401: {"model": ErrorResponse, "description": "User not found"}, }, ) async def getAccessToken(userData: AuthModel): user = check_user(connector, userData.username, userData.password) if not user: return JSONResponse( ErrorResponse(error="Wrong username or password").dict(), status_code=status.HTTP_401_UNAUTHORIZED, ) return AccessTokenResponse(access_token=user.access_token) @app.get( "/api/listTables", name="List tables", responses={ 200: {"model": list[TableDefinition], "description": "List of tables"}, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires admin-level user access token", }, }, ) async def listTables(access_token: str | None = Header(default=None)): is_admin = check_if_admin_access_token(connector, access_token) if not is_admin: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) return [TableDefinition.parse_obj(table) for table in connector.tables()] @app.post( "/api/createTable/{tableName}", name="Create table", responses={ 200: {"model": OkResponse, "description": "Table created successfully"}, 400: { "model": ErrorResponse, "description": "Some generic error happened during table creation", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires admin-level user access token", }, 409: { "model": ErrorResponse, "description": "Table with this name already exists", }, }, ) async def createTable( tableName: str, columns: list[str], access_token: str | None = Header(default=None), ): """ Parameter `columns` should be a list of strings Each string should be in a following format: `column_name:column_type[:column_options]` Where *column_type* should be one of the following: - serial:primary - str - bool - datetime - float - int - int-asset - int-user Also *column_options* can be one of the following: - unique - default Example: ```json [ "id:serial:primary", "name:str:unique", "description:str", "is_active:bool", "price:float", "quantity:int", "creator_id:int-user", "asset_id:int-asset" ] ``` Notes: 1. you cannot use *unique* and *default* at the same time 2. in current implementation you cannot use *default*, because there is no way to specify default value """ is_admin = check_if_admin_access_token(connector, access_token) if not is_admin: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) try: columnsDefinition = parse_columns_from_definition(",".join(columns)) ok, e = create_table(connector, tableName, columnsDefinition) if not ok: if e: raise e raise Exception("Unknown error") except psycopg.errors.UniqueViolation: return JSONResponse( ErrorResponse(error="Table already exists").dict(), status_code=status.HTTP_409_CONFLICT, ) except Exception as e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST ) return OkResponse() @app.post( "/api/dropTable/{tableName}", name="Drop table", responses={ 200: {"model": OkResponse, "description": "Table dropped successfully"}, 400: { "model": ErrorResponse, "description": "Some generic error happened during table creation", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires admin-level user access token", }, }, ) async def dropTable( tableName: str, access_token: str | None = Header(default=None), ): is_admin = check_if_admin_access_token(connector, access_token) if not is_admin: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) try: ok, e = drop_table(connector, tableName) if not ok: if e: raise e raise Exception("Unknown error") except Exception as e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST ) return OkResponse() @app.post( "/api/users/+", name="Create user", responses={ 200: {"model": OkResponse, "description": "Table dropped successfully"}, 400: { "model": ErrorResponse, "description": "Some generic error happened during user creation", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires admin-level user access token", }, 409: { "model": ErrorResponse, "description": "User with this username already exists", }, }, ) async def createUser( user: CreateUserDefinition, access_token: str | None = Header(default=None), ): is_admin = check_if_admin_access_token(connector, access_token) if not is_admin: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) try: ok, e = create_user(connector, user.username, user.password) if not ok: if e: raise e raise Exception("Unknown error") except psycopg.errors.UniqueViolation: return JSONResponse( ErrorResponse(error="Username already exists").dict(), status_code=status.HTTP_409_CONFLICT, ) except Exception as e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST ) return OkResponse() @app.post( "/api/users/*", name="Update user", responses={ 200: {"model": OkResponse, "description": "Table dropped successfully"}, 400: { "model": ErrorResponse, "description": "Some generic error happened during updating user", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires admin-level user access token", }, }, ) async def updateUser( user: UserUpdateDefinition, access_token: str | None = Header(default=None), ): is_admin = check_if_admin_access_token(connector, access_token) if not is_admin: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) try: ok, e = update_user(connector, user.user_id, user.password, user.access_token) if not ok: if e: raise e raise Exception("Unknown error") except Exception as e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST ) return OkResponse() @app.post( "/api/users/{user_id}/-", name="Remove user", responses={ 200: {"model": OkResponse, "description": "Table dropped successfully"}, 400: { "model": ErrorResponse, "description": "Some generic error happened during user creation", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires admin-level user access token", }, 409: { "model": ErrorResponse, "description": "User with this username already exists", }, }, ) async def removeUser( user_id: int, access_token: str | None = Header(default=None), ): is_admin = check_if_admin_access_token(connector, access_token) if not is_admin: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) try: user = get_user_by_id(connector, user_id) if not user: raise Exception("User not found") elif user.access_token == access_token: raise Exception("Cannot remove yourself") ok, e = delete_user(connector, user_id) if not ok: if e: raise e raise Exception("Unknown error") except psycopg.errors.UniqueViolation: return JSONResponse( ErrorResponse(error="Username already exists").dict(), status_code=status.HTTP_409_CONFLICT, ) except Exception as e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST ) return OkResponse() @app.post( "/items/{tableName}", name="Get items from table", responses={ 200: {"model": list[dict[str, Any]], "description": "Table items"}, 400: { "model": ErrorResponse, "description": "Some generic error happened during getting table items", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires user access token", }, 404: { "model": ErrorResponse, "description": "Table not found", }, }, ) async def items( tableName: str, fields: list[str] = ["*"], where: list[ColumnConditionCompat] = [], access_token: str | None = Header(default=None), ): table_info = connector.getTable(tableName) if not table_info: return JSONResponse( ErrorResponse(error="Table not found").dict(), status_code=status.HTTP_404_NOT_FOUND, ) is_admin = check_if_admin_access_token(connector, access_token) if table_info["system"] and not is_admin: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) columns = parse_columns_from_definition(table_info["columns"]) columnsNames = set(column.name for column in columns) if fields == ["*"]: fields = list(columnsNames) else: for column in fields: if column not in columnsNames: return JSONResponse( ErrorResponse( error=f"Column {column} not found on table {tableName}" ), status_code=status.HTTP_404_NOT_FOUND, ) if where: for key in where: if key.column not in columnsNames: return JSONResponse( ErrorResponse(error=f"Column {key} not found on table {tableName}"), status_code=status.HTTP_404_NOT_FOUND, ) _, group = get_user_by_access_token(connector, access_token) if not is_admin: allowedColumns = get_allowed_columns_for_group( connector, tableName, group.id if group else 1 # 1 is anonymous group ) if not allowedColumns: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) elif len(allowedColumns) == 1 and allowedColumns[0] == "*": pass else: for column in fields: if column not in allowedColumns: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) for column in where: if column.column not in allowedColumns: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) try: table_items = connector.selectFromTable( tableName, fields, [ColumnCondition(w.column, w.operator, w.value) for w in where], ) except Exception as e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST ) return table_items @app.post( "/items/{tableName}/+", name="Create item", responses={ 200: {"model": OkResponse, "description": "Item created successfully"}, 400: { "model": ErrorResponse, "description": "Some generic error happened during creating item", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires user access token", }, 404: { "model": ErrorResponse, "description": "Table or column not found", }, 409: { "model": ErrorResponse, "description": "Unique constraint violation", }, }, ) async def itemsCreate( tableName: str, item: dict[str, Any], access_token: str | None = Header(default=None), ): table_info = connector.getTable(tableName) if not table_info: return JSONResponse( ErrorResponse(error="Table not found").dict(), status_code=status.HTTP_404_NOT_FOUND, ) is_admin = check_if_admin_access_token(connector, access_token) if table_info["system"] and not is_admin: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) user, group = get_user_by_access_token(connector, access_token) if not is_admin: allowedColumns = get_allowed_columns_for_group( connector, tableName, group.id if group else -1 ) if not allowedColumns: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) elif len(allowedColumns) == 1 and allowedColumns[0] == "*": pass else: for column in item: if column not in allowedColumns: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) try: connector.insertIntoTable(tableName, item) except psycopg.errors.UndefinedColumn: return JSONResponse( ErrorResponse(error="Column not found").dict(), status_code=status.HTTP_404_NOT_FOUND, ) except psycopg.errors.UniqueViolation: return JSONResponse( ErrorResponse(error="Unique violation").dict(), status_code=status.HTTP_409_CONFLICT, ) except Exception as e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST ) return OkResponse() @app.post( "/items/{tableName}/*", name="Update item in table", responses={ 200: {"model": OkResponse, "description": "Item updated successfully"}, 400: { "model": ErrorResponse, "description": "Some generic error happened during updating item", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires user access token", }, 404: { "model": ErrorResponse, "description": "Table or column not found", }, 409: { "model": ErrorResponse, "description": "Unique constraint violation", }, }, ) async def itemsUpdate( tableName: str, item: dict[str, str], oldItem: dict[str, str], access_token: str | None = Header(default=None), ): table_info = connector.getTable(tableName) if not table_info: return JSONResponse( ErrorResponse(error="Table not found").dict(), status_code=status.HTTP_404_NOT_FOUND, ) is_admin = check_if_admin_access_token(connector, access_token) if table_info["system"] and not is_admin: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) user, group = get_user_by_access_token(connector, access_token) if not is_admin: allowedColumns = get_allowed_columns_for_group( connector, tableName, group.id if group else -1 ) if not allowedColumns: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) elif len(allowedColumns) == 1 and allowedColumns[0] == "*": pass else: for column in item: if column not in allowedColumns: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) try: connector.updateDataInTable( tableName, [ColumnUpdate(column=c, value=item[c]) for c in item], [ ColumnCondition(column=c, operator="eq", value=oldItem[c]) for c in oldItem ], ) except psycopg.errors.UniqueViolation: return JSONResponse( ErrorResponse(error="Unique violation").dict(), status_code=status.HTTP_409_CONFLICT, ) except psycopg.errors.UndefinedColumn: return JSONResponse( ErrorResponse(error="Column not found").dict(), status_code=status.HTTP_404_NOT_FOUND, ) except Exception as e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST ) return OkResponse() @app.post( "/items/{tableName}/-", name="Delete item from table", responses={ 200: {"model": OkResponse, "description": "Item deleted successfully"}, 400: { "model": ErrorResponse, "description": "Some generic error happened during deleting item", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires user access token", }, 404: { "model": ErrorResponse, "description": "Table or column not found", }, }, ) async def itemsDelete( tableName: str, deleteWhere: list[ColumnConditionCompat], access_token: str | None = Header(default=None), ): table_info = connector.getTable(tableName) if not table_info: return JSONResponse( ErrorResponse(error="Table not found").dict(), status_code=status.HTTP_404_NOT_FOUND, ) is_admin = check_if_admin_access_token(connector, access_token) if table_info["system"] and not is_admin: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) user, group = get_user_by_access_token(connector, access_token) if not is_admin: allowedColumns = get_allowed_columns_for_group( connector, tableName, group.id if group else -1 ) if not allowedColumns: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) elif len(allowedColumns) == 1 and allowedColumns[0] == "*": pass else: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) try: connector.deleteFromTable( tableName, [ColumnCondition(dw.column, dw.operator, dw.value) for dw in deleteWhere], ) except Exception as e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST ) return OkResponse() @app.get( "/assets/{fid}", name="Get asset", responses={ 200: { "description": "Asset found", "content": {"application/octet-stream": {}}, }, 404: { "description": "Asset not found", }, }, ) async def getAsset(fid: str): asset = get_asset(connector, fid) if not asset: return status.HTTP_404_NOT_FOUND response: HTTPResponse | None = None try: response = minioClient.get_object(BUCKET_NAME, asset.name, version_id=asset.fid) if response is None: return status.HTTP_404_NOT_FOUND return StreamingResponse( content=io.BytesIO(response.data), media_type=response.getheader("Content-Type"), status_code=status.HTTP_200_OK, ) finally: if response is not None: response.close() response.release_conn() @app.post( "/assets/+", name="Put asset", responses={ 200: { "model": CreateAssetResponse, "description": "Asset created successfully", }, 400: { "model": ErrorResponse, "description": "Some generic error happened during creating asset", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires user access token", }, 500: { "model": ErrorResponse, "description": "Failed put asset into storage", }, }, ) async def createAsset( asset: UploadFile, access_token: str | None = Header(default=None), ): user, _ = get_user_by_access_token(connector, access_token) if not user: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) filename = asset.filename if not filename: filename = f"unnamed" filename = f"{token_hex()}_{filename}" result: ObjectWriteResult = minioClient.put_object( BUCKET_NAME, filename, data=asset.file, content_type=( asset.content_type if asset.content_type else "application/octet-stream" ), length=asset.size, ) if not result: return JSONResponse( ErrorResponse(error="Failed put asset into storage").dict(), status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) ok, e = create_asset(connector, filename, "", str(result.version_id)) if not ok: if e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST, ) return JSONResponse( ErrorResponse(error="Failed to create asset").dict(), status_code=status.HTTP_400_BAD_REQUEST, ) return CreateAssetResponse(fid=result.version_id) @app.post( "/assets/{asset_id}/*", name="Update asset description", responses={ 200: { "model": OkResponse, "description": "Asset description updated successfully", }, 400: { "model": ErrorResponse, "description": "Some generic error happened during updating asset", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires user access token", }, 404: { "model": ErrorResponse, "description": "Asset not found", }, }, ) async def updateAsset( asset_id: int, asset_description: str, access_token: str | None = Header(default=None), ): user = get_user_by_access_token(connector, access_token) if not user: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) ok, e = update_asset(connector, asset_id, asset_description) if not ok: if e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST, ) return JSONResponse( ErrorResponse(error="Asset not found").dict(), status_code=status.HTTP_404_NOT_FOUND, ) return OkResponse() @app.post( "/assets/{asset_id}/-", name="Remove asset", responses={ 200: { "model": OkResponse, "description": "Asset removed successfully", }, 400: { "model": ErrorResponse, "description": "Something went wrong during removing asset", }, 403: { "model": ErrorResponse, "description": "Requesting this endpoint requires user access token", }, 404: { "model": ErrorResponse, "description": "Asset not found", }, }, ) async def removeAsset( asset_id: int, check_references: bool = True, delete_referencing: bool = False, access_token: str | None = Header(default=None), ): user = get_user_by_access_token(connector, access_token) if not user: return JSONResponse( ErrorResponse(error="Not allowed").dict(), status_code=status.HTTP_403_FORBIDDEN, ) asset = get_asset_by_id(connector, asset_id) if not asset: return JSONResponse( ErrorResponse(error="Asset not found").dict(), status_code=status.HTTP_404_NOT_FOUND, ) try: minioClient.remove_object(BUCKET_NAME, asset.fid) except Exception as e: logger.error(f"Failed to remove asset from storage: {e}") return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST, ) ok, e = remove_asset(connector, asset_id, check_references, delete_referencing) if not ok: if e: return JSONResponse( ErrorResponse(error=str(e)).dict(), status_code=status.HTTP_400_BAD_REQUEST, ) return JSONResponse( ErrorResponse(error="Unknown error").dict(), status_code=status.HTTP_400_BAD_REQUEST, ) return OkResponse() if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000)