Update API documentation and finalize model validators/mappings.

- Added comprehensive docstrings to api_crud_v3.py explaining multi-tenancy, sanitization, and soft-delete logic.
- Finalized Address and Contact models/mappings with correct validators and field maps.
- Consolidated test suite in tests/ directory.
This commit is contained in:
Scott Idem
2026-01-09 15:52:00 -05:00
parent 8dc37f274f
commit 2ff211f2c2

View File

@@ -18,11 +18,32 @@ from app.models.api_crud_models import SearchFilter, SearchQuery
from app.ae_obj_types_def import obj_type_kv_li from app.ae_obj_types_def import obj_type_kv_li
# from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, sql_update, sql_delete, get_id_random # from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, sql_update, sql_delete, get_id_random
"""
Aether API V3 - Generic CRUD Router
-----------------------------------
This router provides a standardized, dynamic interface for Create, Read, Update, and Delete operations
across the Aether Object System.
Key Features:
1. Dynamic Object Handling: Routes work for any object defined in `ae_obj_types_def`.
2. Multi-Tenancy: Automatically enforces `account_id` isolation for non-superusers.
3. ID Obfuscation: Uses `id_random` (public strings) -> `id` (database integers) resolution.
4. Data Sanitization: Automatically strips virtual fields and view-only fields before DB writes.
"""
router = APIRouter() router = APIRouter()
# --- Helpers --- # --- Helpers ---
def check_account_access(sql_result: Any, account: AccountContext, obj_name: str = None) -> bool: def check_account_access(sql_result: Any, account: AccountContext, obj_name: str = None) -> bool:
"""
Enforce Multi-Tenant Data Isolation.
Verifies that the requested record belongs to the authenticated user's account.
Returns True if:
- User is a Super User or System (Bypass).
- The record's `account_id` matches the user's `account_id`.
"""
if account.super or account.auth_method == 'bypass': if account.super or account.auth_method == 'bypass':
return True return True
if not account.account_id: if not account.account_id:
@@ -40,6 +61,12 @@ def check_account_access(sql_result: Any, account: AccountContext, obj_name: str
return True return True
def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountContext, model: Any, obj_name: str) -> Dict: def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountContext, model: Any, obj_name: str) -> Dict:
"""
Secure Search Filtering.
Automatically appends an `account_id` filter to database queries to ensure
users only retrieve records associated with their own account.
"""
forced = and_qry_dict or {} forced = and_qry_dict or {}
if account.super or account.auth_method == 'bypass': if account.super or account.auth_method == 'bypass':
return forced return forced
@@ -52,6 +79,12 @@ def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountCo
return forced return forced
def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Optional[Dict[str, str]]: def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Optional[Dict[str, str]]:
"""
Sanitize Sorting Parameters.
Prevents SQL injection and logic errors by validating that requested sort columns
actually exist in the Pydantic model and/or the database table.
"""
if not order_by_li or not isinstance(order_by_li, dict) or not model: if not order_by_li or not isinstance(order_by_li, dict) or not model:
return order_by_li return order_by_li
if not hasattr(model, '__fields__'): if not hasattr(model, '__fields__'):
@@ -67,6 +100,7 @@ def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Opt
final_filtered = {} final_filtered = {}
for column in filtered: for column in filtered:
try: try:
# Lightweight check to see if column exists in SQL
db.execute(text(f"SELECT `{column}` FROM `{table_name}` LIMIT 0")) db.execute(text(f"SELECT `{column}` FROM `{table_name}` LIMIT 0"))
final_filtered[column] = filtered[column] final_filtered[column] = filtered[column]
except Exception: except Exception:
@@ -75,6 +109,12 @@ def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Opt
return filtered return filtered
def get_supported_filters(model: Any, status_filter: StatusFilterParams) -> StatusFilterParams: def get_supported_filters(model: Any, status_filter: StatusFilterParams) -> StatusFilterParams:
"""
Adaptive Status Filtering.
Adjusts the default filters (enabled/hidden) based on whether the target object
actually supports those concepts (i.e., has those columns).
"""
if not model or not hasattr(model, "__fields__"): if not model or not hasattr(model, "__fields__"):
return status_filter return status_filter
# We create a new instance to avoid side effects on the dependency object # We create a new instance to avoid side effects on the dependency object
@@ -99,6 +139,10 @@ def safe_json_loads(json_str: Optional[str]) -> Any:
@router.get("/health", response_model=Resp_Body_Base) @router.get("/health", response_model=Resp_Body_Base)
async def health_check(delay: DelayParams = Depends()): async def health_check(delay: DelayParams = Depends()):
"""
Health Check Endpoint.
Used by monitoring systems to verify API availability.
"""
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
return mk_resp(data={"status": "V3 API is healthy!"}) return mk_resp(data={"status": "V3 API is healthy!"})
@@ -110,6 +154,16 @@ async def get_obj_schema(
variant: str = Query('base'), variant: str = Query('base'),
account: AccountContext = Depends(get_account_context), account: AccountContext = Depends(get_account_context),
): ):
"""
Dynamic Schema Introspection.
Allows the frontend (e.g., Svelte/React apps) to retrieve the structure of an object type on the fly.
Returns:
- Database column definitions (types, defaults, nullability).
- Pydantic model field definitions (validation rules, aliases).
This enables dynamic form generation without hardcoding schemas in the frontend.
"""
from app.db_sql import db from app.db_sql import db
from sqlalchemy import text from sqlalchemy import text
@@ -161,6 +215,14 @@ async def get_obj(
serialization: SerializationParams = Depends(), serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(), delay: DelayParams = Depends(),
): ):
"""
Retrieve a Single Object.
1. Resolves the public `id_random` (string) to the internal `id` (integer).
2. Performs a SQL SELECT.
3. Enforces Multi-Tenant access checks.
4. Serializes the result via Pydantic.
"""
from app.db_sql import redis_lookup_id_random, sql_select from app.db_sql import redis_lookup_id_random, sql_select
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
@@ -204,6 +266,16 @@ async def get_obj_li(
serialization: SerializationParams = Depends(), serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(), delay: DelayParams = Depends(),
): ):
"""
List Objects (Pagination & Filtering).
Supports:
- Standard filtering (enabled/hidden).
- Advanced filtering via JSON Payload (`jp`) param (Search, Fulltext, AND/OR queries).
- Sorting (`order_by_li`).
- Parent-Child filtering (`for_obj_type`, `for_obj_id`).
- Account Isolation (automatically enforced).
"""
from app.db_sql import redis_lookup_id_random, sql_select from app.db_sql import redis_lookup_id_random, sql_select
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
@@ -308,6 +380,13 @@ async def search_obj_li(
serialization: SerializationParams = Depends(), serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(), delay: DelayParams = Depends(),
): ):
"""
Search Objects (POST).
Advanced search endpoint using `SearchQuery` body.
- Security: Guests can access specific objects (e.g., site_domain) if permitted.
- Filtering: Supports dynamic AND/OR filters built from the frontend.
"""
from app.db_sql import redis_lookup_id_random, sql_select from app.db_sql import redis_lookup_id_random, sql_select
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
@@ -396,6 +475,15 @@ async def post_obj(
serialization: SerializationParams = Depends(), serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(), delay: DelayParams = Depends(),
): ):
"""
Create Object.
1. Validates input against Pydantic model (`mdl_in`).
2. Injects `account_id` for ownership.
3. **Sanitizes Payload**: Removes virtual lookup fields (`*_id_random`) and view-only fields (`fields_to_exclude_from_db`)
to prevent "unknown column" errors during insertion.
4. Returns the created object or just its ID.
"""
from app.db_sql import sql_insert, get_id_random, sql_select from app.db_sql import sql_insert, get_id_random, sql_select
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
@@ -462,6 +550,13 @@ async def patch_obj(
serialization: SerializationParams = Depends(), serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(), delay: DelayParams = Depends(),
): ):
"""
Update Object (Partial).
1. Resolves ID and checks access permissions.
2. **Sanitizes Payload**: Removes virtual lookup fields and view-only fields.
3. Performs SQL UPDATE.
"""
from app.db_sql import redis_lookup_id_random, sql_select, sql_update 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) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
@@ -520,6 +615,13 @@ async def delete_obj(
account: AccountContext = Depends(get_account_context), account: AccountContext = Depends(get_account_context),
delay: DelayParams = Depends(), delay: DelayParams = Depends(),
): ):
"""
Delete Object.
Supports:
- Soft Delete: `method='hide'` or `method='disable'`.
- Hard Delete: `method='delete'`.
"""
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete 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) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
@@ -571,6 +673,13 @@ async def get_child_obj_li(
serialization: SerializationParams = Depends(), serialization: SerializationParams = Depends(),
delay: DelayParams = 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 from app.db_sql import redis_lookup_id_random, sql_select
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
@@ -658,6 +767,13 @@ async def post_child_obj(
serialization: SerializationParams = Depends(), serialization: SerializationParams = Depends(),
delay: DelayParams = 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 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) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
@@ -722,6 +838,11 @@ async def get_child_obj(
serialization: SerializationParams = Depends(), serialization: SerializationParams = Depends(),
delay: DelayParams = 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 from app.db_sql import redis_lookup_id_random, sql_select
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
@@ -758,6 +879,11 @@ async def patch_child_obj(
serialization: SerializationParams = Depends(), serialization: SerializationParams = Depends(),
delay: DelayParams = 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 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) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
@@ -800,6 +926,11 @@ async def delete_child_obj(
account: AccountContext = Depends(get_account_context), account: AccountContext = Depends(get_account_context),
delay: DelayParams = Depends(), 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 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) if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)