Remove API versioning and implement robust error handling for all endpoints

This commit is contained in:
Automated Action 2025-05-16 06:39:27 +00:00
parent 522c749b23
commit 669d16ace5
4 changed files with 438 additions and 83 deletions

View File

@ -25,25 +25,14 @@ A RESTful API for managing tasks, built with FastAPI and SQLite.
- `GET /`: Get API information and available endpoints
### Task Management (Versioned API)
### Task Management
- `GET /api/v1/tasks`: Get all tasks
- `POST /api/v1/tasks`: Create a new task
- `GET /api/v1/tasks/{task_id}`: Get a specific task
- `PUT /api/v1/tasks/{task_id}`: Update a task
- `DELETE /api/v1/tasks/{task_id}`: Delete a task
- `POST /api/v1/tasks/{task_id}/complete`: Mark a task as completed
### Task Management (Legacy Unversioned API - Redirects to Versioned API)
The following endpoints are maintained for backward compatibility and will redirect to the versioned API:
- `GET /tasks`: Redirects to `/api/v1/tasks`
- `POST /tasks`: Redirects to `/api/v1/tasks`
- `GET /tasks/{task_id}`: Redirects to `/api/v1/tasks/{task_id}`
- `PUT /tasks/{task_id}`: Redirects to `/api/v1/tasks/{task_id}`
- `DELETE /tasks/{task_id}`: Redirects to `/api/v1/tasks/{task_id}`
- `POST /tasks/{task_id}/complete`: Redirects to `/api/v1/tasks/{task_id}/complete`
- `GET /tasks`: Get all tasks
- `POST /tasks`: Create a new task
- `GET /tasks/{task_id}`: Get a specific task
- `PUT /tasks/{task_id}`: Update a task
- `DELETE /tasks/{task_id}`: Delete a task
- `POST /tasks/{task_id}/complete`: Mark a task as completed
### Health and Diagnostic Endpoints
@ -56,7 +45,7 @@ The following endpoints are maintained for backward compatibility and will redir
```bash
curl -X 'POST' \
'https://your-domain.com/api/v1/tasks/' \
'https://taskmanagerapi-ttkjqk.backend.im/tasks/' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
@ -73,7 +62,7 @@ curl -X 'POST' \
```bash
curl -X 'GET' \
'https://your-domain.com/api/v1/tasks/' \
'https://taskmanagerapi-ttkjqk.backend.im/tasks/' \
-H 'accept: application/json'
```
@ -81,7 +70,7 @@ curl -X 'GET' \
```bash
curl -X 'GET' \
'https://your-domain.com/api/v1/tasks/1' \
'https://taskmanagerapi-ttkjqk.backend.im/tasks/1' \
-H 'accept: application/json'
```
@ -89,7 +78,7 @@ curl -X 'GET' \
```bash
curl -X 'PUT' \
'https://your-domain.com/api/v1/tasks/1' \
'https://taskmanagerapi-ttkjqk.backend.im/tasks/1' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
@ -103,7 +92,7 @@ curl -X 'PUT' \
```bash
curl -X 'DELETE' \
'https://your-domain.com/api/v1/tasks/1' \
'https://taskmanagerapi-ttkjqk.backend.im/tasks/1' \
-H 'accept: application/json'
```
@ -111,7 +100,7 @@ curl -X 'DELETE' \
```bash
curl -X 'POST' \
'https://your-domain.com/api/v1/tasks/1/complete' \
'https://taskmanagerapi-ttkjqk.backend.im/tasks/1/complete' \
-H 'accept: application/json'
```

View File

