Setup complete Todo API with FastAPI and SQLite

- Create Todo model and schemas
- Set up API endpoints for CRUD operations
- Configure SQLAlchemy for database access
- Set up Alembic for database migrations
- Add Ruff for code linting
- Update README with project documentation
This commit is contained in:
Automated Action 2025-05-27 06:32:35 +00:00
parent ad5dd49cad
commit 7fa5fb0213
26 changed files with 761 additions and 3 deletions

120
README.md
View File

@ -1,3 +1,119 @@
# FastAPI Application # Todo API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. This is a FastAPI application for managing todo items.
## Features
- RESTful API for todo management
- CRUD operations for todo items
- SQLite database with SQLAlchemy ORM
- Alembic for database migrations
- Automatic API documentation with Swagger UI
## Project Structure
```
todoapi/
├── alembic.ini # Alembic configuration
├── app/ # Application package
│ ├── api/ # API routes
│ │ └── v1/ # API version 1
│ │ ├── api.py # Main API router
│ │ └── endpoints/ # API endpoints
│ │ └── todos.py # Todo endpoints
│ ├── core/ # Core modules
│ │ └── config.py # Application configuration
│ ├── crud/ # CRUD operations
│ │ └── todo.py # Todo CRUD operations
│ ├── db/ # Database modules
│ │ ├── base.py # Base models module
│ │ ├── base_class.py # Base model class
│ │ └── session.py # Database session
│ ├── models/ # SQLAlchemy models
│ │ └── todo.py # Todo model
│ └── schemas/ # Pydantic schemas
│ └── todo.py # Todo schemas
├── main.py # Main application entry point
├── migrations/ # Alembic migrations
│ ├── env.py # Migrations environment
│ ├── README # Migrations README
│ ├── script.py.mako # Migrations script template
│ └── versions/ # Migration versions
│ └── initial_migration.py # Initial migration
├── pyproject.toml # Project configuration
├── README.md # This file
└── requirements.txt # Project dependencies
```
## Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd todoapi
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the application:
```bash
uvicorn main:app --reload
```
The API will be available at http://localhost:8000.
## API Documentation
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
- OpenAPI JSON: http://localhost:8000/openapi.json
## API Endpoints
### Todo Endpoints
- `GET /api/v1/todos` - List all todos
- `POST /api/v1/todos` - Create a new todo
- `GET /api/v1/todos/{todo_id}` - Get a specific todo
- `PUT /api/v1/todos/{todo_id}` - Update a todo
- `DELETE /api/v1/todos/{todo_id}` - Delete a todo
## Health Check
- `GET /health` - Check if the API is running
## Database Migrations
Apply migrations:
```bash
alembic upgrade head
```
Create a new migration:
```bash
alembic revision --autogenerate -m "description"
```
## Development
### Linting
This project uses Ruff for linting:
```bash
ruff check .
```
To automatically fix issues:
```bash
ruff check --fix .
```

109
alembic.ini Normal file
View File

@ -0,0 +1,109 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# 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.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# 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 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# 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

1
app/__init__.py Normal file
View File

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

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

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

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

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

9
app/api/v1/api.py Normal file
View File

@ -0,0 +1,9 @@
"""
API router.
"""
from fastapi import APIRouter
from app.api.v1.endpoints import todos
api_router = APIRouter()
api_router.include_router(todos.router, prefix="/todos", tags=["todos"])

View File

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

View File

@ -0,0 +1,101 @@
"""
Todo API endpoints.
"""
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud
from app.db.session import get_db
from app.schemas.todo import Todo, TodoCreate, TodoUpdate
router = APIRouter()
@router.get("/", response_model=List[Todo])
def read_todos(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
completed: Optional[bool] = None,
) -> Any:
"""
Retrieve todos.
"""
todos = crud.todo.get_todos(db, skip=skip, limit=limit, completed=completed)
return todos
@router.post("/", response_model=Todo, status_code=status.HTTP_201_CREATED)
def create_todo(
*,
db: Session = Depends(get_db),
todo_in: TodoCreate,
) -> Any:
"""
Create new todo.
"""
todo = crud.todo.create_todo(db=db, todo=todo_in)
return todo
@router.get("/{todo_id}", response_model=Todo)
def read_todo(
*,
db: Session = Depends(get_db),
todo_id: int,
) -> Any:
"""
Get todo by ID.
"""
todo = crud.todo.get_todo(db=db, todo_id=todo_id)
if not todo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo not found",
)
return todo
@router.put("/{todo_id}", response_model=Todo)
def update_todo(
*,
db: Session = Depends(get_db),
todo_id: int,
todo_in: TodoUpdate,
) -> Any:
"""
Update a todo.
"""
todo = crud.todo.get_todo(db=db, todo_id=todo_id)
if not todo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo not found",
)
todo = crud.todo.update_todo(db=db, todo_id=todo_id, todo_update=todo_in)
return todo
@router.delete(
"/{todo_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None
)
def delete_todo(
*,
db: Session = Depends(get_db),
todo_id: int,
) -> None:
"""
Delete a todo.
"""
todo = crud.todo.get_todo(db=db, todo_id=todo_id)
if not todo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo not found",
)
crud.todo.delete_todo(db=db, todo_id=todo_id)
return None

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

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

View File

