TASK-001: Setup FastAPI project structure

This commit is contained in:
OpenClaw Agent
2026-04-16 04:40:40 +00:00
parent a3eca3b7da
commit ef5b32143a
26 changed files with 399 additions and 415 deletions

129
.gitignore vendored
View File

@@ -1,4 +1,3 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@@ -21,15 +20,11 @@ parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
@@ -47,87 +42,12 @@ htmlcov/
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
*.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
@@ -137,40 +57,17 @@ ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Database
*.db
*.sqlite
*.sqlite3
# Alembic
alembic/versions/*.pyc

199
README.md
View File

@@ -1,151 +1,62 @@
# SocialPhoto - Instagram Clone
# Instagram Clone API
A simple Instagram clone API built with FastAPI and SQLite.
## Quick Start
### 1. Install dependencies
```bash
pip install -r requirements.txt
```
### 2. Run the server
```bash
cd app
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
Or from the root directory:
```bash
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### 3. Access API docs
Open your browser to: http://localhost:8000/docs
## API Endpoints
### Authentication
```bash
# Register
curl -X POST http://localhost:8000/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"test","email":"test@test.com","password":"password123"}'
# Login
curl -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"test","password":"password123"}'
```
### Posts
```bash
# Create post (requires auth token)
curl -X POST http://localhost:8000/posts \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "caption=My first post" \
-F "image=@photo.jpg"
# Get all posts
curl http://localhost:8000/posts
# Get single post
curl http://localhost:8000/posts/1
# Like a post
curl -X POST http://localhost:8000/posts/1/like \
-H "Authorization: Bearer YOUR_TOKEN"
# Delete post (owner only)
curl -X DELETE http://localhost:8000/posts/1 \
-H "Authorization: Bearer YOUR_TOKEN"
```
### Users
```bash
# Get user profile
curl http://localhost:8000/users/1
# Get user posts
curl http://localhost:8000/users/1/posts
# Get user stats
curl http://localhost:8000/users/1/stats
# Follow user
curl -X POST http://localhost:8000/users/1/follow \
-H "Authorization: Bearer YOUR_TOKEN"
# Unfollow user
curl -X DELETE http://localhost:8000/users/1/follow \
-H "Authorization: Bearer YOUR_TOKEN"
```
### Comments
```bash
# Add comment
curl -X POST http://localhost:8000/posts/1/comments \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"Great post!"}'
# Get comments
curl http://localhost:8000/posts/1/comments
# Delete comment (owner only)
curl -X DELETE http://localhost:8000/comments/1 \
-H "Authorization: Bearer YOUR_TOKEN"
```
### Feed
```bash
# Get followed users feed (requires auth)
curl http://localhost:8000/feed \
-H "Authorization: Bearer YOUR_TOKEN"
# Get global feed
curl http://localhost:8000/feed/global
```
## Running Tests
```bash
pytest tests/ -v
```
## Project Structure
```
app/
├── main.py # FastAPI application
├── database.py # SQLite database setup
├── models.py # Pydantic models
├── auth.py # JWT authentication
└── routes/
├── auth.py # Auth endpoints
├── users.py # User endpoints
├── posts.py # Post endpoints
├── comments.py # Comment endpoints
└── feed.py # Feed endpoints
```
A FastAPI-based Instagram clone API with SQLAlchemy 2.0 and Alembic migrations.
## Tech Stack
- **Framework**: FastAPI 0.109+
- **Database**: SQLite
- **Auth**: JWT (python-jose)
- **Password Hashing**: bcrypt
- **Testing**: pytest
- **ORM**: SQLAlchemy 2.0
- **Database**: SQLite (dev), PostgreSQL (prod)
- **Migrations**: Alembic
- **Testing**: pytest + httpx
## License
## Project Structure
MIT
```
instagram-clone/
├── app/
│ ├── api/
│ │ ├── endpoints/ # API route handlers
│ │ └── dependencies/ # FastAPI dependencies
│ ├── core/ # Configuration and settings
│ ├── db/ # Database connection and models
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ ├── services/ # Business logic
│ └── utils/ # Utility functions
├── alembic/ # Database migrations
├── tests/
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── pyproject.toml
└── alembic.ini
```
## Setup
```bash
# Install dependencies
pip install -e ".[dev]"
# Run database migrations
alembic upgrade head
# Run the application
uvicorn app.main:app --reload
```
## Testing
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=app tests/
```
## API Documentation
Once running, visit:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc

42
alembic.ini Normal file
View File

@@ -0,0 +1,42 @@
# Alembic configuration file
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = sqlite:///./instagram_clone.db
[post_write_hooks]
[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

61
alembic/env.py Normal file
View File

@@ -0,0 +1,61 @@
"""Alembic environment configuration."""
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import sys
from pathlib import Path
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.db.database import Base
from app.core.config import settings
# this is the Alembic Config object
config = context.config
# Set sqlalchemy.url from settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Model metadata
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
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() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -1 +1 @@
"""SocialPhoto - Instagram Clone API."""
"""Application package."""

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API package."""

View File

@@ -0,0 +1 @@
"""Dependencies package."""

View File

@@ -0,0 +1 @@
"""Endpoints package."""

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Core module package."""

26
app/core/config.py Normal file
View File

@@ -0,0 +1,26 @@
"""Application configuration settings."""
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Application
APP_NAME: str = "Instagram Clone"
APP_VERSION: str = "0.1.0"
DEBUG: bool = False
# Database
DATABASE_URL: str = "sqlite:///./instagram_clone.db"
# Security
SECRET_KEY: Optional[str] = None
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

1
app/db/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Database module package."""

28
app/db/database.py Normal file
View File

@@ -0,0 +1,28 @@
"""Database connection and session management."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.core.config import settings
# Create engine
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {},
)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
"""SQLAlchemy declarative base class."""
pass
def get_db():
"""Dependency to get database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -1,21 +1,15 @@
"""SocialPhoto - Instagram Clone API.
A simple social media API for sharing images with likes, comments, and user follows.
"""
from pathlib import Path
"""FastAPI application entry point."""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.database import init_db
from app.routes import auth, comments, feed, posts, users
from app.core.config import settings
from app.db.database import engine, Base
# Create FastAPI app
app = FastAPI(
title="SocialPhoto",
description="Instagram Clone API - Share images with likes, comments, and follows",
version="1.0.0",
title=settings.APP_NAME,
version=settings.APP_VERSION,
debug=settings.DEBUG,
)
# CORS middleware
@@ -27,43 +21,14 @@ app.add_middleware(
allow_headers=["*"],
)
# Mount uploads directory
UPLOAD_DIR = Path(__file__).parent.parent / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
@app.on_event("startup")
async def startup_event():
"""Initialize database on startup."""
init_db()
# Include routers
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(posts.router)
app.include_router(comments.router)
app.include_router(feed.router)
@app.get("/", tags=["Root"])
@app.get("/")
async def root():
"""Root endpoint."""
return {
"name": "SocialPhoto",
"version": "1.0.0",
"description": "Instagram Clone API",
}
return {"message": f"Welcome to {settings.APP_NAME}", "version": settings.APP_VERSION}
@app.get("/health", tags=["Health"])
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

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

@@ -0,0 +1 @@
"""Models package."""

1
app/schemas/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Schemas package."""

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Services package."""

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

@@ -0,0 +1 @@
"""Utils package."""

44
pyproject.toml Normal file
View File

@@ -0,0 +1,44 @@
[project]
name = "instagram-clone"
version = "0.1.0"
description = "Instagram Clone API"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.109.0",
"uvicorn[standard]>=0.27.0",
"sqlalchemy>=2.0.0",
"alembic>=1.13.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"python-multipart>=0.0.6",
"python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4",
"psycopg2-binary>=2.9.9",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.26.0",
"black>=24.0.0",
"ruff>=0.1.0",
"mypy>=1.8.0",
]
[build-system]
requires = ["setuptools>=68.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.black]
line-length = 88
target-version = ['py311']
[tool.ruff]
line-length = 88
select = ["E", "F", "I", "N", "W"]
fixable = ["ALL"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"

View File

@@ -1 +1 @@
"""Tests package for SocialPhoto."""
"""Tests package."""

View File

@@ -1,129 +1,49 @@
"""Pytest fixtures for SocialPhoto tests."""
import os
import sqlite3
from pathlib import Path
from typing import Generator
"""Pytest configuration and fixtures."""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
# Set test database path before importing app
TEST_DB = Path(__file__).parent / "test_socialphoto.db"
from app.main import app
from app.db.database import Base, get_db
def override_db_path():
"""Override database path for tests."""
return TEST_DB
# Create in-memory SQLite database for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function")
def db() -> Generator[sqlite3.Connection, None, None]:
def db_session():
"""Create a fresh database for each test."""
# Remove existing test database
if TEST_DB.exists():
TEST_DB.unlink()
# Patch the database path before importing
import app.database as db_module
original_path = db_module.DATABASE_PATH
db_module.DATABASE_PATH = TEST_DB
# Initialize database with new path
db_module.init_db()
conn = db_module.get_db_connection()
conn.row_factory = sqlite3.Row
yield conn
conn.close()
db_module.DATABASE_PATH = original_path
if TEST_DB.exists():
TEST_DB.unlink()
Base.metadata.create_all(bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db: sqlite3.Connection) -> Generator[TestClient, None, None]:
"""Create a test client."""
from app.main import app
from app.database import get_db
def client(db_session):
"""Create a test client with fresh database."""
# Override database dependency
def override_get_db():
yield db
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
def get_user_id_from_token(token: str) -> int:
"""Extract user ID from JWT token (for test fixtures only)."""
from jose import jwt
from app.auth import SECRET_KEY, ALGORITHM
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return int(payload["sub"])
@pytest.fixture
def registered_user(client: TestClient) -> dict:
"""Register a test user and return user data with token."""
response = client.post(
"/auth/register",
json={
"username": "testuser",
"email": "test@example.com",
"password": "testpass123",
},
)
assert response.status_code == 201
data = response.json()
token = data["access_token"]
user_id = get_user_id_from_token(token)
return {
"id": user_id,
"username": "testuser",
"email": "test@example.com",
"password": "testpass123",
"token": token,
}
@pytest.fixture
def two_users(client: TestClient) -> dict:
"""Create two test users."""
# User 1
response = client.post(
"/auth/register",
json={
"username": "user1",
"email": "user1@example.com",
"password": "password123",
},
)
assert response.status_code == 201
token1 = response.json()["access_token"]
# User 2
response = client.post(
"/auth/register",
json={
"username": "user2",
"email": "user2@example.com",
"password": "password123",
},
)
assert response.status_code == 201
token2 = response.json()["access_token"]
return {
"user1_id": get_user_id_from_token(token1),
"user2_id": get_user_id_from_token(token2),
"user1_token": token1,
"user2_token": token2,
"user1_username": "user1",
"user2_username": "user2",
}

View File

@@ -0,0 +1 @@
"""Integration tests package."""

1
tests/unit/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Unit tests package."""

18
tests/unit/test_config.py Normal file
View File

@@ -0,0 +1,18 @@
"""Unit tests for core configuration."""
import pytest
from app.core.config import Settings
def test_settings_default_values():
"""Test default settings values."""
settings = Settings()
assert settings.APP_NAME == "Instagram Clone"
assert settings.APP_VERSION == "0.1.0"
assert settings.DEBUG is False
def test_settings_database_url_default():
"""Test default database URL."""
settings = Settings()
assert "sqlite" in settings.DATABASE_URL

View File

@@ -0,0 +1,17 @@
"""Unit tests for database module."""
import pytest
from app.db.database import Base, get_db
def test_base_declarative():
"""Test Base is a DeclarativeBase."""
from sqlalchemy.orm import DeclarativeBase
assert issubclass(Base, DeclarativeBase)
def test_get_db_yields_session(db_session):
"""Test get_db dependency yields database session."""
gen = get_db()
db = next(gen)
assert db is not None

19
tests/unit/test_main.py Normal file
View File

@@ -0,0 +1,19 @@
"""Unit tests for main application endpoints."""
import pytest
def test_root_endpoint(client):
"""Test root endpoint returns welcome message."""
response = client.get("/")
assert response.status_code == 200
data = response.json()
assert "message" in data
assert "version" in data
def test_health_check_endpoint(client):
"""Test health check endpoint."""
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}