From 2bc28761f54e72ace038d703a988253316ca2f82 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Mon, 9 Jun 2025 13:33:57 +0000 Subject: [PATCH] Create file upload and download API - Set up FastAPI project structure - Implement database models and migrations for file metadata - Create file upload endpoint with size validation - Implement file download and listing functionality - Add health check and API information endpoints - Create comprehensive documentation --- README.md | 159 ++++++++++++++++- alembic.ini | 85 ++++++++++ app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/v1/__init__.py | 1 + app/api/v1/api.py | 9 + app/api/v1/endpoints/__init__.py | 1 + app/api/v1/endpoints/files.py | 160 ++++++++++++++++++ app/core/config.py | 33 ++++ app/db/base.py | 4 + app/db/migrations/README | 1 + app/db/migrations/env.py | 81 +++++++++ app/db/migrations/script.py.mako | 24 +++ .../versions/001_create_files_table.py | 39 +++++ app/db/session.py | 22 +++ app/models/__init__.py | 4 + app/models/file.py | 38 +++++ app/schemas/__init__.py | 4 + app/schemas/file.py | 41 +++++ app/services/__init__.py | 4 + app/services/file_service.py | 118 +++++++++++++ main.py | 49 ++++++ requirements.txt | 7 + 23 files changed, 884 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/api.py create mode 100644 app/api/v1/endpoints/__init__.py create mode 100644 app/api/v1/endpoints/files.py create mode 100644 app/core/config.py create mode 100644 app/db/base.py create mode 100644 app/db/migrations/README create mode 100644 app/db/migrations/env.py create mode 100644 app/db/migrations/script.py.mako create mode 100644 app/db/migrations/versions/001_create_files_table.py create mode 100644 app/db/session.py create mode 100644 app/models/__init__.py create mode 100644 app/models/file.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/file.py create mode 100644 app/services/__init__.py create mode 100644 app/services/file_service.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..77ff08e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,158 @@ -# FastAPI Application +# File Upload/Download API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A FastAPI-based REST API for uploading, downloading, and managing files with SQLite database for metadata storage. + +## Features + +- Upload files with size validation +- Download files by ID +- List all uploaded files +- Get detailed file metadata +- Delete files +- Health check endpoint +- OpenAPI documentation + +## Prerequisites + +- Python 3.8+ +- pip (Python package manager) + +## Installation + +1. Clone the repository: + ```bash + git clone + cd fileuploaddownloadapi + ``` + +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +3. Run database migrations: + ```bash + alembic upgrade head + ``` + +## Usage + +### Running the Server + +Start the application with uvicorn: + +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +The API will be available at `http://localhost:8000`. + +### API Documentation + +Interactive API documentation is available at: +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` + +### API Endpoints + +#### File Operations + +- **Upload a file** + - `POST /api/v1/files/` + - Request: Form data with a file + - Response: File metadata with download URL + +- **List all files** + - `GET /api/v1/files/` + - Response: Array of file metadata objects + +- **Get file metadata** + - `GET /api/v1/files/{file_id}` + - Response: File metadata object + +- **Download a file** + - `GET /api/v1/files/{file_id}/download` + - Response: File content as a binary stream + +- **Delete a file** + - `DELETE /api/v1/files/{file_id}` + - Response: No content (204) + +#### Health Check + +- **Health check endpoint** + - `GET /health` + - Response: `{"status": "healthy"}` + +#### Root Endpoint + +- **API Information** + - `GET /` + - Response: Basic API information and links + +## Configuration + +The API uses environment variables for configuration: + +| Variable | Description | Default Value | +|------------------|----------------------------------------------|------------------------| +| PROJECT_NAME | Name of the project | "File Upload Download API" | +| MAX_FILE_SIZE | Maximum allowed file size in bytes | 10485760 (10MB) | + +## File Storage + +Files are stored in the `/app/storage/files` directory. File metadata is stored in a SQLite database at `/app/storage/db/db.sqlite`. + +## Development + +### Project Structure + +``` +fileuploaddownloadapi/ +├── app/ +│ ├── api/ +│ │ └── v1/ +│ │ ├── api.py +│ │ └── endpoints/ +│ │ └── files.py +│ ├── core/ +│ │ └── config.py +│ ├── db/ +│ │ ├── base.py +│ │ ├── session.py +│ │ └── migrations/ +│ │ └── versions/ +│ │ └── 001_create_files_table.py +│ ├── models/ +│ │ └── file.py +│ ├── schemas/ +│ │ └── file.py +│ └── services/ +│ └── file_service.py +├── storage/ +│ ├── db/ +│ └── files/ +├── alembic.ini +├── main.py +└── requirements.txt +``` + +### Running Migrations + +```bash +# Create a new migration +alembic revision -m "description" + +# Apply all migrations +alembic upgrade head + +# Rollback a migration +alembic downgrade -1 + +# Check current migration version +alembic current +``` + +## License + +[MIT License](LICENSE) \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..d2ab457 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,85 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = app/db/migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat migrations/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# SQLite URL example +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks=black +# black.type=console_scripts +# black.entrypoint=black +# black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..06d3914 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..06d3914 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..06d3914 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..3b73af0 --- /dev/null +++ b/app/api/v1/api.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +from app.api.v1.endpoints import files + +# Create the main API router +api_router = APIRouter() + +# Include all endpoint routers +api_router.include_router(files.router, prefix="/files", tags=["files"]) diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..06d3914 --- /dev/null +++ b/app/api/v1/endpoints/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package diff --git a/app/api/v1/endpoints/files.py b/app/api/v1/endpoints/files.py new file mode 100644 index 0000000..1fdf6ab --- /dev/null +++ b/app/api/v1/endpoints/files.py @@ -0,0 +1,160 @@ +from fastapi import APIRouter, Depends, File as UploadedFile, HTTPException, status +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from typing import List + +from app.db.session import get_db +from app.models.file import File +from app.schemas.file import FileResponse +from app.services.file_service import FileService + + +router = APIRouter() + + +@router.post("/", response_model=FileResponse, status_code=status.HTTP_201_CREATED) +async def upload_file( + file: UploadedFile = UploadedFile(...), + db: Session = Depends(get_db), +): + """ + Upload a file to the server. + + The file will be stored in the server's file system, and its metadata will be saved in the database. + """ + try: + # Save the file to disk + unique_filename, file_path, file_size = await FileService.save_file(file) + + # Create a new file record in the database + db_file = File( + filename=unique_filename, + original_filename=file.filename or "unnamed_file", + content_type=file.content_type or "application/octet-stream", + file_size=file_size, + file_path=file_path, + ) + + db.add(db_file) + db.commit() + db.refresh(db_file) + + # Construct the download URL + download_url = f"/api/v1/files/{db_file.id}/download" + + # Return the file metadata with download URL + return { + **db_file.__dict__, + "download_url": download_url, + } + + except HTTPException as e: + # Re-raise HTTP exceptions + raise e + except Exception as e: + # Log the error and return a generic error message + print(f"Error uploading file: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while uploading the file", + ) + + +@router.get("/", response_model=List[FileResponse]) +def list_files(db: Session = Depends(get_db)): + """ + List all files stored in the system. + + Returns a list of file metadata. + """ + files = db.query(File).all() + + # Add download URLs to each file + for file in files: + file.__dict__["download_url"] = f"/api/v1/files/{file.id}/download" + + return files + + +@router.get("/{file_id}", response_model=FileResponse) +def get_file(file_id: int, db: Session = Depends(get_db)): + """ + Get metadata for a specific file by ID. + """ + file = db.query(File).filter(File.id == file_id).first() + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found", + ) + + # Add download URL to the file + file.__dict__["download_url"] = f"/api/v1/files/{file.id}/download" + + return file + + +@router.get("/{file_id}/download") +def download_file(file_id: int, db: Session = Depends(get_db)): + """ + Download a file by its ID. + + Returns the file as a streaming response. + """ + # Get the file metadata from the database + file = db.query(File).filter(File.id == file_id).first() + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found", + ) + + # Check if the file exists on disk + if not FileService.file_exists(file.filename): + # If the file does not exist on disk, update the database + db.delete(file) + db.commit() + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found on disk", + ) + + # Get file content + file_content = FileService.get_file_content(file.filename) + + # Return the file as a streaming response + return StreamingResponse( + content=file_content, + media_type=file.content_type, + headers={ + "Content-Disposition": f'attachment; filename="{file.original_filename}"', + }, + ) + + +@router.delete( + "/{file_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None +) +def delete_file(file_id: int, db: Session = Depends(get_db)): + """ + Delete a file by its ID. + + Removes the file from both the database and the file system. + """ + # Get the file metadata from the database + file = db.query(File).filter(File.id == file_id).first() + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found", + ) + + # Delete the file from disk + FileService.delete_file(file.filename) + + # Delete the file metadata from the database + db.delete(file) + db.commit() + + return None diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..5231be8 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + PROJECT_NAME: str = "File Upload Download API" + API_V1_STR: str = "/api/v1" + + # Base directory for storage + STORAGE_DIR: Path = Path("/app/storage") + + # File storage directory + FILE_STORAGE_DIR: Path = STORAGE_DIR / "files" + + # Database + DB_DIR: Path = STORAGE_DIR / "db" + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + # Configure max file size (10MB by default) + MAX_FILE_SIZE: int = 10 * 1024 * 1024 # 10MB in bytes + + class Config: + case_sensitive = True + env_file = ".env" + + +# Create settings instance +settings = Settings() + +# Ensure storage directories exist +settings.DB_DIR.mkdir(parents=True, exist_ok=True) +settings.FILE_STORAGE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..e8030b1 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,4 @@ +from sqlalchemy.ext.declarative import declarative_base + +# Create a base class for SQLAlchemy models +Base = declarative_base() diff --git a/app/db/migrations/README b/app/db/migrations/README new file mode 100644 index 0000000..3542e0e --- /dev/null +++ b/app/db/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration with SQLite. \ No newline at end of file diff --git a/app/db/migrations/env.py b/app/db/migrations/env.py new file mode 100644 index 0000000..20f0ad6 --- /dev/null +++ b/app/db/migrations/env.py @@ -0,0 +1,81 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from app.db.base import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + 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(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == "sqlite" + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, # Enable batch mode for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/app/db/migrations/script.py.mako b/app/db/migrations/script.py.mako new file mode 100644 index 0000000..1e4564e --- /dev/null +++ b/app/db/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/app/db/migrations/versions/001_create_files_table.py b/app/db/migrations/versions/001_create_files_table.py new file mode 100644 index 0000000..e198680 --- /dev/null +++ b/app/db/migrations/versions/001_create_files_table.py @@ -0,0 +1,39 @@ +"""create files table + +Revision ID: 001 +Revises: +Create Date: 2023-08-15 10:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "files", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("filename", sa.String(), nullable=False), + sa.Column("original_filename", sa.String(), nullable=False), + sa.Column("content_type", sa.String(), nullable=False), + sa.Column("file_size", sa.Integer(), nullable=False), + sa.Column("file_path", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("file_path"), + ) + op.create_index(op.f("ix_files_id"), "files", ["id"], unique=False) + + +def downgrade(): + op.drop_index(op.f("ix_files_id"), table_name="files") + op.drop_table("files") diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..62590dd --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings + +# Create SQLAlchemy engine +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, # Only needed for SQLite +) + +# Create a SessionLocal class for database sessions +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +# Dependency to get DB session +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..33e87f1 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,4 @@ +from app.models.file import File + +# List all models for easy importing +__all__ = ["File"] diff --git a/app/models/file.py b/app/models/file.py new file mode 100644 index 0000000..fba7960 --- /dev/null +++ b/app/models/file.py @@ -0,0 +1,38 @@ +import datetime +from sqlalchemy import Column, Integer, String, DateTime +from uuid import uuid4 + +from app.db.base import Base + + +class File(Base): + """ + SQLAlchemy model for the file table. + Stores metadata about uploaded files. + """ + + __tablename__ = "files" + + id = Column(Integer, primary_key=True, index=True) + filename = Column(String, nullable=False) + original_filename = Column(String, nullable=False) + content_type = Column(String, nullable=False) + file_size = Column(Integer, nullable=False) # in bytes + file_path = Column(String, nullable=False, unique=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + updated_at = Column( + DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) + + @staticmethod + def generate_unique_filename(original_filename: str) -> str: + """ + Generate a unique filename to prevent collisions and security issues. + """ + # Extract file extension from the original filename + if "." in original_filename: + extension = original_filename.rsplit(".", 1)[1].lower() + return f"{uuid4().hex}.{extension}" + + # No extension in the original filename + return f"{uuid4().hex}" diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..281f7b8 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,4 @@ +from app.schemas.file import FileBase, FileCreate, FileInDB, FileResponse + +# List all schemas for easy importing +__all__ = ["FileBase", "FileCreate", "FileInDB", "FileResponse"] diff --git a/app/schemas/file.py b/app/schemas/file.py new file mode 100644 index 0000000..2b72759 --- /dev/null +++ b/app/schemas/file.py @@ -0,0 +1,41 @@ +from datetime import datetime +from pydantic import BaseModel + + +class FileBase(BaseModel): + """Base schema for File with common attributes.""" + + filename: str + content_type: str + file_size: int + + +class FileCreate(FileBase): + """Schema for creating a file (internal use).""" + + original_filename: str + file_path: str + + +class FileInDB(FileBase): + """Schema for file as stored in the database.""" + + id: int + original_filename: str + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class FileResponse(FileBase): + """Schema for file response returned to clients.""" + + id: int + original_filename: str + created_at: datetime + download_url: str + + class Config: + orm_mode = True diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..022c036 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,4 @@ +from app.services.file_service import FileService + +# List all services for easy importing +__all__ = ["FileService"] diff --git a/app/services/file_service.py b/app/services/file_service.py new file mode 100644 index 0000000..c3db653 --- /dev/null +++ b/app/services/file_service.py @@ -0,0 +1,118 @@ +import os +import shutil +from fastapi import UploadFile, HTTPException, status +from pathlib import Path +from typing import BinaryIO + +from app.core.config import settings +from app.models.file import File + + +class FileService: + """Service for handling file operations.""" + + @staticmethod + async def save_file(upload_file: UploadFile) -> tuple[str, str, int]: + """ + Save an uploaded file to disk. + + Args: + upload_file: The uploaded file + + Returns: + Tuple of (unique_filename, file_path, file_size) + + Raises: + HTTPException: If file size exceeds the maximum allowed size + """ + # Generate a unique filename to prevent collisions + original_filename = upload_file.filename or "unnamed_file" + unique_filename = File.generate_unique_filename(original_filename) + + # Define the path where the file will be saved + file_path = settings.FILE_STORAGE_DIR / unique_filename + + # Check file size + # First, we need to get the file size + upload_file.file.seek(0, os.SEEK_END) + file_size = upload_file.file.tell() + upload_file.file.seek(0) # Reset file position + + if file_size > settings.MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File size exceeds the maximum allowed size of {settings.MAX_FILE_SIZE} bytes", + ) + + # Save the file + with open(file_path, "wb") as buffer: + shutil.copyfileobj(upload_file.file, buffer) + + return unique_filename, str(file_path), file_size + + @staticmethod + def get_file_path(filename: str) -> Path: + """ + Get the full path for a file. + + Args: + filename: The filename to retrieve + + Returns: + Path object pointing to the file + """ + return settings.FILE_STORAGE_DIR / filename + + @staticmethod + def file_exists(filename: str) -> bool: + """ + Check if a file exists on disk. + + Args: + filename: The filename to check + + Returns: + True if the file exists, False otherwise + """ + file_path = FileService.get_file_path(filename) + return file_path.exists() and file_path.is_file() + + @staticmethod + def delete_file(filename: str) -> bool: + """ + Delete a file from disk. + + Args: + filename: The filename to delete + + Returns: + True if the file was deleted, False otherwise + """ + file_path = FileService.get_file_path(filename) + if file_path.exists() and file_path.is_file(): + file_path.unlink() + return True + return False + + @staticmethod + def get_file_content(filename: str) -> BinaryIO: + """ + Get file content as a binary file object. + + Args: + filename: The filename to retrieve + + Returns: + File object opened in binary mode + + Raises: + HTTPException: If the file does not exist + """ + file_path = FileService.get_file_path(filename) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found", + ) + + return open(file_path, "rb") diff --git a/main.py b/main.py new file mode 100644 index 0000000..99d337c --- /dev/null +++ b/main.py @@ -0,0 +1,49 @@ +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.api import api_router +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + openapi_url="/openapi.json", + docs_url="/docs", + redoc_url="/redoc", +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(api_router, prefix=settings.API_V1_STR) + + +@app.get("/") +async def root(): + """ + Root endpoint that returns API information. + """ + return { + "title": settings.PROJECT_NAME, + "documentation": "/docs", + "health_check": "/health", + } + + +@app.get("/health", status_code=200) +async def health_check(): + """ + Health check endpoint. + """ + return {"status": "healthy"} + + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ae4db1d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.95.0 +uvicorn>=0.22.0 +sqlalchemy>=2.0.0 +alembic>=1.10.0 +python-multipart>=0.0.6 +python-dotenv>=1.0.0 +ruff>=0.1.0 \ No newline at end of file