Phase 2 Step 2: Implement Scan API Endpoints
Implemented all 5 scan management endpoints with comprehensive error handling, logging, and integration tests. ## Changes ### API Endpoints (web/api/scans.py) - POST /api/scans - Trigger new scan with config file validation - GET /api/scans - List scans with pagination and status filtering - GET /api/scans/<id> - Retrieve scan details with all relationships - DELETE /api/scans/<id> - Delete scan and associated files - GET /api/scans/<id>/status - Poll scan status for long-running scans ### Features - Comprehensive error handling (400, 404, 500) - Structured logging with appropriate levels - Input validation via validators - Consistent JSON error format - SQLAlchemy error handling with graceful degradation - HTTP status codes following REST conventions ### Testing (tests/test_scan_api.py) - 24 integration tests covering all endpoints - Empty/populated scan lists - Pagination with multiple pages - Status filtering - Error scenarios (invalid input, not found, etc.) - Complete workflow integration test ### Test Infrastructure (tests/conftest.py) - Flask app fixture with test database - Flask test client fixture - Database session fixture compatible with app context - Sample scan fixture for testing ### Documentation (docs/ai/PHASE2.md) - Updated progress: 4/14 days complete (29%) - Marked Step 2 as complete - Added implementation details and testing results ## Implementation Notes - All endpoints use ScanService for business logic separation - Scan triggering returns immediately; client polls status endpoint - Background job execution will be added in Step 3 - Authentication will be added in Step 4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
219
web/api/scans.py
219
web/api/scans.py
@@ -5,9 +5,15 @@ Handles endpoints for triggering scans, listing scan history, and retrieving
|
||||
scan results.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from web.services.scan_service import ScanService
|
||||
from web.utils.validators import validate_config_file, validate_page_params
|
||||
|
||||
bp = Blueprint('scans', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@@ -23,14 +29,53 @@ def list_scans():
|
||||
Returns:
|
||||
JSON response with scans list and pagination info
|
||||
"""
|
||||
# TODO: Implement in Phase 2
|
||||
return jsonify({
|
||||
'scans': [],
|
||||
'total': 0,
|
||||
'page': 1,
|
||||
'per_page': 20,
|
||||
'message': 'Scans endpoint - to be implemented in Phase 2'
|
||||
})
|
||||
try:
|
||||
# Get and validate query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
status_filter = request.args.get('status', None, type=str)
|
||||
|
||||
# Validate pagination params
|
||||
page, per_page = validate_page_params(page, per_page)
|
||||
|
||||
# Get scans from service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
paginated_result = scan_service.list_scans(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
status_filter=status_filter
|
||||
)
|
||||
|
||||
logger.info(f"Listed scans: page={page}, per_page={per_page}, status={status_filter}, total={paginated_result.total}")
|
||||
|
||||
return jsonify({
|
||||
'scans': paginated_result.items,
|
||||
'total': paginated_result.total,
|
||||
'page': paginated_result.page,
|
||||
'per_page': paginated_result.per_page,
|
||||
'total_pages': paginated_result.total_pages,
|
||||
'has_prev': paginated_result.has_prev,
|
||||
'has_next': paginated_result.has_next
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid request parameters: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error listing scans: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to retrieve scans'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing scans: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>', methods=['GET'])
|
||||
@@ -44,11 +89,33 @@ def get_scan(scan_id):
|
||||
Returns:
|
||||
JSON response with scan details
|
||||
"""
|
||||
# TODO: Implement in Phase 2
|
||||
return jsonify({
|
||||
'scan_id': scan_id,
|
||||
'message': 'Scan detail endpoint - to be implemented in Phase 2'
|
||||
})
|
||||
try:
|
||||
# Get scan from service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
scan = scan_service.get_scan(scan_id)
|
||||
|
||||
if not scan:
|
||||
logger.warning(f"Scan not found: {scan_id}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': f'Scan with ID {scan_id} not found'
|
||||
}), 404
|
||||
|
||||
logger.info(f"Retrieved scan details: {scan_id}")
|
||||
return jsonify(scan)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error retrieving scan {scan_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to retrieve scan'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error retrieving scan {scan_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
@@ -62,16 +129,53 @@ def trigger_scan():
|
||||
Returns:
|
||||
JSON response with scan_id and status
|
||||
"""
|
||||
# TODO: Implement in Phase 2
|
||||
data = request.get_json() or {}
|
||||
config_file = data.get('config_file')
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json() or {}
|
||||
config_file = data.get('config_file')
|
||||
|
||||
return jsonify({
|
||||
'scan_id': None,
|
||||
'status': 'not_implemented',
|
||||
'message': 'Scan trigger endpoint - to be implemented in Phase 2',
|
||||
'config_file': config_file
|
||||
}), 501 # Not Implemented
|
||||
# Validate required fields
|
||||
if not config_file:
|
||||
logger.warning("Scan trigger request missing config_file")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': 'config_file is required'
|
||||
}), 400
|
||||
|
||||
# Trigger scan via service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
scan_id = scan_service.trigger_scan(
|
||||
config_file=config_file,
|
||||
triggered_by='api'
|
||||
)
|
||||
|
||||
logger.info(f"Scan {scan_id} triggered via API: config={config_file}")
|
||||
|
||||
return jsonify({
|
||||
'scan_id': scan_id,
|
||||
'status': 'running',
|
||||
'message': 'Scan queued successfully'
|
||||
}), 201
|
||||
|
||||
except ValueError as e:
|
||||
# Config file validation error
|
||||
logger.warning(f"Invalid config file: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error triggering scan: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to create scan'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error triggering scan: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>', methods=['DELETE'])
|
||||
@@ -85,12 +189,37 @@ def delete_scan(scan_id):
|
||||
Returns:
|
||||
JSON response with deletion status
|
||||
"""
|
||||
# TODO: Implement in Phase 2
|
||||
return jsonify({
|
||||
'scan_id': scan_id,
|
||||
'status': 'not_implemented',
|
||||
'message': 'Scan deletion endpoint - to be implemented in Phase 2'
|
||||
}), 501
|
||||
try:
|
||||
# Delete scan via service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
scan_service.delete_scan(scan_id)
|
||||
|
||||
logger.info(f"Scan {scan_id} deleted successfully")
|
||||
|
||||
return jsonify({
|
||||
'scan_id': scan_id,
|
||||
'message': 'Scan deleted successfully'
|
||||
}), 200
|
||||
|
||||
except ValueError as e:
|
||||
# Scan not found
|
||||
logger.warning(f"Scan deletion failed: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error deleting scan {scan_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to delete scan'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting scan {scan_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>/status', methods=['GET'])
|
||||
@@ -104,13 +233,33 @@ def get_scan_status(scan_id):
|
||||
Returns:
|
||||
JSON response with scan status and progress
|
||||
"""
|
||||
# TODO: Implement in Phase 2
|
||||
return jsonify({
|
||||
'scan_id': scan_id,
|
||||
'status': 'not_implemented',
|
||||
'progress': '0%',
|
||||
'message': 'Scan status endpoint - to be implemented in Phase 2'
|
||||
})
|
||||
try:
|
||||
# Get scan status from service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
status = scan_service.get_scan_status(scan_id)
|
||||
|
||||
if not status:
|
||||
logger.warning(f"Scan not found for status check: {scan_id}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': f'Scan with ID {scan_id} not found'
|
||||
}), 404
|
||||
|
||||
logger.debug(f"Retrieved status for scan {scan_id}: {status['status']}")
|
||||
return jsonify(status)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error retrieving scan status {scan_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to retrieve scan status'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error retrieving scan status {scan_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET'])
|
||||
|
||||
Reference in New Issue
Block a user