Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# FastAPI Multi-Database API

Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.

FastAPI application with SQLAlchemy, Alembic, pytest, Black, and Docker support. Uses SQLite by default but designed to support MongoDB, MySQL, and PostgreSQL.

## Working Effectively

### Local Development (Recommended for Development)
- Install dependencies: `pip3 install -r requirements.txt` -- takes 30 seconds. NEVER CANCEL.
- Navigate to app directory: `cd app`
- Start the development server: `uvicorn main:app --host 0.0.0.0 --port 8000 --reload`
- Run tests: `python3 -m pytest -vvs` -- takes <1 second (4 tests pass)
- Format code: `python3 -m black .` -- takes <1 second. NEVER CANCEL.
- Import the application: `python3 -c "import main; print('App imported successfully')"`

### Docker Development (Production-like Environment)
- Build the container: `DOCKER_BUILDKIT=0 make build` -- takes 25 seconds. NEVER CANCEL. Set timeout to 60+ seconds.
- Start the application: `DOCKER_BUILDKIT=0 make up` -- starts server with auto-reload
- Run tests in container: `DOCKER_BUILDKIT=0 make test` -- takes 2 seconds. NEVER CANCEL.
- Format code in container: `DOCKER_BUILDKIT=0 make lint` -- takes <1 second. NEVER CANCEL.
- Stop the application: `make down` or Ctrl+C
- Access container shell: `make bash`

### Database Migrations
- Initialize migrations (already done): `alembic init migrations`
- Create migration: `make migrate msg="your_migration_message"`
- Apply migrations: `make exec-migration`
- Migrations are automatically applied when using `make up`

## Critical Build Requirements

### NEVER CANCEL - Docker Build Issues
- ALWAYS use `DOCKER_BUILDKIT=0` prefix for Docker commands to avoid SSL certificate issues
- Docker build takes 25 seconds on first run, subsequent builds are faster with caching
- If Docker build fails with SSL errors, use `DOCKER_BUILDKIT=0` and try again
- Set timeouts to 60+ seconds for all Docker build commands

### Local vs Docker Development
- **Local development** is FASTER for iteration: 30s setup, instant server restart
- **Docker development** ensures production parity: 25s build, slower iteration
- ALWAYS test both local and Docker environments before committing changes

## Validation

### Required Manual Testing After Changes
- ALWAYS test the complete API workflow after making changes:
1. Start the server (local or Docker)
2. Create an item: `curl -X POST http://localhost:8000/items/ -H "Content-Type: application/json" -d '{"name": "Test Item", "description": "Testing"}'`
3. Retrieve the item: `curl http://localhost:8000/items/1`
4. Verify the API documentation: `curl http://localhost:8000/` (should show Swagger UI)
5. Check OpenAPI spec: `curl http://localhost:8000/openapi.json`

### Pre-commit Validation
- ALWAYS run `python3 -m black .` before committing (or `make lint` for Docker)
- ALWAYS run `python3 -m pytest -vvs` before committing (or `make test` for Docker)
- ALWAYS manually test API endpoints after making changes to routers or models

## Common Issues and Solutions

### Import Errors
- All imports in `routers/` should use `core.database` not `database`
- All imports in `services/` should use `models.item` not `models.item_service`
- The main application imports are in `main.py` with proper error handling

### Database Schema Issues
- The model uses `id` as primary key, not `{model_name}_id`
- Service base class assumes `id` field for all operations
- SQLite database file is `database.sqlite` in app directory

### Docker Issues
- Use `DOCKER_BUILDKIT=0` for all Docker commands to avoid SSL issues
- Makefile uses `docker compose` (not `docker-compose`) syntax
- Alembic configuration uses `sqlite:///database.sqlite` format

### API Routing Issues
- Router prefixes are defined once in `main.py` when including routers
- Individual routers should NOT define their own prefix to avoid duplication
- Routes are: `POST /items/` and `GET /items/{item_id}`

## Architecture Overview

### Key Directories
```
app/
├── main.py # FastAPI application entry point
├── core/
│ ├── database.py # Database configuration and session management
│ └── logger.py # Logging configuration
├── models/
│ └── item.py # SQLAlchemy models
├── schemas/
│ └── item.py # Pydantic schemas for API contracts
├── routers/
│ └── item.py # API route handlers
├── services/
│ ├── base.py # Base service class with CRUD operations
│ └── item.py # Item-specific service implementation
├── tests/
│ ├── conftest.py # Test configuration
│ └── test_items.py # API endpoint tests
└── migrations/ # Alembic database migrations
```

### Current API Endpoints
- `GET /` - Swagger UI documentation
- `GET /openapi.json` - OpenAPI specification
- `POST /items/` - Create a new item
- `GET /items/{item_id}` - Retrieve an item by ID

### Database Configuration
- **Default**: SQLite (`database.sqlite`)
- **Supported**: MongoDB, MySQL, PostgreSQL (configuration in `core/database.py`)
- **ORM**: SQLAlchemy with declarative base
- **Migrations**: Alembic for schema versioning

## Common Commands Reference

