From b8a91103e9e070f44156a45ae6c4174c6e418af2 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Sun, 12 Oct 2025 19:33:45 +0000 Subject: [PATCH] Now we have an API --- .env.example | 5 +- api/Dockerfile | 16 ++ api/README.md | 222 ++++++++++++++++++++++++++ api/main.py | 360 +++++++++++++++++++++++++++++++++++++++++++ api/requirements.txt | 7 + api/test_api.sh | 30 ++++ docker-compose.yaml | 23 ++- 7 files changed, 660 insertions(+), 3 deletions(-) create mode 100644 api/Dockerfile create mode 100644 api/README.md create mode 100644 api/main.py create mode 100644 api/requirements.txt create mode 100755 api/test_api.sh diff --git a/.env.example b/.env.example index b7d7e9b..217999c 100644 --- a/.env.example +++ b/.env.example @@ -16,4 +16,7 @@ MYSQL_PORT=3306 MYSQL_DATABASE=maillist MYSQL_USER=maillist MYSQL_PASSWORD=change_this_password -MYSQL_ROOT_PASSWORD=change_this_root_password \ No newline at end of file +MYSQL_ROOT_PASSWORD=change_this_root_password + +# API Configuration +API_TOKEN=change_this_to_a_secure_random_token \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..3b89281 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY . . + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..b3a3ccc --- /dev/null +++ b/api/README.md @@ -0,0 +1,222 @@ +# Mailing List API Documentation + +REST API for managing mailing lists and members with token-based authentication. + +## Base URL + +``` +http://localhost:8000 +``` + +## Authentication + +All endpoints (except `/` and `/health`) require Bearer token authentication. + +Add the token to request headers: +``` +Authorization: Bearer your_api_token_here +``` + +Set your API token in `.env`: +``` +API_TOKEN=your_secure_token_here +``` + +## Endpoints + +### Health Check + +**GET** `/health` +- No authentication required +- Returns API and database status + +```bash +curl http://localhost:8000/health +``` + +### Mailing Lists + +**GET** `/lists` +- Get all mailing lists + +```bash +curl -H "Authorization: Bearer your_token" http://localhost:8000/lists +``` + +**GET** `/lists/{list_id}` +- Get a specific mailing list + +```bash +curl -H "Authorization: Bearer your_token" http://localhost:8000/lists/1 +``` + +**POST** `/lists` +- Create a new mailing list + +```bash +curl -X POST http://localhost:8000/lists \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{ + "list_name": "Developers", + "list_email": "dev@lists.sasalliance.org", + "description": "Developer discussions", + "active": true + }' +``` + +**PATCH** `/lists/{list_id}` +- Update a mailing list + +```bash +curl -X PATCH http://localhost:8000/lists/1 \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{ + "description": "Updated description", + "active": false + }' +``` + +**DELETE** `/lists/{list_id}` +- Delete a mailing list + +```bash +curl -X DELETE http://localhost:8000/lists/1 \ + -H "Authorization: Bearer your_token" +``` + +### Members + +**GET** `/members` +- Get all members + +```bash +curl -H "Authorization: Bearer your_token" http://localhost:8000/members +``` + +**GET** `/members/{member_id}` +- Get a specific member + +```bash +curl -H "Authorization: Bearer your_token" http://localhost:8000/members/1 +``` + +**POST** `/members` +- Create a new member + +```bash +curl -X POST http://localhost:8000/members \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Doe", + "email": "john.doe@example.com", + "active": true + }' +``` + +**PATCH** `/members/{member_id}` +- Update a member + +```bash +curl -X PATCH http://localhost:8000/members/1 \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Q. Doe", + "active": true + }' +``` + +**DELETE** `/members/{member_id}` +- Delete a member + +```bash +curl -X DELETE http://localhost:8000/members/1 \ + -H "Authorization: Bearer your_token" +``` + +### Subscriptions + +**GET** `/lists/{list_id}/members` +- Get all members of a specific list + +```bash +curl -H "Authorization: Bearer your_token" http://localhost:8000/lists/1/members +``` + +**POST** `/subscriptions` +- Subscribe a member to a list + +```bash +curl -X POST http://localhost:8000/subscriptions \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{ + "list_email": "community@lists.sasalliance.org", + "member_email": "john.doe@example.com", + "active": true + }' +``` + +**DELETE** `/subscriptions?list_email=X&member_email=Y` +- Unsubscribe a member from a list + +```bash +curl -X DELETE "http://localhost:8000/subscriptions?list_email=community@lists.sasalliance.org&member_email=john.doe@example.com" \ + -H "Authorization: Bearer your_token" +``` + +## Interactive Documentation + +Once the API is running, visit: + +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc + +These provide interactive API documentation where you can test endpoints directly in your browser. + +## Example Workflow + +1. **Create a new member:** +```bash +curl -X POST http://localhost:8000/members \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{"name": "Jane Smith", "email": "jane@example.com", "active": true}' +``` + +2. **Create a new mailing list:** +```bash +curl -X POST http://localhost:8000/lists \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{"list_name": "Marketing", "list_email": "marketing@lists.sasalliance.org", "active": true}' +``` + +3. **Subscribe the member to the list:** +```bash +curl -X POST http://localhost:8000/subscriptions \ + -H "Authorization: Bearer your_token" \ + -H "Content-Type: application/json" \ + -d '{"list_email": "marketing@lists.sasalliance.org", "member_email": "jane@example.com"}' +``` + +4. **Verify the subscription:** +```bash +curl -H "Authorization: Bearer your_token" http://localhost:8000/lists/1/members +``` + +## Error Responses + +- `401 Unauthorized` - Invalid or missing authentication token +- `404 Not Found` - Resource not found +- `400 Bad Request` - Invalid request data +- `500 Internal Server Error` - Database or server error + +## Notes + +- All changes take effect immediately in Postfix (no reload needed) +- Email validation is enforced on all email fields +- Deleting a list or member also removes associated subscriptions (CASCADE) diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..692b0e1 --- /dev/null +++ b/api/main.py @@ -0,0 +1,360 @@ +""" +Mailing List Management API +FastAPI-based REST API for managing mailing lists and members +""" +from fastapi import FastAPI, HTTPException, Depends, Header +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel, EmailStr +from typing import List, Optional +import mysql.connector +from mysql.connector import Error +import os +from contextlib import contextmanager + +# Configuration +API_TOKEN = os.getenv('API_TOKEN', 'change-this-token') +MYSQL_HOST = os.getenv('MYSQL_HOST', 'mysql') +MYSQL_PORT = int(os.getenv('MYSQL_PORT', 3306)) +MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'maillist') +MYSQL_USER = os.getenv('MYSQL_USER', 'maillist') +MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '') + +# FastAPI app +app = FastAPI( + title="Mailing List Manager API", + description="API for managing mailing lists and members", + version="1.0.0" +) + +security = HTTPBearer() + +# Database connection +@contextmanager +def get_db(): + """Database connection context manager""" + connection = None + try: + connection = mysql.connector.connect( + host=MYSQL_HOST, + port=MYSQL_PORT, + database=MYSQL_DATABASE, + user=MYSQL_USER, + password=MYSQL_PASSWORD + ) + yield connection + except Error as e: + raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") + finally: + if connection and connection.is_connected(): + connection.close() + +# Authentication +def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): + """Verify API token""" + if credentials.credentials != API_TOKEN: + raise HTTPException(status_code=401, detail="Invalid authentication token") + return credentials.credentials + +# Pydantic models +class MailingList(BaseModel): + list_id: Optional[int] = None + list_name: str + list_email: EmailStr + description: Optional[str] = None + active: bool = True + +class MailingListUpdate(BaseModel): + list_name: Optional[str] = None + description: Optional[str] = None + active: Optional[bool] = None + +class Member(BaseModel): + member_id: Optional[int] = None + name: str + email: EmailStr + active: bool = True + +class MemberUpdate(BaseModel): + name: Optional[str] = None + active: Optional[bool] = None + +class Subscription(BaseModel): + list_email: EmailStr + member_email: EmailStr + active: bool = True + +# Routes +@app.get("/") +async def root(): + """API information""" + return { + "name": "Mailing List Manager API", + "version": "1.0.0", + "status": "running" + } + +@app.get("/health") +async def health(): + """Health check endpoint""" + try: + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("SELECT 1") + cursor.fetchone() + cursor.close() + return {"status": "healthy", "database": "connected"} + except Exception as e: + raise HTTPException(status_code=503, detail=f"Unhealthy: {str(e)}") + +# Mailing Lists endpoints +@app.get("/lists", response_model=List[MailingList]) +async def get_lists(token: str = Depends(verify_token)): + """Get all mailing lists""" + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM lists ORDER BY list_name") + lists = cursor.fetchall() + cursor.close() + return lists + +@app.get("/lists/{list_id}", response_model=MailingList) +async def get_list(list_id: int, token: str = Depends(verify_token)): + """Get a specific mailing list""" + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM lists WHERE list_id = %s", (list_id,)) + mailing_list = cursor.fetchone() + cursor.close() + + if not mailing_list: + raise HTTPException(status_code=404, detail="List not found") + return mailing_list + +@app.post("/lists", response_model=MailingList, status_code=201) +async def create_list(mailing_list: MailingList, token: str = Depends(verify_token)): + """Create a new mailing list""" + with get_db() as conn: + cursor = conn.cursor() + try: + cursor.execute( + "INSERT INTO lists (list_name, list_email, description, active) VALUES (%s, %s, %s, %s)", + (mailing_list.list_name, mailing_list.list_email, mailing_list.description, mailing_list.active) + ) + conn.commit() + mailing_list.list_id = cursor.lastrowid + cursor.close() + return mailing_list + except Error as e: + raise HTTPException(status_code=400, detail=f"Failed to create list: {str(e)}") + +@app.patch("/lists/{list_id}", response_model=MailingList) +async def update_list(list_id: int, updates: MailingListUpdate, token: str = Depends(verify_token)): + """Update a mailing list""" + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + + # Build update query dynamically + update_fields = [] + values = [] + + if updates.list_name is not None: + update_fields.append("list_name = %s") + values.append(updates.list_name) + if updates.description is not None: + update_fields.append("description = %s") + values.append(updates.description) + if updates.active is not None: + update_fields.append("active = %s") + values.append(updates.active) + + if not update_fields: + raise HTTPException(status_code=400, detail="No fields to update") + + values.append(list_id) + query = f"UPDATE lists SET {', '.join(update_fields)} WHERE list_id = %s" + + cursor.execute(query, values) + conn.commit() + + # Return updated list + cursor.execute("SELECT * FROM lists WHERE list_id = %s", (list_id,)) + updated_list = cursor.fetchone() + cursor.close() + + if not updated_list: + raise HTTPException(status_code=404, detail="List not found") + return updated_list + +@app.delete("/lists/{list_id}", status_code=204) +async def delete_list(list_id: int, token: str = Depends(verify_token)): + """Delete a mailing list""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM lists WHERE list_id = %s", (list_id,)) + conn.commit() + + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="List not found") + cursor.close() + +# Members endpoints +@app.get("/members", response_model=List[Member]) +async def get_members(token: str = Depends(verify_token)): + """Get all members""" + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM members ORDER BY name") + members = cursor.fetchall() + cursor.close() + return members + +@app.get("/members/{member_id}", response_model=Member) +async def get_member(member_id: int, token: str = Depends(verify_token)): + """Get a specific member""" + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM members WHERE member_id = %s", (member_id,)) + member = cursor.fetchone() + cursor.close() + + if not member: + raise HTTPException(status_code=404, detail="Member not found") + return member + +@app.post("/members", response_model=Member, status_code=201) +async def create_member(member: Member, token: str = Depends(verify_token)): + """Create a new member""" + with get_db() as conn: + cursor = conn.cursor() + try: + cursor.execute( + "INSERT INTO members (name, email, active) VALUES (%s, %s, %s)", + (member.name, member.email, member.active) + ) + conn.commit() + member.member_id = cursor.lastrowid + cursor.close() + return member + except Error as e: + raise HTTPException(status_code=400, detail=f"Failed to create member: {str(e)}") + +@app.patch("/members/{member_id}", response_model=Member) +async def update_member(member_id: int, updates: MemberUpdate, token: str = Depends(verify_token)): + """Update a member""" + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + + update_fields = [] + values = [] + + if updates.name is not None: + update_fields.append("name = %s") + values.append(updates.name) + if updates.active is not None: + update_fields.append("active = %s") + values.append(updates.active) + + if not update_fields: + raise HTTPException(status_code=400, detail="No fields to update") + + values.append(member_id) + query = f"UPDATE members SET {', '.join(update_fields)} WHERE member_id = %s" + + cursor.execute(query, values) + conn.commit() + + cursor.execute("SELECT * FROM members WHERE member_id = %s", (member_id,)) + updated_member = cursor.fetchone() + cursor.close() + + if not updated_member: + raise HTTPException(status_code=404, detail="Member not found") + return updated_member + +@app.delete("/members/{member_id}", status_code=204) +async def delete_member(member_id: int, token: str = Depends(verify_token)): + """Delete a member""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM members WHERE member_id = %s", (member_id,)) + conn.commit() + + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="Member not found") + cursor.close() + +# Subscription endpoints +@app.get("/lists/{list_id}/members", response_model=List[Member]) +async def get_list_members(list_id: int, token: str = Depends(verify_token)): + """Get all members of a specific list""" + with get_db() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT m.* + FROM members m + JOIN list_members lm ON m.member_id = lm.member_id + WHERE lm.list_id = %s AND lm.active = 1 + ORDER BY m.name + """, (list_id,)) + members = cursor.fetchall() + cursor.close() + return members + +@app.post("/subscriptions", status_code=201) +async def subscribe_member(subscription: Subscription, token: str = Depends(verify_token)): + """Subscribe a member to a list""" + with get_db() as conn: + cursor = conn.cursor() + try: + # Get list_id and member_id + cursor.execute("SELECT list_id FROM lists WHERE list_email = %s", (subscription.list_email,)) + list_result = cursor.fetchone() + if not list_result: + raise HTTPException(status_code=404, detail="List not found") + + cursor.execute("SELECT member_id FROM members WHERE email = %s", (subscription.member_email,)) + member_result = cursor.fetchone() + if not member_result: + raise HTTPException(status_code=404, detail="Member not found") + + list_id = list_result[0] + member_id = member_result[0] + + # Insert subscription + cursor.execute( + "INSERT INTO list_members (list_id, member_id, active) VALUES (%s, %s, %s)", + (list_id, member_id, subscription.active) + ) + conn.commit() + cursor.close() + + return {"message": "Subscription created", "list_email": subscription.list_email, "member_email": subscription.member_email} + except Error as e: + if "Duplicate entry" in str(e): + raise HTTPException(status_code=400, detail="Member already subscribed to this list") + raise HTTPException(status_code=400, detail=f"Failed to create subscription: {str(e)}") + +@app.delete("/subscriptions") +async def unsubscribe_member(list_email: EmailStr, member_email: EmailStr, token: str = Depends(verify_token)): + """Unsubscribe a member from a list""" + with get_db() as conn: + cursor = conn.cursor() + + cursor.execute(""" + DELETE lm FROM list_members lm + JOIN lists l ON lm.list_id = l.list_id + JOIN members m ON lm.member_id = m.member_id + WHERE l.list_email = %s AND m.email = %s + """, (list_email, member_email)) + + conn.commit() + + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="Subscription not found") + + cursor.close() + return {"message": "Unsubscribed successfully"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..028271e --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +mysql-connector-python==8.2.0 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-multipart==0.0.6 +email-validator==2.1.0 diff --git a/api/test_api.sh b/api/test_api.sh new file mode 100755 index 0000000..b65aabe --- /dev/null +++ b/api/test_api.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Quick test script for the Mailing List API + +API_URL="http://localhost:8000" +TOKEN="your_api_token_here_change_this" + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}Testing Mailing List API${NC}\n" + +echo -e "${GREEN}1. Health Check:${NC}" +curl -s $API_URL/health | jq . +echo -e "\n" + +echo -e "${GREEN}2. Get All Lists:${NC}" +curl -s -H "Authorization: Bearer $TOKEN" $API_URL/lists | jq . +echo -e "\n" + +echo -e "${GREEN}3. Get All Members:${NC}" +curl -s -H "Authorization: Bearer $TOKEN" $API_URL/members | jq . +echo -e "\n" + +echo -e "${GREEN}4. Get Members of Community List:${NC}" +curl -s -H "Authorization: Bearer $TOKEN" $API_URL/lists/1/members | jq . +echo -e "\n" + +echo -e "${BLUE}Visit http://localhost:8000/docs for interactive API documentation${NC}" diff --git a/docker-compose.yaml b/docker-compose.yaml index 8bd7636..f3d1423 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,9 @@ version: "3.9" + +networks: + maillist-internal: + driver: bridge + services: mysql: image: mysql:8.0 @@ -11,8 +16,8 @@ services: volumes: - mysql_data:/var/lib/mysql - ./database/schema.sql:/docker-entrypoint-initdb.d/schema.sql - ports: - - "3307:3306" + networks: + - maillist-internal healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s @@ -28,6 +33,20 @@ services: condition: service_healthy ports: - "25:25" + networks: + - maillist-internal + + api: + build: ./api + container_name: maillist-api + env_file: .env + depends_on: + mysql: + condition: service_healthy + ports: + - "8000:8000" + networks: + - maillist-internal volumes: mysql_data: