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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user