diff --git a/flask-mongo/.gitignore b/flask-mongo/.gitignore new file mode 100644 index 0000000..a12bc69 --- /dev/null +++ b/flask-mongo/.gitignore @@ -0,0 +1,79 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environment variables +.env + +# Virtual Environment +venv/ +env/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Logs +*.log +logs/ + +# Local development +.DS_Store + +# Keploy test data +keploy/ + +# Temp files +*.swp +*~ + +# Coverage reports +htmlcov/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Jupyter Notebook +.ipynb_checkpoints + +# Local development +*.local + +# Docker +.docker/ + +# MongoDB +data/ + +# VS Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json diff --git a/flask-mongo/app.py b/flask-mongo/app.py index 8a6418b..1813999 100644 --- a/flask-mongo/app.py +++ b/flask-mongo/app.py @@ -1,43 +1,236 @@ +import os from flask import Flask, request, jsonify -from pymongo import MongoClient +from flask_restx import Api, Resource, fields, Namespace from flask_cors import CORS -import collections.abc - +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from pymongo import MongoClient, ReturnDocument +from pymongo.errors import DuplicateKeyError, OperationFailure +from bson import ObjectId +from config import get_config +from utils.logger import setup_logging, get_logger +from utils.validators import validate_json_content_type, validate_student_required +from utils.errors import ( + APIError, NotFoundError, ValidationError, + ConflictError, register_error_handlers +) +# Initialize Flask app app = Flask(__name__) -cors = CORS(app, resources={r"/api/*": {"origins": "*"}}) -# Connect to MongoDB -client = MongoClient('mongodb://mongo:27017/') -db = client['studentsdb'] +# Load configuration +app.config.from_object(get_config()) + +# Initialize CORS +CORS(app, resources={ + r"/api/*": { + "origins": "*", + "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + "allow_headers": ["Content-Type", "Authorization"] + } +}) + +# Initialize rate limiter +limiter = Limiter( + app=app, + key_func=get_remote_address, + default_limits=[app.config.get('RATELIMIT_DEFAULT', '200 per day')] +) + +# Setup logging +setup_logging(app.config) +logger = get_logger(__name__) + + +# Initialize MongoDB +client = MongoClient(app.config['MONGO_URI']) +db = client[app.config['MONGO_DB_NAME']] students_collection = db['students'] -@app.route('/students', methods=['GET']) -def get_students(): - students = list(students_collection.find({}, {'_id': 0})) - return jsonify(students) - -@app.route('/students/', methods=['GET']) -def get_student(student_id): - student = students_collection.find_one({'student_id': student_id}, {'_id': 0}) - return jsonify(student) - -@app.route('/students', methods=['POST']) -def create_student(): - new_student = request.json - students_collection.insert_one(new_student) - return jsonify({'message': 'Student created successfully'}) - -@app.route('/students/', methods=['PUT']) -def update_student(student_id): - updated_student = request.json - students_collection.update_one({'student_id': student_id}, {'$set': updated_student}) - return jsonify({'message': 'Student updated successfully'}) - -@app.route('/students/', methods=['DELETE']) -def delete_student(student_id): - students_collection.delete_one({'student_id': student_id}) - return jsonify({'message': 'Student deleted successfully'}) +# Create indexes +students_collection.create_index('student_id', unique=True) + +# Initialize Flask-RESTx +api = Api( + app, + version='1.0', + title='Student Management API', + description='A RESTful API for managing student records', + doc='/api/docs', + default='Students', + default_label='Student operations', + validate=True +) + +# Define models +student_model = api.model('Student', { + 'student_id': fields.String(required=True, description='Unique student identifier'), + 'name': fields.String(required=True, description='Student full name'), + 'age': fields.Integer(required=True, description='Student age'), + 'email': fields.String(description='Student email address') +}) + +# Namespace +ns = Namespace('students', description='Student operations') + +@ns.route('/') +class StudentList(Resource): + @ns.doc('list_students') + @ns.marshal_list_with(student_model) + @limiter.limit("100 per hour") + def get(self): + """List all students""" + logger.info('Fetching all students') + try: + students = list(students_collection.find({}, {'_id': 0})) + return students + except Exception as e: + logger.error(f'Error fetching students: {str(e)}') + raise APIError('Failed to retrieve students', status_code=500) + + @ns.doc('create_student') + @ns.expect(student_model) + @ns.marshal_with(student_model, code=201) + @validate_json_content_type + @validate_student_required + @limiter.limit("50 per hour") + def post(self): + """Create a new student""" + data = request.get_json() + logger.info(f'Creating new student: {data}') + + try: + # Check if student already exists + if students_collection.find_one({"student_id": data['student_id']}): + raise ConflictError(f"Student with ID {data['student_id']} already exists") + + # Insert new student + result = students_collection.insert_one(data) + if not result.inserted_id: + raise APIError('Failed to create student', status_code=500) + + logger.info(f'Student created with ID: {data["student_id"]}') + return data, 201 + + except DuplicateKeyError: + raise ConflictError(f"Student with ID {data['student_id']} already exists") + except Exception as e: + logger.error(f'Error creating student: {str(e)}') + raise APIError('Failed to create student', status_code=500) + +@ns.route('/') +@ns.param('student_id', 'The student identifier') +@ns.response(404, 'Student not found') +class Student(Resource): + @ns.doc('get_student') + @ns.marshal_with(student_model) + def get(self, student_id): + """Fetch a student given its identifier""" + logger.info(f'Fetching student with ID: {student_id}') + student = students_collection.find_one( + {'student_id': student_id}, + {'_id': 0} + ) + + if not student: + logger.warning(f'Student not found with ID: {student_id}') + raise NotFoundError('Student not found') + + return student + + @ns.doc('update_student') + @ns.expect(student_model) + @ns.marshal_with(student_model) + @validate_json_content_type + @validate_student_required + def put(self, student_id): + """Update a student""" + data = request.get_json() + logger.info(f'Updating student with ID: {student_id}, data: {data}') + + try: + # Don't allow changing student_id + if 'student_id' in data and data['student_id'] != student_id: + raise ValidationError('Cannot change student_id') + + # Update the student + result = students_collection.find_one_and_update( + {'student_id': student_id}, + {'$set': data}, + return_document=ReturnDocument.AFTER, + projection={'_id': 0} + ) + + if not result: + raise NotFoundError('Student not found') + + logger.info(f'Student updated: {student_id}') + return result + + except Exception as e: + logger.error(f'Error updating student {student_id}: {str(e)}') + if isinstance(e, APIError): + raise + raise APIError('Failed to update student', status_code=500) + + @ns.doc('delete_student') + @ns.response(204, 'Student deleted') + def delete(self, student_id): + """Delete a student""" + logger.info(f'Deleting student with ID: {student_id}') + + try: + result = students_collection.delete_one({'student_id': student_id}) + if result.deleted_count == 0: + raise NotFoundError('Student not found') + + logger.info(f'Student deleted: {student_id}') + return '', 204 + + except Exception as e: + logger.error(f'Error deleting student {student_id}: {str(e)}') + if isinstance(e, APIError): + raise + raise APIError('Failed to delete student', status_code=500) + +# Register the namespace +api.add_namespace(ns, path='/api/students') + +# Register error handlers +register_error_handlers(app) + +# Health check endpoint +@app.route('/api/health') +def health_check(): + """Health check endpoint""" + try: + # Test database connection + client.admin.command('ping') + return jsonify({ + 'status': 'healthy', + 'database': 'connected' + }), 200 + except Exception as e: + logger.error(f'Health check failed: {str(e)}') + return jsonify({ + 'status': 'unhealthy', + 'database': 'disconnected', + 'error': str(e) + }), 500 if __name__ == '__main__': - app.run(host='0.0.0.0', port=6000, debug=True) + try: + # Log startup + logger.info('Starting Student Management API...') + logger.info(f'Environment: {app.config["ENV"]}') + logger.info(f'Debug mode: {app.config["DEBUG"]}') + + # Run the app + app.run( + host='0.0.0.0', + port=int(os.environ.get('PORT', 6000)), + debug=app.config.get('DEBUG', False) + ) + except Exception as e: + logger.critical(f'Failed to start application: {str(e)}') + raise diff --git a/flask-mongo/config.py b/flask-mongo/config.py new file mode 100644 index 0000000..ba95426 --- /dev/null +++ b/flask-mongo/config.py @@ -0,0 +1,55 @@ +import os +from dotenv import load_dotenv +from pathlib import Path + +# Load environment variables from .env file +env_path = Path('.') / '.env' +load_dotenv(dotenv_path=env_path) + +class Config: + """Base configuration""" + DEBUG = os.getenv('DEBUG', 'False') == 'True' + SECRET_KEY = os.getenv('SECRET_KEY', 'dev-key-please-change-in-production') + + # MongoDB configuration + MONGO_URI = os.getenv('MONGO_URI', 'mongodb://localhost:27017/') + MONGO_DB_NAME = os.getenv('MONGO_DB_NAME', 'studentsdb') + + # Rate limiting + RATELIMIT_DEFAULT = os.getenv('RATE_LIMIT', '200 per day') + + # Logging + LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') + LOG_FORMAT = os.getenv('LOG_FORMAT', + '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + +class DevelopmentConfig(Config): + """Development configuration""" + DEBUG = True + + +class TestingConfig(Config): + """Testing configuration""" + TESTING = True + MONGO_DB_NAME = 'test_studentsdb' + + +class ProductionConfig(Config): + """Production configuration""" + DEBUG = False + + +# Dictionary to map config names to classes +config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig +} + +def get_config(config_name=None): + """Retrieve environment configuration""" + if config_name is None: + config_name = os.getenv('FLASK_ENV', 'development') + return config.get(config_name, config['default']) diff --git a/flask-mongo/requirements.txt b/flask-mongo/requirements.txt index cbc61f9..302349e 100644 --- a/flask-mongo/requirements.txt +++ b/flask-mongo/requirements.txt @@ -1,14 +1,14 @@ -Flask -pymongo==4.4.1 -Flask-Cors==3.0.10 -Werkzeug==2.2.2 -annotated-types==0.7.0 -anyio==4.4.0 -certifi==2024.7.4 -charset-normalizer==3.3.2 -click==8.1.7 -coverage==7.6.0 -dnspython==2.6.1 +Flask==3.0.0 +pymongo==4.6.0 +Flask-Cors==4.0.0 +flask-limiter==3.5.0 +flask-swagger-ui==4.11.1 +flask-restx==1.1.0 +python-dotenv==1.0.0 +jsonschema==4.20.0 +python-json-logger==2.0.7 +Werkzeug==3.0.1 +blinker==1.7.0 email_validator==2.2.0 fastapi==0.111.1 fastapi-cli==0.0.4 diff --git a/flask-mongo/utils/errors.py b/flask-mongo/utils/errors.py new file mode 100644 index 0000000..be2e347 --- /dev/null +++ b/flask-mongo/utils/errors.py @@ -0,0 +1,89 @@ +from flask import jsonify +from werkzeug.exceptions import HTTPException + +class APIError(Exception): + """Base API error class""" + status_code = 400 + + def __init__(self, message, status_code=None, payload=None): + super().__init__() + self.message = message + if status_code is not None: + self.status_code = status_code + self.payload = payload + + def to_dict(self): + rv = dict(self.payload or ()) + rv['message'] = self.message + rv['status'] = 'error' + rv['code'] = self.status_code + return rv + +class NotFoundError(APIError): + """Raised when a resource is not found""" + status_code = 404 + +class ValidationError(APIError): + """Raised when validation fails""" + status_code = 400 + +class UnauthorizedError(APIError): + """Raised when authentication is required but not provided""" + status_code = 401 + +class ForbiddenError(APIError): + """Raised when the user doesn't have permission to access the resource""" + status_code = 403 + +class ConflictError(APIError): + """Raised when there's a conflict with the current state of the resource""" + status_code = 409 + +def register_error_handlers(app): + """Register error handlers for the application""" + + @app.errorhandler(APIError) + def handle_api_error(error): + """Handle API errors""" + response = jsonify(error.to_dict()) + response.status_code = error.status_code + return response + + @app.errorhandler(404) + def not_found_error(error): + """Handle 404 errors""" + return jsonify({ + 'status': 'error', + 'code': 404, + 'message': 'The requested resource was not found.' + }), 404 + + @app.errorhandler(500) + def internal_error(error): + """Handle 500 errors""" + # Log the error here + app.logger.error(f'Internal Server Error: {str(error)}') + return jsonify({ + 'status': 'error', + 'code': 500, + 'message': 'An internal server error occurred.' + }), 500 + + @app.errorhandler(HTTPException) + def handle_http_exception(error): + """Handle other HTTP exceptions""" + return jsonify({ + 'status': 'error', + 'code': error.code, + 'message': error.description + }), error.code + + @app.errorhandler(Exception) + def handle_generic_exception(error): + """Handle all other exceptions""" + app.logger.error(f'Unhandled exception: {str(error)}') + return jsonify({ + 'status': 'error', + 'code': 500, + 'message': 'An unexpected error occurred.' + }), 500 diff --git a/flask-mongo/utils/logger.py b/flask-mongo/utils/logger.py new file mode 100644 index 0000000..c7892cb --- /dev/null +++ b/flask-mongo/utils/logger.py @@ -0,0 +1,60 @@ +import logging +import sys +from pythonjsonlogger import jsonlogger +from flask import has_request_context, request +import time + +class RequestFormatter(jsonlogger.JsonFormatter): + """Custom formatter to include request details in logs""" + def add_fields(self, log_record, record, message_dict): + super().add_fields(log_record, record, message_dict) + if has_request_context(): + log_record['url'] = request.url + log_record['method'] = request.method + log_record['ip'] = request.remote_addr + + # Add timestamp in ISO format + log_record['timestamp'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) + + # Add log level and message + log_record['level'] = record.levelname + log_record['message'] = record.getMessage() + + # Add logger name + log_record['logger'] = record.name + + +def setup_logging(config): + """Configure application logging""" + logger = logging.getLogger() + logger.setLevel(getattr(logging, config.LOG_LEVEL)) + + # Clear any existing handlers + logger.handlers.clear() + + # Create console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(getattr(logging, config.LOG_LEVEL)) + + # Create formatter + formatter = RequestFormatter( + '%(timestamp)s %(level)s %(name)s %(message)s', + datefmt='%Y-%m-%dT%H:%M:%SZ' + ) + + # Add formatter to console handler + console_handler.setFormatter(formatter) + + # Add console handler to logger + logger.addHandler(console_handler) + + # Configure Flask's default logger + flask_logger = logging.getLogger('werkzeug') + flask_logger.setLevel(logging.ERROR) + + return logger + + +def get_logger(name): + """Get a logger instance with the given name""" + return logging.getLogger(name) diff --git a/flask-mongo/utils/validators.py b/flask-mongo/utils/validators.py new file mode 100644 index 0000000..207a65b --- /dev/null +++ b/flask-mongo/utils/validators.py @@ -0,0 +1,85 @@ +import re +from functools import wraps +from flask import jsonify, request +from jsonschema import validate, ValidationError + +# Define validation schemas +STUDENT_SCHEMA = { + "type": "object", + "properties": { + "student_id": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z0-9_-]+$", + "error_message": "Student ID must be a non-empty alphanumeric string" + }, + "name": { + "type": "string", + "minLength": 2, + "maxLength": 100, + "pattern": "^[a-zA-Z\\s'-]+$", + "error_message": "Name must be a valid string between 2-100 characters" + }, + "age": { + "type": "integer", + "minimum": 1, + "maximum": 120, + "error_message": "Age must be a positive integer between 1 and 120" + }, + "email": { + "type": "string", + "format": "email", + "error_message": "Invalid email format" + } + }, + "required": ["student_id", "name", "age"], + "additionalProperties": False +} + +def validate_student_data(data): + """Validate student data against the schema""" + try: + validate(instance=data, schema=STUDENT_SCHEMA) + return None # No errors + except ValidationError as e: + # Extract the error message from the schema + path = ".".join(str(p) for p in e.absolute_path) if e.absolute_path else "" + field = path.split(".")[-1] if path else "request body" + + # Get custom error message from schema if available + if "properties" in STUDENT_SCHEMA and field in STUDENT_SCHEMA["properties"]: + if "error_message" in STUDENT_SCHEMA["properties"][field]: + return f"Validation error in {field}: {STUDENT_SCHEMA['properties'][field]['error_message']}" + + return f"Validation error in {field}: {e.message}" + +def validate_json_content_type(f): + """Decorator to validate content type is application/json""" + @wraps(f) + def decorated_function(*args, **kwargs): + if request.method in ['POST', 'PUT', 'PATCH']: + if not request.is_json: + return jsonify({ + 'error': 'Content-Type must be application/json' + }), 415 + return f(*args, **kwargs) + return decorated_function + +def validate_student_required(f): + """Decorator to validate student data in request""" + @wraps(f) + def decorated_function(*args, **kwargs): + data = request.get_json() + if not data: + return jsonify({ + 'error': 'No input data provided' + }), 400 + + error = validate_student_data(data) + if error: + return jsonify({ + 'error': error + }), 400 + + return f(*args, **kwargs) + return decorated_function