From 2ff211f2c2b43b05ea0ba90621494d811c44aaee Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 9 Jan 2026 15:52:00 -0500 Subject: [PATCH] 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. --- app/routers/api_crud_v3.py | 131 +++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 27772b1..ccf71ba 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -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.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() # --- Helpers --- 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': return True if not account.account_id: @@ -40,6 +61,12 @@ def check_account_access(sql_result: Any, account: AccountContext, obj_name: str return True 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 {} if account.super or account.auth_method == 'bypass': return forced @@ -52,6 +79,12 @@ def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountCo return forced 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: return order_by_li 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 = {} for column in filtered: try: + # Lightweight check to see if column exists in SQL db.execute(text(f"SELECT `{column}` FROM `{table_name}` LIMIT 0")) final_filtered[column] = filtered[column] except Exception: @@ -75,6 +109,12 @@ def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Opt return filtered 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__"): return status_filter # 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) 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) return mk_resp(data={"status": "V3 API is healthy!"}) @@ -110,6 +154,16 @@ async def get_obj_schema( variant: str = Query('base'), 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 sqlalchemy import text @@ -161,6 +215,14 @@ async def get_obj( serialization: SerializationParams = 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 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(), 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 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(), 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 if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -396,6 +475,15 @@ async def post_obj( serialization: SerializationParams = 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 if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -462,6 +550,13 @@ async def patch_obj( serialization: SerializationParams = 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 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), 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 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(), 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 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(), 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) @@ -722,6 +838,11 @@ async def get_child_obj( 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) @@ -758,6 +879,11 @@ async def patch_child_obj( 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) @@ -800,6 +926,11 @@ async def delete_child_obj( 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)