mirror of
https://github.com/patrickbeane/quote-of-the-day.git
synced 2026-03-28 04:45:32 +00:00
Initial commit: Quote of the Day API
Secure, rate‑limited Flask + Gunicorn microservice with SQLite persistence, delivery tracking, and systemd deployment config. Includes setup script, HTML template, and production‑ready README.
This commit is contained in:
138
README.md
Normal file
138
README.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
## Quote of the Day API
|
||||||
|
|
||||||
|
**A secure, rate‑limited microservice** serving curated jokes and quotes from a persistent SQLite database. Built for fun, engineered for production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Overview
|
||||||
|
|
||||||
|
Quote of the Day is a small but production-minded API designed to demonstrate:
|
||||||
|
|
||||||
|
- **Rate limiting** – `flask-limiter` enforces per‑IP request caps to prevent abuse.
|
||||||
|
- **Persistent storage** – SQLite replaces JSON to ensure atomic reads/writes and avoid race conditions under concurrent access.
|
||||||
|
- **Proxy awareness** – `ProxyFix` middleware ensures correct client IP logging behind reverse proxies.
|
||||||
|
- **Organic usage** – The live API has been discovered and now serves hundreds of requests daily.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- Randomized quote delivery with delivery count tracking
|
||||||
|
- Plain text and JSON endpoints
|
||||||
|
- Rate-limited access to prevent abuse
|
||||||
|
- Systemd-ready deployment with Gunicorn
|
||||||
|
- SQLite-backed persistence for safe concurrent access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂 Architecture
|
||||||
|
```
|
||||||
|
[ Client ]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[ Flask + Gunicorn ]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[ SQLite DB (quotes.db) ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description | Rate Limit |
|
||||||
|
|--------|--------------|--------------------------------------------|----------------|
|
||||||
|
| GET | `/` | HTML frontend (quotes.html) | N/A |
|
||||||
|
| GET | `/quote` | Returns a random quote as plain text | 20/minute |
|
||||||
|
| GET | `/api/quote` | Returns a random quote as JSON | 50/minute |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Running Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repo
|
||||||
|
git clone https://github.com/patrickbeane/quote-of-the-day.git
|
||||||
|
cd quote-of-the-day
|
||||||
|
|
||||||
|
# (Optional) Create a virtual environment
|
||||||
|
python3 -m venv venv && source venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Edit `add-quotes.py` with your favorite quotes
|
||||||
|
nano add-quotes.py
|
||||||
|
|
||||||
|
# Run `add-quotes.py` to generate the DB
|
||||||
|
python add-quotes.py
|
||||||
|
|
||||||
|
# Run the API
|
||||||
|
python quotes.py
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://<your-server-ip>:5051` for the HTML frontend or `http://<your-server-ip>:5051/api/quote` for JSON output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Example Output
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"quote": "Strive not to be a success, but rather to be of value.",
|
||||||
|
"author": "Albert Einstein",
|
||||||
|
"delivered": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠 Deployment with systemd
|
||||||
|
|
||||||
|
For production use, this service can be run behind Gunicorn and managed via systemd.
|
||||||
|
|
||||||
|
A sample unit file is included in [`deploy/quotes.service`](deploy/quotes.service):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/quotes.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reexec
|
||||||
|
sudo systemctl enable quotes
|
||||||
|
sudo systemctl start quotes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Production Considerations
|
||||||
|
|
||||||
|
- **Rate limiting** prevents abuse and keeps the service responsive.
|
||||||
|
- **SQLite** ensures atomic writes and safe concurrent access.
|
||||||
|
- **ProxyFix** ensures accurate IP logging behind reverse proxies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗 Production Deployment (on my infra)
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Client ]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[ Hermes (Docker + Caddy) ]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[ Hades (Flask + Gunicorn) ]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[ SQLite DB (quotes.db) ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Fun Fact
|
||||||
|
|
||||||
|
The live API has been hit by automated clients and bots, resulting in some quotes being "delivered" hundreds of times - a real‑world example of why rate limiting and logging matter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
|
||||||
|
MIT License – free to use, modify, and share.
|
||||||
25
add-quotes.py
Normal file
25
add-quotes.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect("quotes.db")
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
quotes = [
|
||||||
|
("Strive not to be a success, but rather to be of value.", "Albert Einstein"),
|
||||||
|
("I find that the harder I work, the more luck I seem to have.", "Thomas Jefferson")
|
||||||
|
]
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS quotes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
quote TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
delivered INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
for quote, author in quotes:
|
||||||
|
c.execute("INSERT INTO quotes (quote, author) VALUES (?, ?)", (quote, author))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
17
deploy/quotes.service
Normal file
17
deploy/quotes.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# /etc/systemd/system/quotes.service
|
||||||
|
[Unit]
|
||||||
|
Description=Flask Quote and API (Gunicorn)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=yourusername
|
||||||
|
WorkingDirectory=/home/yourusername/quote-of-the-day
|
||||||
|
ExecStart=/home/yourusername/quote-of-the-day/venv/bin/gunicorn quotes:app --workers 2 --bind 0.0.0.0:5051
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment="PATH=/home/yourusername/quote-of-the-day/venv/bin:/usr/bin"
|
||||||
|
Environment="VIRTUAL_ENV=/home/yourusername/quote-of-the-day/venv"
|
||||||
|
Environment="FLASK_ENV=production"
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
65
quotes.py
Normal file
65
quotes.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from flask import Flask, jsonify, render_template
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
import sqlite3
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||||
|
limiter = Limiter(key_func=get_remote_address, default_limits=["50/hour"])
|
||||||
|
limiter.init_app(app)
|
||||||
|
random.seed()
|
||||||
|
|
||||||
|
DB_FILE = "quotes.db"
|
||||||
|
|
||||||
|
def verify_db():
|
||||||
|
if not os.path.exists(DB_FILE):
|
||||||
|
raise RuntimeError("quotes.db not found. Run add-quotes.py to initialize.")
|
||||||
|
|
||||||
|
def get_random_quote():
|
||||||
|
"""Fetch a random quote from the database and increment delivered count"""
|
||||||
|
conn = sqlite3.connect(DB_FILE)
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# Pick a random quote ID
|
||||||
|
c.execute("SELECT id, quote, author, delivered FROM quotes ORDER BY RANDOM() LIMIT 1")
|
||||||
|
row = c.fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
quote_id, quote_text, author, delivered = row
|
||||||
|
|
||||||
|
# Increment delivered count atomically
|
||||||
|
c.execute("UPDATE quotes SET delivered = delivered + 1 WHERE id = ?", (quote_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {"id": quote_id, "quote": quote_text, "author": author, "delivered": delivered + 1}
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def main():
|
||||||
|
return render_template("quotes.html")
|
||||||
|
|
||||||
|
@app.route("/quote")
|
||||||
|
@limiter.limit("20/minute")
|
||||||
|
def quote():
|
||||||
|
quote = get_random_quote()
|
||||||
|
if not quote:
|
||||||
|
return "No quotes found in database!", 500
|
||||||
|
return f"{quote['quote']} - {quote['author']}"
|
||||||
|
|
||||||
|
@app.route("/api/quote")
|
||||||
|
@limiter.limit("50/minute")
|
||||||
|
def quote_api():
|
||||||
|
quote = get_random_quote()
|
||||||
|
if not quote:
|
||||||
|
return jsonify({"error": "No quotes found"}), 500
|
||||||
|
return jsonify(quote)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
verify_db()
|
||||||
|
"""Running on Oracle's private network, in my instance - here it is 0.0.0.0 so only one node is needed"""
|
||||||
|
app.run(host="0.0.0.0", port=5051, debug=True)
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Flask
|
||||||
|
flask-limiter
|
||||||
|
Werkzeug
|
||||||
|
gunicorn
|
||||||
11
templates/quotes.html
Normal file
11
templates/quotes.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Quote of the Day</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Quote of the Day</h1>
|
||||||
|
<p>Visit <code>/quote</code> for plain text or <code>/api/quote</code> for JSON.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user