Coverage for mcpgateway / utils / error_formatter.py: 100%
54 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
1# -*- coding: utf-8 -*-
2"""Location: ./mcpgateway/utils/error_formatter.py
3Copyright 2025
4SPDX-License-Identifier: Apache-2.0
5Authors: Mihai Criveti
7MCP Gateway Centralized for Pydantic validation error, SQL exception.
8This module provides centralized error formatting for the MCP Gateway,
9transforming technical Pydantic validation errors and SQLAlchemy database
10exceptions into user-friendly messages suitable for API responses.
12The ErrorFormatter class handles:
13- Pydantic ValidationError formatting
14- SQLAlchemy DatabaseError and IntegrityError formatting
15- Mapping technical error messages to user-friendly explanations
16- Consistent error response structure
18Examples:
19 >>> from mcpgateway.utils.error_formatter import ErrorFormatter
20 >>> from pydantic import ValidationError
21 >>>
22 >>> # Format validation errors
23 >>> formatter = ErrorFormatter()
24 >>> # formatted_error = formatter.format_validation_error(validation_error)
25"""
27# Standard
28from typing import Any, Dict
30# Third-Party
31from pydantic import ValidationError
32from sqlalchemy.exc import DatabaseError, IntegrityError
34# First-Party
35from mcpgateway.services.logging_service import LoggingService
37# Initialize logging service first
38logging_service = LoggingService()
39logger = logging_service.get_logger(__name__)
42class ErrorFormatter:
43 """Transform technical errors into user-friendly messages.
45 Provides static methods to convert Pydantic validation errors and
46 SQLAlchemy database exceptions into consistent, user-friendly error
47 responses suitable for API consumption.
49 Examples:
50 >>> formatter = ErrorFormatter()
51 >>> isinstance(formatter, ErrorFormatter)
52 True
53 """
55 @staticmethod
56 def format_validation_error(error: ValidationError) -> Dict[str, Any]:
57 """Convert Pydantic errors to user-friendly format.
59 Transforms Pydantic ValidationError objects into a structured
60 dictionary containing user-friendly error messages. Maps technical
61 validation messages to more understandable explanations.
63 Args:
64 error (ValidationError): The Pydantic validation error to format
66 Returns:
67 Dict[str, Any]: A dictionary with formatted error details containing:
68 - message: General error description
69 - details: List of field-specific errors
70 - success: Always False for errors
72 Examples:
73 >>> from pydantic import BaseModel, ValidationError, field_validator
74 >>> # Create a test model with validation
75 >>> class TestModel(BaseModel):
76 ... name: str
77 ... @field_validator('name')
78 ... def validate_name(cls, v):
79 ... if not v.startswith('A'):
80 ... raise ValueError('Tool name must start with a letter, number, or underscore')
81 ... return v
82 >>> # Test validation error formatting
83 >>> try:
84 ... TestModel(name="B123")
85 ... except ValidationError as e:
86 ... print(type(e))
87 ... result = ErrorFormatter.format_validation_error(e)
88 <class 'pydantic_core._pydantic_core.ValidationError'>
89 >>> result['message']
90 'Validation failed: Name must start with a letter, number, or underscore and contain only letters, numbers, periods, underscores, hyphens, and slashes'
91 >>> result['success']
92 False
93 >>> len(result['details']) > 0
94 True
95 >>> result['details'][0]['field']
96 'name'
97 >>> 'must start with a letter, number, or underscore' in result['details'][0]['message']
98 True
100 >>> # Test with multiple errors
101 >>> class MultiFieldModel(BaseModel):
102 ... name: str
103 ... url: str
104 ... @field_validator('name')
105 ... def validate_name(cls, v):
106 ... if len(v) > 255:
107 ... raise ValueError('Tool name exceeds maximum length')
108 ... return v
109 ... @field_validator('url')
110 ... def validate_url(cls, v):
111 ... if not v.startswith('http'):
112 ... raise ValueError('Tool URL must start with http')
113 ... return v
114 >>>
115 >>> try:
116 ... MultiFieldModel(name='A' * 300, url='ftp://invalid')
117 ... except ValidationError as e:
118 ... print(type(e))
119 ... result = ErrorFormatter.format_validation_error(e)
120 <class 'pydantic_core._pydantic_core.ValidationError'>
121 >>> len(result['details'])
122 2
123 >>> any('too long' in detail['message'] for detail in result['details'])
124 True
125 >>> any('valid HTTP' in detail['message'] for detail in result['details'])
126 True
127 """
128 errors = []
130 for err in error.errors():
131 loc = err.get("loc", ["field"])
132 field = loc[-1] if loc else "field"
133 msg = err.get("msg", "Invalid value")
135 # Map technical messages to user-friendly ones
136 user_message = ErrorFormatter._get_user_message(field, msg)
137 errors.append({"field": field, "message": user_message})
139 # Log the full error for debugging
140 logger.debug(f"Validation error: {error}")
142 return {"message": f"Validation failed: {user_message}", "details": errors, "success": False}
144 @staticmethod
145 def _get_user_message(field: str, technical_msg: str) -> str:
146 """Map technical validation messages to user-friendly ones.
148 Converts technical validation error messages into user-friendly
149 explanations based on pattern matching. Provides field-specific
150 context in the returned message.
152 Args:
153 field (str): The field name that failed validation
154 technical_msg (str): The technical validation message from Pydantic
156 Returns:
157 str: User-friendly error message with field context
159 Examples:
160 >>> # Test letter requirement mapping
161 >>> msg = ErrorFormatter._get_user_message("name", "Tool name must start with a letter, number, or underscore")
162 >>> msg
163 'Name must start with a letter, number, or underscore and contain only letters, numbers, periods, underscores, hyphens, and slashes'
165 >>> # Test length validation mapping
166 >>> msg = ErrorFormatter._get_user_message("description", "Tool name exceeds maximum length")
167 >>> msg
168 'Description is too long (maximum 255 characters)'
170 >>> # Test URL validation mapping
171 >>> msg = ErrorFormatter._get_user_message("endpoint", "Tool URL must start with http")
172 >>> msg
173 'Endpoint must be a valid HTTP or WebSocket URL'
175 >>> # Test directory traversal validation
176 >>> msg = ErrorFormatter._get_user_message("path", "cannot contain directory traversal")
177 >>> msg
178 'Path contains invalid characters'
180 >>> # Test HTML injection validation
181 >>> msg = ErrorFormatter._get_user_message("content", "contains HTML tags")
182 >>> msg
183 'Content cannot contain HTML or script tags'
185 >>> # Test fallback for unknown messages
186 >>> msg = ErrorFormatter._get_user_message("custom_field", "Some unknown error")
187 >>> msg
188 'Invalid custom_field'
189 """
190 mappings = {
191 "Tool name must start with a letter, number, or underscore": f"{field.title()} must start with a letter, number, or underscore and contain only letters, numbers, periods, underscores, hyphens, and slashes",
192 "Tool name exceeds maximum length": f"{field.title()} is too long (maximum 255 characters)",
193 "Tool URL must start with": f"{field.title()} must be a valid HTTP or WebSocket URL",
194 "cannot contain directory traversal": f"{field.title()} contains invalid characters",
195 "contains HTML tags": f"{field.title()} cannot contain HTML or script tags",
196 "Server ID must be a valid UUID format": f"{field.title()} must be a valid UUID",
197 }
199 for pattern, friendly_msg in mappings.items():
200 if pattern in technical_msg:
201 return friendly_msg
203 # Default fallback
204 return f"Invalid {field}"
206 @staticmethod
207 def format_database_error(error: DatabaseError) -> Dict[str, Any]:
208 """Convert database errors to user-friendly format.
210 Transforms SQLAlchemy database exceptions into structured error
211 responses. Handles common integrity constraint violations and
212 provides specific messages for known error patterns.
214 Args:
215 error (DatabaseError): The SQLAlchemy database error to format
217 Returns:
218 Dict[str, Any]: A dictionary with formatted error details containing:
219 - message: User-friendly error description
220 - success: Always False for errors
222 Examples:
223 >>> from unittest.mock import Mock
224 >>>
225 >>> # Test UNIQUE constraint on gateway URL
226 >>> mock_error = Mock(spec=IntegrityError)
227 >>> mock_error.orig = Mock()
228 >>> mock_error.orig.__str__ = lambda self: "UNIQUE constraint failed: gateways.url"
229 >>> result = ErrorFormatter.format_database_error(mock_error)
230 >>> result['message']
231 'A gateway with this URL already exists'
232 >>> result['success']
233 False
235 >>> # Test UNIQUE constraint on gateway slug
236 >>> mock_error.orig.__str__ = lambda self: "UNIQUE constraint failed: gateways.slug"
237 >>> result = ErrorFormatter.format_database_error(mock_error)
238 >>> result['message']
239 'A gateway with this name already exists'
241 >>> # Test UNIQUE constraint on tool name
242 >>> mock_error.orig.__str__ = lambda self: "UNIQUE constraint failed: tools.name"
243 >>> result = ErrorFormatter.format_database_error(mock_error)
244 >>> result['message']
245 'A tool with this name already exists'
247 >>> # Test UNIQUE constraint on resource URI
248 >>> mock_error.orig.__str__ = lambda self: "UNIQUE constraint failed: resources.uri"
249 >>> result = ErrorFormatter.format_database_error(mock_error)
250 >>> result['message']
251 'A resource with this URI already exists'
253 >>> # Test UNIQUE constraint on server name
254 >>> mock_error.orig.__str__ = lambda self: "UNIQUE constraint failed: servers.name"
255 >>> result = ErrorFormatter.format_database_error(mock_error)
256 >>> result['message']
257 'A server with this name already exists'
259 >>> # Test UNIQUE constraint on prompt name
260 >>> mock_error.orig.__str__ = lambda self: "UNIQUE constraint failed: prompts.name"
261 >>> result = ErrorFormatter.format_database_error(mock_error)
262 >>> result['message']
263 'A prompt with this name already exists'
265 >>> # Test FOREIGN KEY constraint
266 >>> mock_error.orig.__str__ = lambda self: "FOREIGN KEY constraint failed"
267 >>> result = ErrorFormatter.format_database_error(mock_error)
268 >>> result['message']
269 'Referenced item not found'
271 >>> # Test NOT NULL constraint
272 >>> mock_error.orig.__str__ = lambda self: "NOT NULL constraint failed"
273 >>> result = ErrorFormatter.format_database_error(mock_error)
274 >>> result['message']
275 'Required field is missing'
277 >>> # Test CHECK constraint
278 >>> mock_error.orig.__str__ = lambda self: "CHECK constraint failed: invalid_data"
279 >>> result = ErrorFormatter.format_database_error(mock_error)
280 >>> result['message']
281 'Validation failed. Please check the input data.'
283 >>> # Test generic database error
284 >>> generic_error = Mock(spec=DatabaseError)
285 >>> generic_error.orig = None
286 >>> result = ErrorFormatter.format_database_error(generic_error)
287 >>> result['message']
288 'Unable to complete the operation. Please try again.'
289 >>> result['success']
290 False
291 """
292 error_str = str(error.orig) if hasattr(error, "orig") else str(error)
294 # Log full error
295 logger.error(f"Database error: {error}")
297 # Map common database errors
298 if isinstance(error, IntegrityError):
299 if "UNIQUE constraint failed" in error_str:
300 if "gateways.url" in error_str:
301 return {"message": "A gateway with this URL already exists", "success": False}
302 elif "gateways.slug" in error_str:
303 return {"message": "A gateway with this name already exists", "success": False}
304 elif "tools.name" in error_str:
305 return {"message": "A tool with this name already exists", "success": False}
306 elif "resources.uri" in error_str:
307 return {"message": "A resource with this URI already exists", "success": False}
308 elif "servers.name" in error_str:
309 return {"message": "A server with this name already exists", "success": False}
310 elif "prompts.name" in error_str:
311 return {"message": "A prompt with this name already exists", "success": False}
312 elif "servers.id" in error_str:
313 return {"message": "A server with this ID already exists", "success": False}
314 elif "a2a_agents.slug" in error_str:
315 return {"message": "An A2A agent with this name already exists", "success": False}
317 elif "FOREIGN KEY constraint failed" in error_str:
318 return {"message": "Referenced item not found", "success": False}
319 elif "NOT NULL constraint failed" in error_str:
320 return {"message": "Required field is missing", "success": False}
321 elif "CHECK constraint failed:" in error_str:
322 return {"message": "Validation failed. Please check the input data.", "success": False}
324 # Generic database error
325 return {"message": "Unable to complete the operation. Please try again.", "success": False}