### Development Workflow
```bash
# Setup
pip3 install -r requirements.txt
cd app

# Start development
uvicorn main:app --host 0.0.0.0 --port 8000 --reload

# In another terminal
curl -X POST http://localhost:8000/items/ -H "Content-Type: application/json" -d '{"name": "Test", "description": "Test item"}'
curl http://localhost:8000/items/1

# Format and test
python3 -m black .
python3 -m pytest -vvs
```

### Docker Workflow
```bash
# Build and start
DOCKER_BUILDKIT=0 make build
DOCKER_BUILDKIT=0 make up

# In another terminal
curl -X POST http://localhost:8000/items/ -H "Content-Type: application/json" -d '{"name": "Docker Test", "description": "Via container"}'

# Test and lint
DOCKER_BUILDKIT=0 make test
DOCKER_BUILDKIT=0 make lint

# Stop
make down
```

### Troubleshooting Commands
```bash
# Check if app imports correctly
cd app && python3 -c "import main; print('OK')"

# Check database file
ls -la app/database.sqlite

# Check Docker container status
docker ps

# View container logs
docker logs app
```
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
DOCKER_COMPOSE := $(shell which docker-compose)
DOCKER_COMPOSE := docker compose
DOCKER := $(shell which docker)

# Start development
Expand Down
2 changes: 1 addition & 1 deletion app/alembic.ini
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako
# output_encoding = utf-8

sqlalchemy.url = "database.sqlite"
sqlalchemy.url = sqlite:///database.sqlite


[post_write_hooks]
Expand Down
Binary file added app/database.sqlite
Binary file not shown.
1 change: 1 addition & 0 deletions app/migrations/README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Generic single-database configuration.
76 changes: 76 additions & 0 deletions app/migrations/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool

from alembic import context

# 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.
if config.config_file_name is not None:
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 = None

# 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() -> None:
"""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() -> None:
"""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:
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()
26 changes: 26 additions & 0 deletions app/migrations/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""${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"}
2 changes: 1 addition & 1 deletion app/models/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ class Item(Base):

id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
description = Column(String, nullable=True)
description = Column(String, nullable=True)
1 change: 0 additions & 1 deletion app/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
from routers.item import router as item_router

6 changes: 3 additions & 3 deletions app/routers/item.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from database import get_db
from core.database import get_db
from schemas.item import ItemCreate, ItemRead
from services.item_service import ItemService
from services.item import ItemService

router = APIRouter(prefix="/items", tags=["items"])
router = APIRouter(tags=["items"])


@router.post("/", response_model=ItemRead)
Expand Down
25 changes: 5 additions & 20 deletions app/services/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,19 @@ def create_item(self, item_data: BaseModel) -> ModelType:
self.db.add(item)
self.db.commit()
self.db.refresh(item)
logger.info(
f"Created {self.model.__name__} with ID {getattr(item, f'{self.model.__name__.lower()}_id')}"
)
logger.info(f"Created {self.model.__name__} with ID {getattr(item, 'id')}")
return item

def list_items(self, order_by: str | None = None) -> List[ModelType]:
"""Retrieve a list of all items, optionally ordered by a field."""
order_by = (
text(f"{self.model.__name__.lower()}_id")
if order_by is None
else text(order_by)
)
order_by = text("id") if order_by is None else text(order_by)
items = self.db.query(self.model).order_by(order_by).all()
logger.info(f"Retrieved {len(items)} {self.model.__name__}(s)")
return items

def get_item(self, item_id: int) -> ModelType:
"""Retrieve a single item by its ID."""
primary_key_field = getattr(self.model, f"{self.model.__name__.lower()}_id")
item = self.db.query(self.model).filter(primary_key_field == item_id).first()
item = self.db.query(self.model).filter(self.model.id == item_id).first()
if not item:
raise HTTPException(
status_code=404,
Expand All @@ -51,11 +44,7 @@ def get_item(self, item_id: int) -> ModelType:

def update_item(self, item_id: int, item_data: BaseModel) -> ModelType:
"""Update an existing item with new data."""
item = (
self.db.query(self.model)
.filter(getattr(self.model, f"{self.model.__name__.lower()}_id") == item_id)
.first()
)
item = self.db.query(self.model).filter(self.model.id == item_id).first()
if not item:
logger.warning(f"{self.model.__name__} with ID {item_id} not found")
raise HTTPException(
Expand All @@ -72,11 +61,7 @@ def update_item(self, item_id: int, item_data: BaseModel) -> ModelType:

def delete_item(self, item_id: int) -> Dict[str, str]:
"""Delete an item from the database."""
item = (
self.db.query(self.model)
.filter(getattr(self.model, f"{self.model.__name__.lower()}_id") == item_id)
.first()
)
item = self.db.query(self.model).filter(self.model.id == item_id).first()
if not item:
logger.warning(
f"{self.model.__name__} with ID {item_id} not found for deletion"
Expand Down
2 changes: 1 addition & 1 deletion app/services/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@

class ItemService(BaseService):
def __init__(self, db: Session):
super().__init__(db=db, model=Item)
super().__init__(db=db, model=Item)
Loading