- Updated V3 CRUD routers to return 400 Bad Request for database schema errors (unknown columns) across all list and search endpoints. - Fixed serialization issue in nested patch endpoint. - Overhauled Section 7 of Frontend Integration Guide to document HTTP status code mappings for common error categories.
362 lines
17 KiB
Python
362 lines
17 KiB
Python
from fastapi import APIRouter, Depends, Path, Query, Request, Response, Header
|
|
from pydantic import ValidationError
|
|
from typing import Optional, Union
|
|
import asyncio
|
|
import logging
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
from app.lib_general_v3 import (
|
|
AccountContext, get_account_context,
|
|
PaginationParams, StatusFilterParams,
|
|
SerializationParams, DelayParams
|
|
)
|
|
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, 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
|
|
|
|
"""
|
|
Aether API V3 - Nested (Child) CRUD Router
|
|
------------------------------------------
|
|
This router handles relational operations between parent and child objects.
|
|
Example: /person/{person_id}/journal/
|
|
"""
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/', response_model=Resp_Body_Base)
|
|
async def get_child_obj_li(
|
|
response: Response,
|
|
parent_obj_type: str,
|
|
parent_obj_id: str,
|
|
child_obj_type: str,
|
|
order_by_li: Optional[str] = None,
|
|
jp: Optional[Union[str, None]] = None,
|
|
account: AccountContext = Depends(get_account_context),
|
|
pagination: PaginationParams = Depends(),
|
|
status_filter: StatusFilterParams = Depends(),
|
|
serialization: SerializationParams = Depends(),
|
|
delay: DelayParams = Depends(),
|
|
):
|
|
"""
|
|
List Child Objects (One-to-Many).
|
|
|
|
Retrieves a list of child objects associated with a specific parent.
|
|
1. Verifies parent existence and user access to the parent.
|
|
2. Filters children where `{parent_obj_type}_id` matches the parent's ID.
|
|
"""
|
|
from app.db_sql import redis_lookup_id_random, sql_select
|
|
import urllib.parse
|
|
|
|
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
|
|
|
qry_dict_li = None
|
|
fulltext_qry_dict_obj = None
|
|
and_qry_dict_obj = None
|
|
and_like_dict_obj = None
|
|
or_like_dict_obj = None
|
|
and_in_dict_li_obj = None
|
|
|
|
jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None
|
|
if jp_obj:
|
|
if jp_obj.get('qry'): qry_dict_li = jp_obj['qry']
|
|
if jp_obj.get('ft_qry'): fulltext_qry_dict_obj = jp_obj['ft_qry']
|
|
if jp_obj.get('and_qry'): and_qry_dict_obj = jp_obj['and_qry']
|
|
if jp_obj.get('and_like'): and_like_dict_obj = jp_obj['and_like']
|
|
if jp_obj.get('or_like'): or_like_dict_obj = jp_obj['or_like']
|
|
if jp_obj.get('and_in_li'): and_in_dict_li_obj = jp_obj['and_in_li']
|
|
|
|
order_by_li = safe_json_loads(order_by_li)
|
|
obj_name = child_obj_type
|
|
|
|
if obj_name not in obj_type_kv_li or parent_obj_type not in obj_type_kv_li:
|
|
return mk_resp(data=False, status_code=400, response=response, status_message=f"Invalid object type(s).")
|
|
|
|
obj_cfg = obj_type_kv_li[obj_name]
|
|
table_name = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
|
|
base_name = obj_cfg.get('mdl_default', obj_cfg.get('mdl'))
|
|
|
|
if not table_name or not base_name:
|
|
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
|
|
|
order_by_li = filter_order_by(order_by_li, base_name, table_name)
|
|
status_filter = get_supported_filters(base_name, status_filter)
|
|
|
|
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type)
|
|
if not resolved_parent_id:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent not found.")
|
|
|
|
parent_cfg = obj_type_kv_li[parent_obj_type]
|
|
parent_table = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
|
|
if parent_sql_res := sql_select(table_name=parent_table, record_id=resolved_parent_id):
|
|
if not check_account_access(parent_sql_res, account, parent_obj_type):
|
|
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent.")
|
|
else:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
|
|
|
and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name, table_name=table_name)
|
|
|
|
sql_result = sql_select(
|
|
table_name=table_name,
|
|
field_name=f'{parent_obj_type}_id',
|
|
field_value=resolved_parent_id,
|
|
enabled=status_filter.enabled,
|
|
hidden=status_filter.hidden,
|
|
qry_dict_li=qry_dict_li,
|
|
fulltext_qry_dict=fulltext_qry_dict_obj,
|
|
and_qry_dict=and_qry_dict_obj,
|
|
and_like_dict=and_like_dict_obj,
|
|
or_like_dict=or_like_dict_obj,
|
|
and_in_dict_li=and_in_dict_li_obj,
|
|
order_by_li=order_by_li,
|
|
limit=pagination.limit,
|
|
offset=pagination.offset,
|
|
as_list=True,
|
|
)
|
|
|
|
if sql_result is False:
|
|
# Standardized rich error bubbling
|
|
db_err = format_db_error(get_last_sql_error())
|
|
|
|
# If it's a schema error (like Unknown Column), it's a 400 Bad Request
|
|
status_code = 400 if db_err.category == "database_schema" else 500
|
|
|
|
return mk_resp(data=False, status_code=status_code, response=response, status_message="Search failed due to database error.", details=db_err.dict())
|
|
|
|
if sql_result:
|
|
resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result]
|
|
return mk_resp(data=resp_data_li, response=response)
|
|
else:
|
|
return mk_resp(data=[], status_code=200, response=response)
|
|
|
|
|
|
@router.post('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/', response_model=Resp_Body_Base)
|
|
async def post_child_obj(
|
|
request: Request,
|
|
response: Response,
|
|
parent_obj_type: str = Path(min_length=2, max_length=50),
|
|
parent_obj_id: str = Path(min_length=11, max_length=22),
|
|
child_obj_type: str = Path(min_length=2, max_length=50),
|
|
return_obj: Optional[bool] = True,
|
|
x_ae_ignore_extra_fields: Optional[bool] = Header(False),
|
|
account: AccountContext = Depends(get_account_context),
|
|
serialization: SerializationParams = Depends(),
|
|
delay: DelayParams = Depends(),
|
|
):
|
|
"""
|
|
Create Child Object.
|
|
|
|
1. Verifies Parent existence and access.
|
|
2. Automatically links the new child to the parent (`{parent_obj_type}_id` = parent_id).
|
|
3. Performs standard creation logic (validation, injection, sanitization).
|
|
"""
|
|
from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, get_id_random
|
|
|
|
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
|
|
|
obj_data = await request.json()
|
|
|
|
if parent_obj_type not in obj_type_kv_li or child_obj_type not in obj_type_kv_li:
|
|
return mk_resp(data=False, status_code=400, response=response, status_message="Invalid object type.")
|
|
|
|
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type)
|
|
if not resolved_parent_id:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
|
|
|
parent_cfg = obj_type_kv_li[parent_obj_type]
|
|
parent_table = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
|
|
if parent_sql_res := sql_select(table_name=parent_table, record_id=resolved_parent_id):
|
|
if not check_account_access(parent_sql_res, account, parent_obj_type):
|
|
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent.")
|
|
else:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
|
|
|
obj_cfg = obj_type_kv_li[child_obj_type]
|
|
table_name_insert = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
|
|
table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
|
|
input_model = obj_cfg.get('mdl_in', obj_cfg.get('mdl'))
|
|
output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
|
|
|
|
if not table_name_insert or not input_model or not table_name_select or not output_model:
|
|
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
|
|
|
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
|
if 'account_id' in input_model.__fields__:
|
|
obj_data['account_id'] = account.account_id
|
|
|
|
obj_data[f'{parent_obj_type}_id'] = resolved_parent_id
|
|
|
|
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
|
|
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
|
|
|
try:
|
|
validated_obj = input_model(**obj_data)
|
|
except ValidationError as e:
|
|
structured_errors = {err['loc'][-1]: err['msg'] for err in e.errors()}
|
|
return mk_resp(data=False, status_code=400, response=response, status_message="Validation Failed", details=structured_errors)
|
|
except Exception as 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)
|
|
|
|
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
|
new_obj_id = sql_insert_result
|
|
new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=child_obj_type)
|
|
|
|
if return_obj:
|
|
if sql_select_result := sql_select(table_name=table_name_select, record_id=new_obj_id):
|
|
resp_data = output_model(**sql_select_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
|
|
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:
|
|
# Standardized rich error bubbling
|
|
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.dict())
|
|
|
|
|
|
@router.get('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)
|
|
async def get_child_obj(
|
|
response: Response,
|
|
parent_obj_type: str = Path(min_length=2, max_length=50),
|
|
parent_obj_id: str = Path(min_length=11, max_length=22),
|
|
child_obj_type: str = Path(min_length=2, max_length=50),
|
|
child_obj_id: str = Path(min_length=11, max_length=22),
|
|
account: AccountContext = Depends(get_account_context),
|
|
serialization: SerializationParams = Depends(),
|
|
delay: DelayParams = Depends(),
|
|
):
|
|
"""
|
|
Retrieve Child Object.
|
|
|
|
Verifies that the child belongs to the specified parent.
|
|
"""
|
|
from app.db_sql import redis_lookup_id_random, sql_select
|
|
|
|
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
|
|
|
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type)
|
|
resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_type)
|
|
|
|
if not resolved_parent_id or not resolved_child_id:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.")
|
|
|
|
obj_cfg = obj_type_kv_li[child_obj_type]
|
|
table_name = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
|
|
base_name = obj_cfg.get('mdl_default', obj_cfg.get('mdl'))
|
|
|
|
if sql_result := sql_select(table_name=table_name, record_id=resolved_child_id):
|
|
if sql_result.get(f'{parent_obj_type}_id') != resolved_parent_id:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.")
|
|
|
|
resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
|
|
return mk_resp(data=resp_data, response=response)
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.")
|
|
|
|
|
|
@router.patch('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)
|
|
async def patch_child_obj(
|
|
request: Request,
|
|
response: Response,
|
|
parent_obj_type: str = Path(min_length=2, max_length=50),
|
|
parent_obj_id: str = Path(min_length=11, max_length=22),
|
|
child_obj_type: str = Path(min_length=2, max_length=50),
|
|
child_obj_id: str = Path(min_length=11, max_length=22),
|
|
return_obj: Optional[bool] = True,
|
|
x_ae_ignore_extra_fields: Optional[bool] = Header(False),
|
|
account: AccountContext = Depends(get_account_context),
|
|
serialization: SerializationParams = Depends(),
|
|
delay: DelayParams = Depends(),
|
|
):
|
|
"""
|
|
Update Child Object.
|
|
|
|
Verifies that the child belongs to the specified parent before updating.
|
|
"""
|
|
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
|
|
|
|
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
|
|
|
obj_data = await request.json()
|
|
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type)
|
|
resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_type)
|
|
|
|
if not resolved_parent_id or not resolved_child_id:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.")
|
|
|
|
obj_cfg = obj_type_kv_li[child_obj_type]
|
|
table_name_update = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
|
|
table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
|
|
input_model = obj_cfg.get('mdl_in', obj_cfg.get('mdl'))
|
|
output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
|
|
|
|
if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_id):
|
|
if existing_child.get(f'{parent_obj_type}_id') != resolved_parent_id:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.")
|
|
else:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.")
|
|
|
|
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
|
|
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
|
|
|
if sql_update(data=obj_data, table_name=table_name_update, record_id=resolved_child_id):
|
|
if return_obj:
|
|
if updated_child := sql_select(table_name=table_name_select, record_id=resolved_child_id):
|
|
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.")
|
|
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.dict())
|
|
|
|
|
|
@router.delete('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)
|
|
async def delete_child_obj(
|
|
response: Response,
|
|
parent_obj_type: str = Path(min_length=2, max_length=50),
|
|
parent_obj_id: str = Path(min_length=11, max_length=22),
|
|
child_obj_type: str = Path(min_length=2, max_length=50),
|
|
child_obj_id: str = Path(min_length=11, max_length=22),
|
|
method: str = Query('delete', regex='^(delete|hide|disable)$'),
|
|
account: AccountContext = Depends(get_account_context),
|
|
delay: DelayParams = Depends(),
|
|
):
|
|
"""
|
|
Delete Child Object.
|
|
|
|
Verifies that the child belongs to the specified parent before deleting.
|
|
"""
|
|
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete
|
|
|
|
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
|
|
|
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type)
|
|
resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_type)
|
|
|
|
if not resolved_parent_id or not resolved_child_id:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.")
|
|
|
|
obj_cfg = obj_type_kv_li[child_obj_type]
|
|
table_name_delete = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
|
|
table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
|
|
|
|
if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_id):
|
|
if existing_child.get(f'{parent_obj_type}_id') != resolved_parent_id:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.")
|
|
else:
|
|
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.")
|
|
|
|
if method == 'hide':
|
|
success = sql_update(table_name=table_name_delete, record_id=resolved_child_id, data={'hide': True})
|
|
elif method == 'disable':
|
|
success = sql_update(table_name=table_name_delete, record_id=resolved_child_id, data={'enable': False})
|
|
else:
|
|
success = sql_delete(table_name=table_name_delete, record_id=resolved_child_id)
|
|
|
|
if success:
|
|
return mk_resp(data=True, response=response, status_message=f"Deleted successfully via {method}.")
|
|
return mk_resp(data=False, status_code=400, response=response, status_message="Deletion failed.")
|