From 669d16ace5c42f78430048a4619828e1ea505d9b Mon Sep 17 00:00:00 2001 From: Automated Action Date: Fri, 16 May 2025 06:39:27 +0000 Subject: [PATCH] Remove API versioning and implement robust error handling for all endpoints --- README.md | 37 ++-- app/api/routers/tasks.py | 447 ++++++++++++++++++++++++++++++++++++--- app/core/config.py | 3 +- main.py | 34 +-- 4 files changed, 438 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index e736631..c76bf92 100644 --- a/README.md +++ b/README.md @@ -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' ``` diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index 971e6db..7a68f9c 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -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 \ No newline at end of file + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error completing task: {str(e)}" + ) \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 1990475..01604af 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 diff --git a/main.py b/main.py index 674f4ea..f575032 100644 --- a/main.py +++ b/main.py @@ -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",