@ -2,7 +2,7 @@
Configuration settings for the application. Configuration settings for the application.
""" """
from pathlib import Path from pathlib import Path
from typing import List, Optional, Union from typing import List, Union
from pydantic import AnyHttpUrl, BaseSettings, validator from pydantic import AnyHttpUrl, BaseSettings, validator

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

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

84
app/crud/todo.py Normal file
View File

@ -0,0 +1,84 @@
"""
CRUD operations for Todo model.
"""
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.todo import Todo
from app.schemas.todo import TodoCreate, TodoUpdate
def get_todo(db: Session, todo_id: int) -> Optional[Todo]:
"""
Get a Todo by ID.
"""
return db.query(Todo).filter(Todo.id == todo_id).first()
def get_todos(
db: Session,
skip: int = 0,
limit: int = 100,
completed: Optional[bool] = None
) -> List[Todo]:
"""
Get all Todos with optional filtering.
"""
query = db.query(Todo)
if completed is not None:
query = query.filter(Todo.completed == completed)
return query.offset(skip).limit(limit).all()
def create_todo(db: Session, todo: TodoCreate) -> Todo:
"""
Create a new Todo.
"""
db_todo = Todo(
title=todo.title,
description=todo.description,
completed=todo.completed
)
db.add(db_todo)
db.commit()
db.refresh(db_todo)
return db_todo
def update_todo(
db: Session,
todo_id: int,
todo_update: TodoUpdate
) -> Optional[Todo]:
"""
Update a Todo.
"""
db_todo = get_todo(db, todo_id)
if not db_todo:
return None
update_data = todo_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(db_todo, field, value)
db.add(db_todo)
db.commit()
db.refresh(db_todo)
return db_todo
def delete_todo(db: Session, todo_id: int) -> bool:
"""
Delete a Todo.
"""
db_todo = get_todo(db, todo_id)
if not db_todo:
return False
db.delete(db_todo)
db.commit()
return True

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

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

7
app/db/base.py Normal file
View File

@ -0,0 +1,7 @@
"""
Base module for SQLAlchemy models.
"""
# Import all the models, so that Base has them before being
# imported by Alembic
from app.db.base_class import Base # noqa
from app.models.todo import Todo # noqa

23
app/db/base_class.py Normal file
View File

@ -0,0 +1,23 @@
"""
Base class for SQLAlchemy models.
"""
from typing import Any
from sqlalchemy.ext.declarative import as_declarative, declared_attr
@as_declarative()
class Base:
"""
Base class for all SQLAlchemy models.
"""
id: Any
__name__: str
# Generate __tablename__ automatically
@declared_attr
def __tablename__(cls) -> str:
"""
Generate table name based on the class name.
"""
return cls.__name__.lower()

24
app/db/session.py Normal file
View File

@ -0,0 +1,24 @@
"""
Database session module.
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(
settings.SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""
Get database session.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

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

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

24
app/models/todo.py Normal file
View File

@ -0,0 +1,24 @@
"""
Todo model module.
"""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text
from app.db.base_class import Base
class Todo(Base):
"""
Todo model.
"""
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
completed = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow
)

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

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

54
app/schemas/todo.py Normal file
View File

@ -0,0 +1,54 @@
"""
Todo schemas module.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class TodoBase(BaseModel):
"""
Base schema for Todo.
"""
title: str
description: Optional[str] = None
completed: bool = False
class TodoCreate(TodoBase):
"""
Schema for creating a Todo.
"""
pass
class TodoUpdate(BaseModel):
"""
Schema for updating a Todo.
"""
title: Optional[str] = None
description: Optional[str] = None
completed: Optional[bool] = None
class TodoInDBBase(TodoBase):
"""
Base schema for Todo in DB.
"""
id: int
created_at: datetime
updated_at: datetime
class Config:
"""
Config for the schema.
"""
orm_mode = True
class Todo(TodoInDBBase):
"""
Schema for returning a Todo.
"""
pass

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

81
migrations/env.py Normal file
View File

@ -0,0 +1,81 @@
"""
Alembic environment module.
"""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# 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 app.db.base import Base # noqa
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() -> 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:
is_sqlite = connection.dialect.name == 'sqlite'
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite # Key configuration for SQLite
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -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() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,37 @@
"""initial migration
Revision ID: initial_migration
Revises:
Create Date: 2023-04-10 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'initial_migration'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('todo',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('completed', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_todo_id'), 'todo', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_todo_id'), table_name='todo')
op.drop_table('todo')
# ### end Alembic commands ###

46
pyproject.toml Normal file
View File

@ -0,0 +1,46 @@
[tool.ruff]
# Same as Black.
line-length = 88
# Assume Python 3.10.
target-version = "py310"
# Exclude a variety of commonly ignored directories.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".mypy_cache",
".nox",
".pants.d",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"venv",
]
[tool.ruff.lint]
# Enable flake8-bugbear (`B`) rules.
select = ["E", "F", "B", "I"]
ignore = ["B008"] # Ignore function calls in argument defaults (common in FastAPI)
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.lint.mccabe]
# Unlike Flake8, default to a complexity level of 10.
max-complexity = 10
[tool.ruff.lint.isort]
known-third-party = ["fastapi", "pydantic", "sqlalchemy", "alembic", "starlette"]

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
fastapi==0.95.0
uvicorn==0.21.1
pydantic==1.10.7
SQLAlchemy==2.0.9
alembic==1.10.3
python-dotenv==1.0.0
ruff==0.0.262
pytest==7.3.1
httpx==0.24.0