Enhance V3 CRUD: Implement Error Bubbling and Dry-Run Validation.

- Updated app/db_sql.py to capture SQL exceptions in thread-local storage for later retrieval.
- Implemented format_db_error() in app/lib_api_crud_v3.py to clean up raw MariaDB error strings.
- Added POST /v3/crud/{obj_type}/validate endpoint for dry-run payload validation.
- Updated main and nested routers to bubble up validation and database errors into the response 'meta.details' field.
- Added tests/test_v3_error_bubbling.py to verify formatting logic.
This commit is contained in:
Scott Idem
2026-01-09 16:57:54 -05:00
parent 3885cc6aba
commit 4b86432381
5 changed files with 122 additions and 10 deletions

View File

@@ -15,9 +15,10 @@ from app.lib_general_v3 import (
)
from app.lib_api_crud_v3 import (
check_account_access, apply_forced_account_filter, filter_order_by,
get_supported_filters, safe_json_loads, sanitize_payload
get_supported_filters, safe_json_loads, sanitize_payload, format_db_error
)
from app.lib_schema_v3 import get_object_schema_info
from app.db_sql import get_last_sql_error
from app.models.response_models import *
from app.models.api_crud_models import SearchFilter, SearchQuery
from app.ae_obj_types_def import obj_type_kv_li
@@ -76,6 +77,33 @@ async def get_obj_schema(
return mk_resp(data=schema_info, response=response)
@router.post("/{obj_type}/validate", response_model=Resp_Body_Base, tags=['CRUD v3 Validation (Dev)'])
async def validate_obj_payload(
request: Request,
response: Response,
obj_type: str = Path(min_length=2, max_length=50),
account: AccountContext = Depends(get_account_context),
):
"""
Dry-Run Payload Validation.
Verifies that a payload is valid according to the Pydantic model
without performing any database operations.
"""
obj_data = await request.json()
if obj_type not in obj_type_kv_li:
return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_type}' not found.")
obj_cfg = obj_type_kv_li[obj_type]
input_model = obj_cfg.get('mdl_in', obj_cfg.get('mdl'))
try:
input_model(**obj_data)
return mk_resp(data=True, response=response, status_message="Payload is valid.")
except Exception as e:
return mk_resp(data=False, status_code=400, response=response, status_message="Validation Failed", details=str(e))
@router.get('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base)
async def get_obj(
response: Response,
@@ -382,7 +410,7 @@ async def post_obj(
try:
validated_obj = input_model(**obj_data)
except Exception as e:
return mk_resp(data=False, status_code=400, response=response, status_message=f"Validation error: {e}")
return mk_resp(data=False, status_code=400, response=response, status_message="Validation Failed", details=str(e))
data_to_insert = validated_obj.dict(exclude_unset=True)
@@ -399,7 +427,8 @@ async def post_obj(
return mk_resp(data=resp_data, response=response)
return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=response)
else:
return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create object.")
db_err = format_db_error(get_last_sql_error())
return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create object.", details=db_err)
@router.patch('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base)
@@ -458,7 +487,8 @@ async def patch_obj(
return mk_resp(data=resp_data, response=response)
return mk_resp(data=True, response=response, status_message="Object updated successfully.")
else:
return mk_resp(data=False, status_code=400, response=response, status_message="Failed to update object.")
db_err = format_db_error(get_last_sql_error())
return mk_resp(data=False, status_code=400, response=response, status_message="Failed to update object.", details=db_err)
@router.delete('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base)

View File

@@ -12,8 +12,9 @@ from app.lib_general_v3 import (
)
from app.lib_api_crud_v3 import (
check_account_access, apply_forced_account_filter, filter_order_by,
get_supported_filters, safe_json_loads, sanitize_payload
get_supported_filters, safe_json_loads, sanitize_payload, format_db_error
)
from app.db_sql import get_last_sql_error
from app.models.response_models import *
from app.ae_obj_types_def import obj_type_kv_li
@@ -178,7 +179,7 @@ async def post_child_obj(
try:
validated_obj = input_model(**obj_data)
except Exception as e:
return mk_resp(data=False, status_code=400, response=response, status_message=f"Validation error: {e}")
return mk_resp(data=False, status_code=400, response=response, status_message="Validation Failed", details=str(e))
data_to_insert = validated_obj.dict(exclude_unset=True)
@@ -195,7 +196,8 @@ async def post_child_obj(
return mk_resp(data=resp_data, response=response)
return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=response)
else:
return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create child object.")
db_err = format_db_error(get_last_sql_error())
return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create child object.", details=db_err)
@router.get('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)
@@ -286,7 +288,9 @@ async def patch_child_obj(
resp_data = output_model(**updated_child).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
return mk_resp(data=resp_data, response=response)
return mk_resp(data=True, response=response, status_message="Updated successfully.")
return mk_resp(data=False, status_code=400, response=response, status_message="Update failed.")
else:
db_err = format_db_error(get_last_sql_error())
return mk_resp(data=False, status_code=400, response=response, status_message="Update failed.", details=db_err)
@router.delete('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)