@ -21,11 +21,70 @@ def read_tasks(
"""
Retrieve tasks.
"""
if status:
tasks = crud.task.get_by_status(db, status=status)
else:
tasks = crud.task.get_multi(db, skip=skip, limit=limit)
return tasks
try:
import traceback
import sqlite3
from sqlalchemy import text
from app.db.session import db_file
print(f"Getting tasks with status: {status}, skip: {skip}, limit: {limit}")
# Try the normal SQLAlchemy approach first
try:
if status:
tasks = crud.task.get_by_status(db, status=status)
else:
tasks = crud.task.get_multi(db, skip=skip, limit=limit)
return tasks
except Exception as e:
print(f"Error getting tasks with SQLAlchemy: {e}")
print(traceback.format_exc())
# Continue to fallback
# Fallback to direct SQLite approach
try:
conn = sqlite3.connect(str(db_file))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
if status:
cursor.execute("SELECT * FROM task WHERE status = ? LIMIT ? OFFSET ?",
(status.value, limit, skip))
else:
cursor.execute("SELECT * FROM task LIMIT ? OFFSET ?", (limit, skip))
rows = cursor.fetchall()
# Convert to Task objects
tasks = []
for row in rows:
task_dict = dict(row)
# Convert completed to boolean
if 'completed' in task_dict:
task_dict['completed'] = bool(task_dict['completed'])
# Convert to object with attributes
class TaskResult:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
tasks.append(TaskResult(**task_dict))
conn.close()
return tasks
except Exception as e:
print(f"Error getting tasks with direct SQLite: {e}")
print(traceback.format_exc())
raise
except Exception as e:
print(f"Global error in read_tasks: {e}")
print(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving tasks: {str(e)}"
)
@router.post("/", response_model=Task, status_code=status.HTTP_201_CREATED)
@ -176,13 +235,71 @@ def read_task(
"""
Get task by ID.
"""
task = crud.task.get(db, id=task_id)
if not task:
try:
import traceback
import sqlite3
from app.db.session import db_file
print(f"Getting task with ID: {task_id}")
# Try the normal SQLAlchemy approach first
try:
task = crud.task.get(db, id=task_id)
if task:
return task
# Fall through to direct SQLite if task not found
except Exception as e:
print(f"Error getting task with SQLAlchemy: {e}")
print(traceback.format_exc())
# Continue to fallback
# Fallback to direct SQLite approach
try:
conn = sqlite3.connect(str(db_file))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,))
row = cursor.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
task_dict = dict(row)
# Convert completed to boolean
if 'completed' in task_dict:
task_dict['completed'] = bool(task_dict['completed'])
# Convert to object with attributes
class TaskResult:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
conn.close()
return TaskResult(**task_dict)
except HTTPException:
raise # Re-raise the 404 exception
except Exception as e:
print(f"Error getting task with direct SQLite: {e}")
print(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving task: {str(e)}"
)
except HTTPException:
raise # Re-raise any HTTP exceptions
except Exception as e:
print(f"Global error in read_task: {e}")
print(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving task: {str(e)}"
)
return task
@router.put("/{task_id}", response_model=Task)
@ -195,14 +312,136 @@ def update_task(
"""
Update a task.
"""
task = crud.task.get(db, id=task_id)
if not task:
try:
import traceback
import sqlite3
import json
from datetime import datetime
from app.db.session import db_file
print(f"Updating task with ID: {task_id}, data: {task_in}")
# Handle datetime conversion for due_date if present
if hasattr(task_in, "due_date") and task_in.due_date is not None:
if isinstance(task_in.due_date, str):
try:
task_in.due_date = datetime.fromisoformat(task_in.due_date.replace('Z', '+00:00'))
except Exception as e:
print(f"Error parsing due_date: {e}")
# Try the normal SQLAlchemy approach first
try:
task = crud.task.get(db, id=task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
updated_task = crud.task.update(db, db_obj=task, obj_in=task_in)
return updated_task
except HTTPException:
raise # Re-raise the 404 exception
except Exception as e:
print(f"Error updating task with SQLAlchemy: {e}")
print(traceback.format_exc())
# Continue to fallback
# Fallback to direct SQLite approach
try:
conn = sqlite3.connect(str(db_file))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# First check if task exists
cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,))
row = cursor.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
# Convert Pydantic model to dict, excluding unset values
updates = {}
model_data = task_in.model_dump(exclude_unset=True) if hasattr(task_in, "model_dump") else task_in.dict(exclude_unset=True)
# Only include fields that were provided in the update
for key, value in model_data.items():
if value is not None: # Skip None values
updates[key] = value
if not updates:
# No updates provided
task_dict = dict(row)
# Convert completed to boolean
if 'completed' in task_dict:
task_dict['completed'] = bool(task_dict['completed'])
# Return the unchanged task
class TaskResult:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
conn.close()
return TaskResult(**task_dict)
# Format datetime objects
if 'due_date' in updates and isinstance(updates['due_date'], datetime):
updates['due_date'] = updates['due_date'].isoformat()
# Add updated_at timestamp
updates['updated_at'] = datetime.utcnow().isoformat()
# Build the SQL update statement
set_clause = ", ".join([f"{key} = ?" for key in updates.keys()])
params = list(updates.values())
params.append(task_id) # For the WHERE clause
cursor.execute(f"UPDATE task SET {set_clause} WHERE id = ?", params)
conn.commit()
# Return the updated task
cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,))
updated_row = cursor.fetchone()
conn.close()
if updated_row:
task_dict = dict(updated_row)
# Convert completed to boolean
if 'completed' in task_dict:
task_dict['completed'] = bool(task_dict['completed'])
# Convert to object with attributes
class TaskResult:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
return TaskResult(**task_dict)
else:
raise Exception("Task was updated but could not be retrieved")
except HTTPException:
raise # Re-raise the 404 exception
except Exception as e:
print(f"Error updating task with direct SQLite: {e}")
print(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating task: {str(e)}"
)
except HTTPException:
raise # Re-raise any HTTP exceptions
except Exception as e:
print(f"Global error in update_task: {e}")
print(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating task: {str(e)}"
)
task = crud.task.update(db, db_obj=task, obj_in=task_in)
return task
@router.delete("/{task_id}", response_model=Task)
@ -214,14 +453,87 @@ def delete_task(
"""
Delete a task.
"""
task = crud.task.get(db, id=task_id)
if not task:
try:
import traceback
import sqlite3
from app.db.session import db_file
print(f"Deleting task with ID: {task_id}")
# First, get the task to return it later
task_to_return = None
# Try the normal SQLAlchemy approach first
try:
task = crud.task.get(db, id=task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
task_to_return = task
task = crud.task.remove(db, id=task_id)
return task
except HTTPException:
raise # Re-raise the 404 exception
except Exception as e:
print(f"Error deleting task with SQLAlchemy: {e}")
print(traceback.format_exc())
# Continue to fallback
# Fallback to direct SQLite approach
try:
conn = sqlite3.connect(str(db_file))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# First save the task data for the return value
cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,))
row = cursor.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
task_dict = dict(row)
# Convert completed to boolean
if 'completed' in task_dict:
task_dict['completed'] = bool(task_dict['completed'])
# Delete the task
cursor.execute("DELETE FROM task WHERE id = ?", (task_id,))
conn.commit()
conn.close()
# Convert to object with attributes
class TaskResult:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
return TaskResult(**task_dict)
except HTTPException:
raise # Re-raise the 404 exception
except Exception as e:
print(f"Error deleting task with direct SQLite: {e}")
print(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error deleting task: {str(e)}"
)
except HTTPException:
raise # Re-raise any HTTP exceptions
except Exception as e:
print(f"Global error in delete_task: {e}")
print(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error deleting task: {str(e)}"
)
task = crud.task.remove(db, id=task_id)
return task
@router.post("/{task_id}/complete", response_model=Task)
@ -233,10 +545,89 @@ def complete_task(
"""
Mark a task as completed.
"""
task = crud.task.mark_completed(db, task_id=task_id)
if not task:
try:
import traceback
import sqlite3
from datetime import datetime
from app.db.session import db_file
print(f"Marking task {task_id} as completed")
# Try the normal SQLAlchemy approach first
try:
task = crud.task.mark_completed(db, task_id=task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
return task
except Exception as e:
print(f"Error completing task with SQLAlchemy: {e}")
print(traceback.format_exc())
# Continue to fallback
# Fallback to direct SQLite approach
try:
conn = sqlite3.connect(str(db_file))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# First check if task exists
cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,))
row = cursor.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
# Update task to completed status
now = datetime.utcnow().isoformat()
cursor.execute(
"UPDATE task SET completed = ?, status = ?, updated_at = ? WHERE id = ?",
(1, "done", now, task_id)
)
conn.commit()
# Get the updated task
cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,))
updated_row = cursor.fetchone()
conn.close()
if updated_row:
task_dict = dict(updated_row)
# Convert completed to boolean
if 'completed' in task_dict:
task_dict['completed'] = bool(task_dict['completed'])
# Convert to object with attributes
class TaskResult:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
return TaskResult(**task_dict)
else:
raise Exception("Task was completed but could not be retrieved")
except HTTPException:
raise # Re-raise the 404 exception
except Exception as e:
print(f"Error completing task with direct SQLite: {e}")
print(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error completing task: {str(e)}"
)
except HTTPException:
raise # Re-raise any HTTP exceptions
except Exception as e:
print(f"Global error in complete_task: {e}")
print(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
return task
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error completing task: {str(e)}"
)

