diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..279e725 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 +``` \ No newline at end of file diff --git a/Makefile b/Makefile index 59c6ab6..e06dc75 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -DOCKER_COMPOSE := $(shell which docker-compose) +DOCKER_COMPOSE := docker compose DOCKER := $(shell which docker) # Start development diff --git a/app/alembic.ini b/app/alembic.ini index d124b10..1dbf2fa 100644 --- a/app/alembic.ini +++ b/app/alembic.ini @@ -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] diff --git a/app/database.sqlite b/app/database.sqlite new file mode 100644 index 0000000..871d42e Binary files /dev/null and b/app/database.sqlite differ diff --git a/app/migrations/README b/app/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/app/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/app/migrations/env.py b/app/migrations/env.py new file mode 100644 index 0000000..f9dbb75 --- /dev/null +++ b/app/migrations/env.py @@ -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() diff --git a/app/migrations/script.py.mako b/app/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/app/migrations/script.py.mako @@ -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"} diff --git a/app/models/item.py b/app/models/item.py index f76969e..dd31885 100644 --- a/app/models/item.py +++ b/app/models/item.py @@ -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) \ No newline at end of file + description = Column(String, nullable=True) diff --git a/app/routers/__init__.py b/app/routers/__init__.py index f5fdab0..e97fc61 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1,2 +1 @@ from routers.item import router as item_router - diff --git a/app/routers/item.py b/app/routers/item.py index 9a1bc87..ca547be 100644 --- a/app/routers/item.py +++ b/app/routers/item.py @@ -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) diff --git a/app/services/base.py b/app/services/base.py index 4d97ff7..bba969c 100644 --- a/app/services/base.py +++ b/app/services/base.py @@ -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, @@ -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( @@ -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" diff --git a/app/services/item.py b/app/services/item.py index abad83f..cc551a9 100644 --- a/app/services/item.py +++ b/app/services/item.py @@ -5,4 +5,4 @@ class ItemService(BaseService): def __init__(self, db: Session): - super().__init__(db=db, model=Item) \ No newline at end of file + super().__init__(db=db, model=Item) diff --git a/app/tests/test_items.py b/app/tests/test_items.py new file mode 100644 index 0000000..db47d93 --- /dev/null +++ b/app/tests/test_items.py @@ -0,0 +1,44 @@ +import pytest +from fastapi.testclient import TestClient + + +def test_create_item(client: TestClient): + """Test creating a new item.""" + response = client.post("/items/", json={"name": "Test Item", "description": "A test item"}) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Test Item" + assert data["description"] == "A test item" + assert "id" in data + + +def test_get_item(client: TestClient): + """Test retrieving an item by ID.""" + # First create an item + create_response = client.post("/items/", json={"name": "Get Test Item", "description": "For retrieval"}) + assert create_response.status_code == 200 + item_id = create_response.json()["id"] + + # Then retrieve it + get_response = client.get(f"/items/{item_id}") + assert get_response.status_code == 200 + data = get_response.json() + assert data["name"] == "Get Test Item" + assert data["description"] == "For retrieval" + assert data["id"] == item_id + + +def test_get_nonexistent_item(client: TestClient): + """Test retrieving a non-existent item returns 404.""" + response = client.get("/items/99999") + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + +def test_api_docs_accessible(client: TestClient): + """Test that API documentation is accessible.""" + response = client.get("/openapi.json") + assert response.status_code == 200 + data = response.json() + assert data["info"]["title"] == "FastAPI Template Starter" + assert "/items/" in data["paths"] \ No newline at end of file