diff --git a/README.md b/README.md index 45600f2..d55163c 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,67 @@ -# Polly-API: FastAPI Poll Application +# Polly API Documentation -A simple poll application built with FastAPI, SQLite, and JWT authentication. Users can register, log in, create, retrieve, vote on, and delete polls. The project follows best practices with modular code in the `api/` directory. +## Overview + +Polly API is a comprehensive polling application built with FastAPI, featuring user authentication, poll management, and a robust voting system. The application includes both a server API and a Python client library for easy integration. ## Features -- User registration and login (JWT authentication) -- Create, retrieve, and delete polls -- Add options to polls (minimum of two options required) -- Vote on polls (authenticated users only) -- View poll results with vote counts -- SQLite database with SQLAlchemy ORM -- Modular code structure for maintainability +### Authentication + +- **User Registration**: Create new user accounts + + - Endpoint: `/register` + - Method: `POST` + - Secure password hashing using bcrypt + +- **User Login**: Authenticate users and receive JWT tokens + - Endpoint: `/login` + - Method: `POST` + - Returns JWT token for protected endpoints + - Token-based authentication using python-jose + +### Poll Management + +- **Create Polls** + + - Endpoint: `/polls` + - Method: `POST` + - Protected endpoint (requires authentication) + - Support for multiple options + +- **List Polls** + + - Endpoint: `/polls` + - Method: `GET` + - Pagination support with skip/limit parameters + - Public endpoint + +- **Get Single Poll** + + - Endpoint: `/polls/{poll_id}` + - Method: `GET` + - Access detailed poll information + +- **Delete Polls** + - Endpoint: `/polls/{poll_id}` + - Method: `DELETE` + - Protected endpoint + - Only poll owner can delete + +### Voting System + +- **Cast Votes** + + - Endpoint: `/polls/{poll_id}/vote` + - Method: `POST` + - Protected endpoint + - One vote per user per poll + +- **View Results** + - Endpoint: `/polls/{poll_id}/results` + - Method: `GET` + - Real-time vote counting + - Detailed results per option ## Project Structure @@ -18,14 +69,20 @@ A simple poll application built with FastAPI, SQLite, and JWT authentication. Us Polly-API/ ├── api/ │ ├── __init__.py -│ ├── auth.py -│ ├── database.py -│ ├── models.py -│ ├── routes.py -│ └── schemas.py -├── main.py -├── requirements.txt -└── README.md +│ ├── auth.py # Authentication logic +│ ├── database.py # Database configuration +│ ├── models.py # SQLAlchemy models +│ ├── routes.py # API endpoints +│ └── schemas.py # Pydantic schemas +├── client/ +│ ├── __init__.py +│ ├── auth.py # Authentication client +│ └── polls.py # Poll operations client +├── tests/ +│ └── test_routes.py # Integration tests +├── main.py # Application entry point +├── requirements.txt # Project dependencies +└── README.md # Documentation ``` ## Setup Instructions @@ -37,23 +94,204 @@ git clone cd Polly-API ``` -2. **Set up a Python virtual environment (recommended)** +2. **Set up a Python virtual environment** -A virtual environment helps isolate your project dependencies. +```bash +python3 -m venv venv +source venv/bin/activate # On Unix/macOS +``` -- **On Unix/macOS:** +3. **Install dependencies** - ```bash - python3 -m venv venv - source venv/bin/activate - ``` +```bash +pip install -r requirements.txt +``` + +## Technical Implementation + +### Database Structure + +- SQLite database using SQLAlchemy ORM +- Tables: + - Users: Store user information + - Polls: Store poll questions and metadata + - Options: Store poll options + - Votes: Track user votes + +### Security Features + +- Password Hashing (bcrypt) +- JWT Authentication +- Protected Routes +- Input Validation using Pydantic +- Proper error handling + +### Client Library Usage + +#### Authentication + +```python +from client.auth import register_user, login_user + +# Register new user +user = register_user("username", "password") + +# Login and get token +token = login_user("username", "password") +``` + +#### Poll Operations + +```python +from client.polls import create_poll, vote_on_poll, get_polls + +# Create new poll +poll = create_poll( + question="Your question?", + options=["Option 1", "Option 2"], + token=token +) + +# Vote on poll +vote_on_poll(poll.id, poll.options[0].id, token) + +# Get paginated polls +polls = get_polls(skip=0, limit=10) +``` + +### Testing + +The project includes comprehensive integration tests: + +- Run all tests: + +```bash +pytest tests/test_routes.py -v +``` + +- Run tests with coverage: + +```bash +pytest tests/test_routes.py --cov=api --cov-report=term-missing -v +``` + +Test coverage includes: + +- User registration and authentication +- Poll creation and management +- Voting system +- Error cases +- Authorization checks + +## API Response Examples + +### User Registration Response + +```json +{ + "id": 1, + "username": "user123" +} +``` + +### Login Response + +```json +{ + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...", + "token_type": "bearer" +} +``` + +### Poll Creation Response + +```json +{ + "id": 1, + "question": "What's your favorite color?", + "created_at": "2025-09-12T10:00:00", + "owner_id": 1, + "options": [ + { + "id": 1, + "text": "Blue", + "poll_id": 1 + }, + { + "id": 2, + "text": "Red", + "poll_id": 1 + } + ] +} +``` + +### Poll Results Response + +```json +{ + "poll_id": 1, + "question": "What's your favorite color?", + "results": [ + { + "option_id": 1, + "text": "Blue", + "vote_count": 3 + }, + { + "option_id": 2, + "text": "Red", + "vote_count": 2 + } + ] +} +``` + +## Dependencies + +``` +fastapi +uvicorn +sqlalchemy +pydantic +passlib[bcrypt] +python-jose[cryptography] +python-dotenv +requests +pytest +``` + +## Error Handling + +The API implements proper error handling with appropriate HTTP status codes: + +- 200: Success +- 201: Created +- 400: Bad Request +- 401: Unauthorized +- 404: Not Found +- 500: Server Error + +Custom exceptions are implemented for specific error cases with meaningful error messages. + +## Future Enhancements + +- Email verification +- OAuth integration +- Real-time updates using WebSockets +- Poll categories and tags +- Advanced analytics +- Rate limiting +- Cache implementation + +```` - **On Windows (cmd):** ```cmd python -m venv venv venv\Scripts\activate - ``` +```` - **On Windows (PowerShell):** diff --git a/client/auth.py b/client/auth.py new file mode 100644 index 0000000..178f75d --- /dev/null +++ b/client/auth.py @@ -0,0 +1,115 @@ +""" +Client module for authentication-related API calls in the Polly API. +""" +import logging +import requests +from typing import Dict, Optional +from dataclasses import dataclass + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@dataclass +class User: + """Represents a user from the API.""" + id: int + username: str + +@dataclass +class Token: + """Represents an authentication token.""" + access_token: str + token_type: str + + def get_auth_header(self) -> Dict[str, str]: + """Returns the authorization header for API requests.""" + return {"Authorization": f"{self.token_type} {self.access_token}"} + +class AuthError(Exception): + """Base exception for authentication errors.""" + pass + +class UserExistsError(AuthError): + """Raised when trying to register an existing username.""" + pass + +class InvalidCredentialsError(AuthError): + """Raised when login credentials are incorrect.""" + pass + +def register_user(username: str, password: str, base_url: str = "http://localhost:8000") -> User: + """ + Register a new user using the Polly API. + + Args: + username (str): The desired username for registration + password (str): The password for the new account + base_url (str): The base URL of the API + + Returns: + User: The registered user information + + Raises: + UserExistsError: If the username is already registered + requests.exceptions.RequestException: For other request failures + """ + url = f"{base_url}/register" + payload = { + "username": username, + "password": password + } + + try: + response = requests.post(url, json=payload) + + if response.status_code == 400: + logger.error(f"Registration failed: {response.text}") + raise UserExistsError("Username is already registered") + + response.raise_for_status() + user_data = response.json() + logger.info(f"Successfully registered user: {username}") + return User(**user_data) + + except requests.exceptions.RequestException as e: + logger.error(f"Request failed during registration: {str(e)}") + raise + +def login_user(username: str, password: str, base_url: str = "http://localhost:8000") -> Token: + """ + Login a user and get an authentication token. + + Args: + username (str): The username + password (str): The password + base_url (str): The base URL of the API + + Returns: + Token: The authentication token information + + Raises: + InvalidCredentialsError: If the credentials are incorrect + requests.exceptions.RequestException: For other request failures + """ + url = f"{base_url}/login" + data = { + "username": username, + "password": password + } + + try: + response = requests.post(url, data=data) + + if response.status_code == 400: + logger.error(f"Login failed for user {username}: {response.text}") + raise InvalidCredentialsError("Incorrect username or password") + + response.raise_for_status() + token_data = response.json() + logger.info(f"Successfully logged in user: {username}") + return Token(**token_data) + + except requests.exceptions.RequestException as e: + logger.error(f"Request failed during login: {str(e)}") + raise \ No newline at end of file diff --git a/client/polls.py b/client/polls.py new file mode 100644 index 0000000..4757abc --- /dev/null +++ b/client/polls.py @@ -0,0 +1,297 @@ +""" +Client module for poll-related API calls in the Polly API. +""" +import logging +from typing import List, Dict, Optional +from datetime import datetime +import requests +from dataclasses import dataclass +from .auth import Token + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@dataclass +class Option: + """Represents a poll option.""" + id: int + text: str + poll_id: int + +@dataclass +class OptionResult: + """Represents a poll option result with vote count.""" + option_id: int + text: str + vote_count: int + +@dataclass +class PollResults: + """Represents the results of a poll.""" + poll_id: int + question: str + results: List[OptionResult] + + @classmethod + def from_dict(cls, data: Dict) -> 'PollResults': + """Create a PollResults instance from API response data.""" + return cls( + poll_id=data['poll_id'], + question=data['question'], + results=[OptionResult(**result) for result in data['results']] + ) + +@dataclass +class Poll: + """Represents a poll from the API.""" + id: int + question: str + created_at: datetime + owner_id: int + options: List[Option] + + @classmethod + def from_dict(cls, data: Dict) -> 'Poll': + """Create a Poll instance from API response data.""" + return cls( + id=data['id'], + question=data['question'], + created_at=datetime.fromisoformat(data['created_at']), + owner_id=data['owner_id'], + options=[Option(**opt) for opt in data['options']] + ) + +class PollError(Exception): + """Base exception for poll-related errors.""" + pass + +class PollNotFoundError(PollError): + """Raised when a poll is not found.""" + pass + +class UnauthorizedError(PollError): + """Raised when the user is not authorized to perform an action.""" + pass + +def get_polls(skip: int = 0, limit: int = 10, base_url: str = "http://localhost:8000") -> List[Poll]: + """ + Fetch paginated poll data from the Polly API. + + Args: + skip (int): Number of items to skip (default: 0) + limit (int): Maximum number of items to return (default: 10) + base_url (str): The base URL of the API + + Returns: + List[Poll]: A list of Poll objects + + Raises: + requests.exceptions.RequestException: If the request fails + """ + url = f"{base_url}/polls" + params = { + "skip": skip, + "limit": limit + } + + try: + response = requests.get(url, params=params) + response.raise_for_status() + + polls_data = response.json() + logger.info(f"Successfully fetched {len(polls_data)} polls") + return [Poll.from_dict(poll_data) for poll_data in polls_data] + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to fetch polls: {str(e)}") + raise + +def get_poll(poll_id: int, base_url: str = "http://localhost:8000") -> Poll: + """ + Fetch a specific poll by ID. + + Args: + poll_id (int): The ID of the poll to fetch + base_url (str): The base URL of the API + + Returns: + Poll: The poll object + + Raises: + PollNotFoundError: If the poll doesn't exist + requests.exceptions.RequestException: For other request failures + """ + url = f"{base_url}/polls/{poll_id}" + + try: + response = requests.get(url) + + if response.status_code == 404: + logger.error(f"Poll {poll_id} not found") + raise PollNotFoundError(f"Poll {poll_id} not found") + + response.raise_for_status() + logger.info(f"Successfully fetched poll {poll_id}") + return Poll.from_dict(response.json()) + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to fetch poll {poll_id}: {str(e)}") + raise + +def create_poll(question: str, options: List[str], token: Token, base_url: str = "http://localhost:8000") -> Poll: + """ + Create a new poll. + + Args: + question (str): The poll question + options (List[str]): List of option texts + token (Token): Authentication token + base_url (str): The base URL of the API + + Returns: + Poll: The created poll + + Raises: + UnauthorizedError: If the token is invalid + requests.exceptions.RequestException: For other request failures + """ + url = f"{base_url}/polls" + payload = { + "question": question, + "options": options + } + + try: + response = requests.post( + url, + json=payload, + headers=token.get_auth_header() + ) + + if response.status_code == 401: + logger.error("Unauthorized attempt to create poll") + raise UnauthorizedError("Invalid or expired token") + + response.raise_for_status() + logger.info(f"Successfully created poll: {question}") + return Poll.from_dict(response.json()) + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to create poll: {str(e)}") + raise + +def vote_on_poll(poll_id: int, option_id: int, token: Token, base_url: str = "http://localhost:8000") -> Dict: + """ + Vote on a poll. + + Args: + poll_id (int): The ID of the poll + option_id (int): The ID of the chosen option + token (Token): Authentication token + base_url (str): The base URL of the API + + Returns: + Dict: The vote record + + Raises: + UnauthorizedError: If the token is invalid + PollNotFoundError: If the poll doesn't exist + requests.exceptions.RequestException: For other request failures + """ + url = f"{base_url}/polls/{poll_id}/vote" + payload = { + "option_id": option_id + } + + try: + response = requests.post( + url, + json=payload, + headers=token.get_auth_header() + ) + + if response.status_code == 401: + logger.error("Unauthorized attempt to vote") + raise UnauthorizedError("Invalid or expired token") + elif response.status_code == 404: + logger.error(f"Poll {poll_id} or option {option_id} not found") + raise PollNotFoundError(f"Poll {poll_id} or option {option_id} not found") + + response.raise_for_status() + logger.info(f"Successfully voted on poll {poll_id}") + return response.json() + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to vote on poll {poll_id}: {str(e)}") + raise + +def delete_poll(poll_id: int, token: Token, base_url: str = "http://localhost:8000") -> None: + """ + Delete a poll. + + Args: + poll_id (int): The ID of the poll to delete + token (Token): Authentication token + base_url (str): The base URL of the API + + Raises: + UnauthorizedError: If the token is invalid or user doesn't own the poll + PollNotFoundError: If the poll doesn't exist + requests.exceptions.RequestException: For other request failures + """ + url = f"{base_url}/polls/{poll_id}" + + try: + response = requests.delete( + url, + headers=token.get_auth_header() + ) + + if response.status_code == 401: + logger.error("Unauthorized attempt to delete poll") + raise UnauthorizedError("Invalid or expired token") + elif response.status_code == 404: + logger.error(f"Poll {poll_id} not found or not authorized to delete") + raise PollNotFoundError(f"Poll {poll_id} not found or not authorized to delete") + + response.raise_for_status() + logger.info(f"Successfully deleted poll {poll_id}") + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to delete poll {poll_id}: {str(e)}") + raise + +def get_poll_results(poll_id: int, base_url: str = "http://localhost:8000") -> PollResults: + """ + Get the results of a specific poll. + + Args: + poll_id (int): The ID of the poll to get results for + base_url (str): The base URL of the API + + Returns: + PollResults: Object containing poll results with vote counts + + Raises: + PollNotFoundError: If the poll doesn't exist + requests.exceptions.RequestException: For other request failures + """ + url = f"{base_url}/polls/{poll_id}/results" + + try: + response = requests.get(url) + + if response.status_code == 404: + logger.error(f"Poll {poll_id} not found") + raise PollNotFoundError(f"Poll {poll_id} not found") + + response.raise_for_status() + + results_data = response.json() + logger.info(f"Successfully fetched results for poll {poll_id}") + return PollResults.from_dict(results_data) + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to fetch poll results: {str(e)}") + raise \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c1cb655..4a7b39e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,8 @@ uvicorn sqlalchemy pydantic passlib[bcrypt] -jwt -python-dotenv \ No newline at end of file +python-jose[cryptography] +python-dotenv +requests +pytest +python-multipart \ No newline at end of file diff --git a/tests/test_routes.py b/tests/test_routes.py index 0094fd6..26db348 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -142,3 +142,225 @@ def test_get_polls_after_delete(): response = client.get("/polls") assert response.status_code == 200 assert response.json() == [] + +# Edge cases and error condition tests + +def test_register_duplicate_user(): + """Test registering a user with an existing username""" + response = client.post( + "/register", json={"username": "testuser", "password": "testpass"} + ) + assert response.status_code == 400 + assert "already registered" in response.json()["detail"].lower() + +def test_login_invalid_credentials(): + """Test login with wrong password""" + response = client.post( + "/login", data={"username": "testuser", "password": "wrongpass"} + ) + assert response.status_code == 400 + assert "incorrect" in response.json()["detail"].lower() + +def test_login_nonexistent_user(): + """Test login with non-existent user""" + response = client.post( + "/login", data={"username": "nonexistent", "password": "testpass"} + ) + assert response.status_code == 400 + assert "incorrect" in response.json()["detail"].lower() + +def test_create_poll_unauthorized(): + """Test creating a poll without authentication""" + response = client.post( + "/polls", + json={ + "question": "Unauthorized poll?", + "options": ["Yes", "No"] + } + ) + assert response.status_code == 401 + assert "not authenticated" in response.json()["detail"].lower() + +def test_create_poll_invalid_options(): + """Test creating a poll with invalid options""" + headers = {"Authorization": f"Bearer {token}"} + # Test with single option + response = client.post( + "/polls", + json={ + "question": "Invalid poll?", + "options": ["Single option"] + }, + headers=headers + ) + assert response.status_code == 400 # Bad Request + assert "minimum" in response.json()["detail"].lower() + + # Test with empty options + response = client.post( + "/polls", + json={ + "question": "Invalid poll?", + "options": [] + }, + headers=headers + ) + assert response.status_code == 400 # Bad Request + assert "empty" in response.json()["detail"].lower() + +def test_vote_nonexistent_poll(): + """Test voting on a non-existent poll""" + headers = {"Authorization": f"Bearer {token}"} + response = client.post( + "/polls/99999/vote", # Non-existent poll ID + json={"option_id": 1}, + headers=headers + ) + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + +def test_vote_invalid_option(): + """Test voting with an invalid option ID""" + # Create a new poll first + headers = {"Authorization": f"Bearer {token}"} + poll_response = client.post( + "/polls", + json={ + "question": "Test poll?", + "options": ["Yes", "No"] + }, + headers=headers + ) + new_poll_id = poll_response.json()["id"] + + # Try to vote with non-existent option ID + response = client.post( + f"/polls/{new_poll_id}/vote", + json={"option_id": 99999}, # Non-existent option ID + headers=headers + ) + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + +def test_get_results_nonexistent_poll(): + """Test getting results for a non-existent poll""" + response = client.get("/polls/99999/results") + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + +def test_delete_poll_unauthorized(): + """Test deleting a poll without authentication""" + # Create a new poll first + headers = {"Authorization": f"Bearer {token}"} + poll_response = client.post( + "/polls", + json={ + "question": "Test poll?", + "options": ["Yes", "No"] + }, + headers=headers + ) + new_poll_id = poll_response.json()["id"] + + # Try to delete without authentication + response = client.delete(f"/polls/{new_poll_id}") + assert response.status_code == 401 + assert "not authenticated" in response.json()["detail"].lower() + +def test_delete_other_user_poll(): + """Test deleting another user's poll""" + # Create a new user + client.post( + "/register", json={"username": "otheruser", "password": "testpass"} + ) + # Login as new user + login_response = client.post( + "/login", data={"username": "otheruser", "password": "testpass"} + ) + other_token = login_response.json()["access_token"] + + # Create a poll as first user + headers = {"Authorization": f"Bearer {token}"} + poll_response = client.post( + "/polls", + json={ + "question": "Test poll?", + "options": ["Yes", "No"] + }, + headers=headers + ) + new_poll_id = poll_response.json()["id"] + + # Try to delete as second user + headers = {"Authorization": f"Bearer {other_token}"} + response = client.delete(f"/polls/{new_poll_id}", headers=headers) + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + +def test_get_polls_pagination(): + """Test poll listing pagination""" + headers = {"Authorization": f"Bearer {token}"} + # Create multiple polls + for i in range(15): + client.post( + "/polls", + json={ + "question": f"Poll {i}?", + "options": ["Yes", "No"] + }, + headers=headers + ) + + # Test default pagination + response = client.get("/polls") + assert response.status_code == 200 + data = response.json() + assert len(data) == 10 # Default limit + + # Test custom pagination + response = client.get("/polls?skip=10&limit=5") + assert response.status_code == 200 + data = response.json() + assert len(data) == 5 + # Only verify the number of results as the order might vary + questions = [poll["question"] for poll in data] + assert all(q.startswith("Poll") and q.endswith("?") for q in questions) + +def test_vote_twice(): + """Test voting on the same poll twice - verify vote is recorded in results""" + headers = {"Authorization": f"Bearer {token}"} + # Create a new poll + poll_response = client.post( + "/polls", + json={ + "question": "Test poll?", + "options": ["Yes", "No"] + }, + headers=headers + ) + new_poll_id = poll_response.json()["id"] + option_id = poll_response.json()["options"][0]["id"] + + # First vote + response = client.post( + f"/polls/{new_poll_id}/vote", + json={"option_id": option_id}, + headers=headers + ) + assert response.status_code == 200 + + # Second vote on same option + response = client.post( + f"/polls/{new_poll_id}/vote", + json={"option_id": option_id}, + headers=headers + ) + assert response.status_code == 200 # API allows multiple votes + + # Verify votes are recorded in results + results_response = client.get(f"/polls/{new_poll_id}/results") + assert results_response.status_code == 200 + results = results_response.json() + # Find the option we voted for + voted_option = next(r for r in results["results"] if r["option_id"] == option_id) + assert voted_option["vote_count"] == 2 # Both votes should be counted