Implement Blogging API with FastAPI and SQLite

- Create project structure with app organization
- Set up FastAPI application with CORS and health endpoint
- Implement database models with SQLAlchemy (User, Post, Comment)
- Set up Alembic for database migrations
- Implement authentication with JWT tokens
- Create CRUD operations for all models
- Implement REST API endpoints for users, posts, and comments
- Add comprehensive documentation in README.md
This commit is contained in:
Automated Action 2025-06-02 22:34:58 +00:00
parent 43ed82386b
commit 606cda0912
37 changed files with 1542 additions and 2 deletions

160
README.md
View File

@ -1,3 +1,159 @@
# FastAPI Application
# Blogging API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A RESTful API for a blogging platform built with FastAPI and SQLite.
## Features
- User management with authentication
- Blog post creation, retrieval, updating, and deletion
- Comment functionality on blog posts
- Role-based permissions (regular users and superusers)
- RESTful API with proper HTTP methods and status codes
- JWT-based authentication
- SQLite database with SQLAlchemy ORM
- Alembic for database migrations
## Prerequisites
- Python 3.8+
- Virtual environment (recommended)
## Environment Variables
The application uses the following environment variables:
- `SECRET_KEY`: Secret key for JWT token encryption (default provided for development)
- `ACCESS_TOKEN_EXPIRE_MINUTES`: JWT token expiration time in minutes (default: 60 * 24 * 8 = 8 days)
## Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd bloggingapi
```
2. Install the dependencies:
```bash
pip install -r requirements.txt
```
3. Run database migrations:
```bash
alembic upgrade head
```
4. Run the application:
```bash
uvicorn main:app --reload
```
The API will be available at http://localhost:8000.
## API Documentation
Once the application is running, you can access the API documentation at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## API Endpoints
### Authentication
- `POST /api/v1/login/access-token` - Get JWT access token
### Users
- `GET /api/v1/users/` - Get all users (superuser only)
- `POST /api/v1/users/` - Create a new user
- `GET /api/v1/users/me` - Get current user
- `PUT /api/v1/users/me` - Update current user
- `GET /api/v1/users/{user_id}` - Get user by ID
- `DELETE /api/v1/users/{user_id}` - Delete user (superuser only)
### Posts
- `GET /api/v1/posts/` - Get all published posts
- `POST /api/v1/posts/` - Create a new post
- `GET /api/v1/posts/{post_id}` - Get post by ID
- `PUT /api/v1/posts/{post_id}` - Update post
- `DELETE /api/v1/posts/{post_id}` - Delete post
- `GET /api/v1/posts/user/{user_id}` - Get posts by user
### Comments
- `GET /api/v1/comments/` - Get all comments (superuser only)
- `POST /api/v1/comments/` - Create a new comment
- `GET /api/v1/comments/{comment_id}` - Get comment by ID
- `PUT /api/v1/comments/{comment_id}` - Update comment
- `DELETE /api/v1/comments/{comment_id}` - Delete comment
- `GET /api/v1/comments/post/{post_id}` - Get comments by post
- `GET /api/v1/comments/user/{user_id}` - Get comments by user
### Health Check
- `GET /health` - API health check
## Project Structure
```
.
├── alembic.ini # Alembic configuration
├── app # Application package
│ ├── api # API endpoints
│ │ └── v1 # API version 1
│ │ ├── api.py # API router
│ │ └── endpoints # API endpoint modules
│ │ ├── comments.py # Comment endpoints
│ │ ├── login.py # Authentication endpoints
│ │ ├── posts.py # Post endpoints
│ │ └── users.py # User endpoints
│ ├── auth # Authentication package
│ │ ├── deps.py # Auth dependencies
│ │ └── security.py # Security utilities
│ ├── core # Core package
│ │ └── config.py # Configuration settings
│ ├── crud # CRUD operations
│ │ ├── base.py # Base CRUD class
│ │ ├── comment.py # Comment CRUD
│ │ ├── post.py # Post CRUD
│ │ └── user.py # User CRUD
│ ├── db # Database package
│ │ ├── base.py # Import all models
│ │ ├── base_class.py # Base model class
│ │ └── session.py # Database session
│ ├── models # SQLAlchemy models
│ │ ├── comment.py # Comment model
│ │ ├── post.py # Post model
│ │ └── user.py # User model
│ └── schemas # Pydantic schemas
│ ├── comment.py # Comment schemas
│ ├── post.py # Post schemas
│ └── user.py # User schemas
├── main.py # FastAPI application
├── migrations # Alembic migrations
│ ├── env.py # Alembic environment
│ ├── script.py.mako # Migration script template
│ └── versions # Migration versions
│ └── 001_initial_tables.py # Initial migration
└── requirements.txt # Project dependencies
```
## Development
The project uses Alembic for database migrations. To create a new migration after model changes:
```bash
alembic revision --autogenerate -m "Description of changes"
```
To apply migrations:
```bash
alembic upgrade head
```

105
alembic.ini Normal file
View File

@ -0,0 +1,105 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(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 with absolute path
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
# 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

0
app/__init__.py Normal file
View File

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

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

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

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

View File

@ -0,0 +1,158 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.auth import deps
from app.db.session import get_db
router = APIRouter()
@router.get("/", response_model=List[schemas.Comment])
def read_comments(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Retrieve comments. Only superusers can see all comments.
"""
comments = crud.comment.get_multi(db, skip=skip, limit=limit)
return comments
@router.post("/", response_model=schemas.Comment)
def create_comment(
*,
db: Session = Depends(get_db),
comment_in: schemas.CommentCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Create new comment.
"""
post = crud.post.get(db, id=comment_in.post_id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found",
)
comment = crud.comment.create_with_author(
db=db, obj_in=comment_in, author_id=current_user.id
)
return comment
@router.get("/{comment_id}", response_model=schemas.Comment)
def read_comment(
*,
db: Session = Depends(get_db),
comment_id: str,
) -> Any:
"""
Get comment by ID.
"""
comment = crud.comment.get(db, id=comment_id)
if not comment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Comment not found",
)
return comment
@router.put("/{comment_id}", response_model=schemas.Comment)
def update_comment(
*,
db: Session = Depends(get_db),
comment_id: str,
comment_in: schemas.CommentUpdate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update a comment.
"""
comment = crud.comment.get(db, id=comment_id)
if not comment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Comment not found",
)
if comment.author_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only update your own comments",
)
comment = crud.comment.update(db, db_obj=comment, obj_in=comment_in)
return comment
@router.delete("/{comment_id}", response_model=schemas.Comment)
def delete_comment(
*,
db: Session = Depends(get_db),
comment_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Delete a comment.
"""
comment = crud.comment.get(db, id=comment_id)
if not comment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Comment not found",
)
if comment.author_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only delete your own comments",
)
comment = crud.comment.remove(db, id=comment_id)
return comment
@router.get("/post/{post_id}", response_model=List[schemas.Comment])
def read_comments_by_post(
post_id: str,
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve comments by post.
"""
post = crud.post.get(db, id=post_id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found",
)
comments = crud.comment.get_multi_by_post(
db=db, post_id=post_id, skip=skip, limit=limit
)
return comments
@router.get("/user/{user_id}", response_model=List[schemas.Comment])
def read_comments_by_user(
user_id: str,
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve comments by user.
"""
if current_user.id != user_id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only view your own comments",
)
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
comments = crud.comment.get_multi_by_author(
db=db, author_id=user_id, skip=skip, limit=limit
)
return comments

View File

@ -0,0 +1,41 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app import crud, schemas
from app.auth.security import create_access_token
from app.core.config import settings
from app.db.session import get_db
router = APIRouter()
@router.post("/login/access-token", response_model=schemas.Token)
def login_access_token(
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = crud.user.authenticate(
db, email=form_data.username, password=form_data.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect email or password",
)
elif not crud.user.is_active(user):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}

View File

@ -0,0 +1,148 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.auth import deps
from app.db.session import get_db
router = APIRouter()
@router.get("/", response_model=List[schemas.Post])
def read_posts(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve published posts.
"""
posts = crud.post.get_multi_published(db, skip=skip, limit=limit)
return posts
@router.post("/", response_model=schemas.Post)
def create_post(
*,
db: Session = Depends(get_db),
post_in: schemas.PostCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Create new post.
"""
post = crud.post.create_with_author(db, obj_in=post_in, author_id=current_user.id)
return post
@router.get("/{post_id}", response_model=schemas.Post)
def read_post(
*,
db: Session = Depends(get_db),
post_id: str,
) -> Any:
"""
Get post by ID.
"""
post = crud.post.get(db, id=post_id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found",
)
if not post.is_published:
# Only the author can see unpublished posts
current_user = deps.get_current_user(db=db)
if current_user.id != post.author_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The post is not published yet",
)
return post
@router.put("/{post_id}", response_model=schemas.Post)
def update_post(
*,
db: Session = Depends(get_db),
post_id: str,
post_in: schemas.PostUpdate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update a post.
"""
post = crud.post.get(db, id=post_id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found",
)
if post.author_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only update your own posts",
)
post = crud.post.update(db, db_obj=post, obj_in=post_in)
return post
@router.delete("/{post_id}", response_model=schemas.Post)
def delete_post(
*,
db: Session = Depends(get_db),
post_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Delete a post.
"""
post = crud.post.get(db, id=post_id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found",
)
if post.author_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only delete your own posts",
)
post = crud.post.remove(db, id=post_id)
return post
@router.get("/user/{user_id}", response_model=List[schemas.Post])
def read_user_posts(
user_id: str,
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve posts by user.
"""
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check if the current user is the author or a superuser
try:
current_user = deps.get_current_user(db=db)
is_author_or_superuser = (user_id == current_user.id) or current_user.is_superuser
except Exception:
is_author_or_superuser = False
if is_author_or_superuser:
# Return all posts for the author or superuser
posts = crud.post.get_multi_by_author(
db=db, author_id=user_id, skip=skip, limit=limit
)
else:
# Filter to only published posts for other users
posts = [
post for post in crud.post.get_multi_by_author(
db=db, author_id=user_id, skip=skip, limit=limit
)
if post.is_published
]
return posts

View File

@ -0,0 +1,117 @@
from typing import Any, List
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.auth import deps
from app.db.session import get_db
router = APIRouter()
@router.get("/", response_model=List[schemas.User])
def read_users(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Retrieve users. Only superusers can access this endpoint.
"""
users = crud.user.get_multi(db, skip=skip, limit=limit)
return users
@router.post("/", response_model=schemas.User)
def create_user(
*,
db: Session = Depends(get_db),
user_in: schemas.UserCreate,
) -> Any:
"""
Create new user.
"""
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists in the system.",
)
user = crud.user.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this username already exists in the system.",
)
user = crud.user.create(db, obj_in=user_in)
return user
@router.put("/me", response_model=schemas.User)
def update_user_me(
*,
db: Session = Depends(get_db),
full_name: str = Body(None),
email: str = Body(None),
password: str = Body(None),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update own user.
"""
current_user_data = jsonable_encoder(current_user)
user_in = schemas.UserUpdate(**current_user_data)
if full_name is not None:
user_in.full_name = full_name
if email is not None:
user_in.email = email
if password is not None:
user_in.password = password
user = crud.user.update(db, db_obj=current_user, obj_in=user_in)
return user
@router.get("/me", response_model=schemas.User)
def read_user_me(
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get current user.
"""
return current_user
@router.get("/{user_id}", response_model=schemas.User)
def read_user_by_id(
user_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
db: Session = Depends(get_db),
) -> Any:
"""
Get a specific user by id.
"""
user = crud.user.get(db, id=user_id)
if user == current_user:
return user
if not crud.user.is_superuser(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges",
)
return user
@router.delete("/{user_id}", response_model=schemas.User)
def delete_user(
user_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Delete a user. Only superusers can delete users.
"""
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user = crud.user.remove(db, id=user_id)
return user

0
app/auth/__init__.py Normal file
View File

56
app/auth/deps.py Normal file
View File

@ -0,0 +1,56 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.core.config import settings
from app.db.session import get_db
from app.auth.security import ALGORITHM
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> models.User:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
)
token_data = schemas.TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
user = crud.user.get(db, id=token_data.sub)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
def get_current_active_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user
def get_current_active_superuser(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges"
)
return current_user

34
app/auth/security.py Normal file
View File

@ -0,0 +1,34 @@
from datetime import datetime, timedelta
from typing import Any, Union
import uuid
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = settings.ALGORITHM
def create_access_token(
subject: Union[str, Any], expires_delta: timedelta = None
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def generate_uuid() -> str:
return str(uuid.uuid4())

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

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

@ -0,0 +1,39 @@
import os
from pathlib import Path
from typing import List
from pydantic import AnyHttpUrl, validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
)
PROJECT_NAME: str = "Blogging API"
API_V1_STR: str = "/api/v1"
# JWT token settings
SECRET_KEY: str = os.getenv("SECRET_KEY", "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
# SQLite settings
# Main database location
DB_DIR = Path("/app/storage/db")
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
# CORS settings
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: str | List[str]) -> List[str] | str:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
settings = Settings()

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

@ -0,0 +1,3 @@
from app.crud.user import user # noqa: F401
from app.crud.post import post # noqa: F401
from app.crud.comment import comment # noqa: F401

65
app/crud/base.py Normal file
View File

@ -0,0 +1,65 @@
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.base_class import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType]):
"""
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
**Parameters**
* `model`: A SQLAlchemy model class
* `schema`: A Pydantic model (schema) class
"""
self.model = model
def get(self, db: Session, id: Any) -> Optional[ModelType]:
return db.query(self.model).filter(self.model.id == id).first()
def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
return db.query(self.model).offset(skip).limit(limit).all()
def create(self, db: Session, *, obj_in: CreateSchemaType, **kwargs) -> ModelType:
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data, **kwargs)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
*,
db_obj: ModelType,
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> ModelType:
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def remove(self, db: Session, *, id: Any) -> ModelType:
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj

46
app/crud/comment.py Normal file
View File

@ -0,0 +1,46 @@
from typing import List
from sqlalchemy.orm import Session
from app.auth.security import generate_uuid
from app.crud.base import CRUDBase
from app.models.comment import Comment
from app.schemas.comment import CommentCreate, CommentUpdate
class CRUDComment(CRUDBase[Comment, CommentCreate, CommentUpdate]):
def create_with_author(
self, db: Session, *, obj_in: CommentCreate, author_id: str
) -> Comment:
obj_in_data = obj_in.dict()
db_obj = Comment(
id=generate_uuid(),
**obj_in_data,
author_id=author_id,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_multi_by_post(
self, db: Session, *, post_id: str, skip: int = 0, limit: int = 100
) -> List[Comment]:
return (
db.query(self.model)
.filter(Comment.post_id == post_id)
.offset(skip)
.limit(limit)
.all()
)
def get_multi_by_author(
self, db: Session, *, author_id: str, skip: int = 0, limit: int = 100
) -> List[Comment]:
return (
db.query(self.model)
.filter(Comment.author_id == author_id)
.offset(skip)
.limit(limit)
.all()
)
comment = CRUDComment(Comment)

46
app/crud/post.py Normal file
View File

@ -0,0 +1,46 @@
from typing import List
from sqlalchemy.orm import Session
from app.auth.security import generate_uuid
from app.crud.base import CRUDBase
from app.models.post import Post
from app.schemas.post import PostCreate, PostUpdate
class CRUDPost(CRUDBase[Post, PostCreate, PostUpdate]):
def create_with_author(
self, db: Session, *, obj_in: PostCreate, author_id: str
) -> Post:
obj_in_data = obj_in.dict()
db_obj = Post(
id=generate_uuid(),
**obj_in_data,
author_id=author_id,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_multi_by_author(
self, db: Session, *, author_id: str, skip: int = 0, limit: int = 100
) -> List[Post]:
return (
db.query(self.model)
.filter(Post.author_id == author_id)
.offset(skip)
.limit(limit)
.all()
)
def get_multi_published(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[Post]:
return (
db.query(self.model)
.filter(Post.is_published.is_(True))
.offset(skip)
.limit(limit)
.all()
)
post = CRUDPost(Post)

58
app/crud/user.py Normal file
View File

@ -0,0 +1,58 @@
from typing import Any, Dict, Optional, Union
from sqlalchemy.orm import Session
from app.auth.security import get_password_hash, verify_password, generate_uuid
from app.crud.base import CRUDBase
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()
def get_by_username(self, db: Session, *, username: str) -> Optional[User]:
return db.query(User).filter(User.username == username).first()
def create(self, db: Session, *, obj_in: UserCreate) -> User:
db_obj = User(
id=generate_uuid(),
email=obj_in.email,
username=obj_in.username,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
is_superuser=obj_in.is_superuser,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(exclude_unset=True)
if update_data.get("password"):
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
return super().update(db, db_obj=db_obj, obj_in=update_data)
def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]:
user = self.get_by_email(db, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(self, user: User) -> bool:
return user.is_active
def is_superuser(self, user: User) -> bool:
return user.is_superuser
user = CRUDUser(User)

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

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

@ -0,0 +1,6 @@
# 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.user import User # noqa
from app.models.post import Post # noqa
from app.models.comment import Comment # noqa

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

@ -0,0 +1,12 @@
from typing import Any
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
id: Any
__name__: str
# Generate __tablename__ automatically
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()

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

@ -0,0 +1,18 @@
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)
# Dependency to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

17
app/models/comment.py Normal file
View File

@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import Column, String, Text, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class Comment(Base):
id = Column(String, primary_key=True, index=True)
content = Column(Text, nullable=False)
post_id = Column(String, ForeignKey("post.id"), nullable=False)
author_id = Column(String, ForeignKey("user.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
post = relationship("Post", back_populates="comments")
author = relationship("User", back_populates="comments")

18
app/models/post.py Normal file
View File

@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import Column, String, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class Post(Base):
id = Column(String, primary_key=True, index=True)
title = Column(String, index=True, nullable=False)
content = Column(Text, nullable=False)
is_published = Column(Boolean, default=True)
author_id = Column(String, ForeignKey("user.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
author = relationship("User", back_populates="posts")
comments = relationship("Comment", back_populates="post", cascade="all, delete-orphan")

20
app/models/user.py Normal file
View File

@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, String, DateTime
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class User(Base):
id = Column(String, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, index=True)
is_active = Column(Boolean(), default=True)
is_superuser = Column(Boolean(), default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
posts = relationship("Post", back_populates="author", cascade="all, delete-orphan")
comments = relationship("Comment", back_populates="author", cascade="all, delete-orphan")

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

35
app/schemas/comment.py Normal file
View File

@ -0,0 +1,35 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# Shared properties
class CommentBase(BaseModel):
content: Optional[str] = None
# Properties to receive on comment creation
class CommentCreate(CommentBase):
content: str
post_id: str
# Properties to receive on comment update
class CommentUpdate(CommentBase):
pass
# Properties shared by models stored in DB
class CommentInDBBase(CommentBase):
id: str
post_id: str
author_id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Properties to return to client
class Comment(CommentInDBBase):
pass
# Properties stored in DB but not returned to the client
class CommentInDB(CommentInDBBase):
pass

36
app/schemas/post.py Normal file
View File

@ -0,0 +1,36 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# Shared properties
class PostBase(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
is_published: Optional[bool] = True
# Properties to receive on post creation
class PostCreate(PostBase):
title: str
content: str
# Properties to receive on post update
class PostUpdate(PostBase):
pass
# Properties shared by models stored in DB
class PostInDBBase(PostBase):
id: str
author_id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Properties to return to client
class Post(PostInDBBase):
pass
# Properties stored in DB but not returned to the API client
class PostInDB(PostInDBBase):
pass

47
app/schemas/user.py Normal file
View File

@ -0,0 +1,47 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = None
is_active: Optional[bool] = True
is_superuser: bool = False
full_name: Optional[str] = None
# Properties to receive on user creation
class UserCreate(UserBase):
email: EmailStr
username: str
password: str
# Properties to receive on user update
class UserUpdate(UserBase):
password: Optional[str] = None
# Properties shared by models stored in DB
class UserInDBBase(UserBase):
id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Properties to return to client
class User(UserInDBBase):
pass
# Properties stored in DB but not returned to the client
class UserInDB(UserInDBBase):
hashed_password: str
# Token schema
class Token(BaseModel):
access_token: str
token_type: str
# Token payload
class TokenPayload(BaseModel):
sub: Optional[str] = None

39
main.py Normal file
View File

@ -0,0 +1,39 @@
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,
description="Blogging API with FastAPI and SQLite",
version="0.1.0",
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
)
# Set all CORS enabled origins
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router)
# Health check endpoint
@app.get("/health", tags=["health"])
async def health_check():
return {"status": "ok"}
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True,
)

87
migrations/env.py Normal file
View File

@ -0,0 +1,87 @@
import sys
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Add the project root to the Python path
sys.path.insert(0, '')
# 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
from app.db.base import Base # noqa: E402
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"},
render_as_batch=True,
)
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,86 @@
"""initial tables
Revision ID: 001
Revises:
Create Date: 2023-05-08 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() -> None:
# Create user table
op.create_table(
'user',
sa.Column('id', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_superuser', 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_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True)
# Create post table
op.create_table(
'post',
sa.Column('id', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('is_published', sa.Boolean(), nullable=True),
sa.Column('author_id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['author_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_post_id'), 'post', ['id'], unique=False)
op.create_index(op.f('ix_post_title'), 'post', ['title'], unique=False)
# Create comment table
op.create_table(
'comment',
sa.Column('id', sa.String(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('post_id', sa.String(), nullable=False),
sa.Column('author_id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['author_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['post_id'], ['post.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_comment_id'), 'comment', ['id'], unique=False)
def downgrade() -> None:
# Drop comment table
op.drop_index(op.f('ix_comment_id'), table_name='comment')
op.drop_table('comment')
# Drop post table
op.drop_index(op.f('ix_post_title'), table_name='post')
op.drop_index(op.f('ix_post_id'), table_name='post')
op.drop_table('post')
# Drop user table
op.drop_index(op.f('ix_user_username'), table_name='user')
op.drop_index(op.f('ix_user_id'), table_name='user')
op.drop_index(op.f('ix_user_full_name'), table_name='user')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')

14
requirements.txt Normal file
View File

@ -0,0 +1,14 @@
fastapi>=0.95.0
uvicorn>=0.21.1
sqlalchemy>=2.0.0
alembic>=1.10.3
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
email-validator>=2.0.0
python-dotenv>=1.0.0
ruff>=0.0.292
pytest>=7.3.1
httpx>=0.24.0