View File

@ -35,7 +35,8 @@ if DB_DIR is None:
class Settings(BaseSettings):
PROJECT_NAME: str = "Task Manager API"
API_V1_STR: str = "/api/v1"
# No API version prefix - use direct paths
API_PREFIX: str = ""
SECRET_KEY: str = secrets.token_urlsafe(32)
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8

34
main.py
View File

@ -68,47 +68,21 @@ async def validation_exception_handler(request: Request, exc: Exception):
content=error_detail,
)
# Include the API router with the version prefix
app.include_router(api_router, prefix=settings.API_V1_STR)
# Add support for compatibility with non-versioned endpoints
from fastapi import Request
from fastapi.responses import RedirectResponse
# Create a catch-all route for /tasks paths to redirect to versioned API
@app.get("/tasks", include_in_schema=False)
@app.post("/tasks", include_in_schema=False)
@app.get("/tasks/{task_id:path}", include_in_schema=False)
@app.put("/tasks/{task_id:path}", include_in_schema=False)
@app.delete("/tasks/{task_id:path}", include_in_schema=False)
@app.post("/tasks/{task_id:path}/complete", include_in_schema=False)
async def redirect_to_versioned_api(request: Request, task_id: str = None):
"""
Redirect unversioned API requests to the versioned API path
"""
target_url = str(request.url)
# Replace the /tasks part with /api/v1/tasks
versioned_url = target_url.replace("/tasks", f"{settings.API_V1_STR}/tasks", 1)
# Add debugging info
print(f"Redirecting from {target_url} to {versioned_url}")
# Use 307 to preserve the method and body
return RedirectResponse(url=versioned_url, status_code=307)
# Include the API router directly (no version prefix)
app.include_router(api_router)
@app.get("/", tags=["info"])
def api_info():
"""
API information endpoint with links to documentation and versioned endpoints
API information endpoint with links to documentation and endpoints
"""
return {
"name": settings.PROJECT_NAME,
"version": "1.0.0",
"description": "A RESTful API for managing tasks",
"endpoints": {
"api": f"{settings.API_V1_STR}",
"tasks": f"{settings.API_V1_STR}/tasks",
"tasks": "/tasks",
"docs": "/docs",
"redoc": "/redoc",
"health": "/health",