feat(data_store): implement V3 cascading lookup and ID Vision standardization

- Added GET /v3/data_store/code/{code} with hierarchical context-aware fallback.
- Implemented ID Vision standard in Data_Store_Base (string IDs, internal int exclusion).
- Enhanced Data_Store_Base robustness to handle stringified 'NULL' values from the database.
- Fixed legacy router bugs by removing undefined parameters (inc_event_cfg, inc_event_location).
- Corrected type hints and resolved UnboundLocalError in data_store methods.
- Updated Frontend Integration Guide with Section 8: Data Store V3.
- Added unified E2E test script: tests/e2e/test_e2e_v3_data_store_lookup.py.
This commit is contained in:
Scott Idem
2026-01-28 16:51:48 -05:00
parent 9c0aae9a6d
commit fdcc859017
5 changed files with 224 additions and 78 deletions

View File

@@ -55,7 +55,7 @@ def load_data_store_obj(
def load_data_store_obj_w_code( def load_data_store_obj_w_code(
account_id: int, account_id: int,
code: str, code: str,
for_type: int = None, for_type: str = None,
for_id: int = None, for_id: int = None,
enabled: str = 'enabled', # enabled, disabled, all enabled: str = 'enabled', # enabled, disabled, all
limit: int = 1, limit: int = 1,
@@ -123,11 +123,11 @@ def load_data_store_obj_w_code(
try: try:
data_store_obj = Data_Store_Base(**data_store_rec) data_store_obj = Data_Store_Base(**data_store_rec)
data_store_obj_li.append(data_store_obj) data_store_obj_li.append(data_store_obj)
log.debug(data_store_obj)
except ValidationError as e: except ValidationError as e:
log.error(e.json()) log.error(e.json())
data_store_obj_li.append(None) data_store_obj_li.append(None)
# return False # return False
log.debug(data_store_obj)
else: pass else: pass
log.info(f'Found {len(data_store_obj_li)} Data Store records with code: {code} for Account ID: {account_id} and For Type: {for_type} and For ID: {for_id}') log.info(f'Found {len(data_store_obj_li)} Data Store records with code: {code} for Account ID: {account_id} and For Type: {for_type} and For ID: {for_id}')

View File

@@ -1,7 +1,7 @@
import datetime, pytz import datetime, pytz
from typing import Dict, List, Optional, Set, Union from typing import Dict, List, Optional, Set, Union, ClassVar
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
from app.db_sql import redis_lookup_id_random from app.db_sql import redis_lookup_id_random
from app.lib_general import log, logging from app.lib_general import log, logging
@@ -11,39 +11,37 @@ from app.models.common_field_schema import base_fields
# ### BEGIN ### API Data Store Models ### Data_Store_Base() ### # ### BEGIN ### API Data Store Models ### Data_Store_Base() ###
class Data_Store_Base(BaseModel): class Data_Store_Base(BaseModel):
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals()) log.debug(locals())
id_random: Optional[str] = Field( # --- Standardized Vision IDs (Strings) ---
**base_fields['data_store_id_random'], id: Optional[str] = Field(None, **base_fields['data_store_id_random'])
alias = 'data_store_id_random', data_store_id: Optional[str] = Field(None, **base_fields['data_store_id_random'])
)
id: Optional[int] = Field( account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
alias = 'data_store_id' person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
) user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
account_id_random: Optional[str] # Internal Integer IDs (Excluded from API)
account_id: Optional[int] # We use Optional[Union[int, str]] here to prevent validation crashes
# if the DB returns stringified integers or "NULL" strings.
id_int: Optional[Union[int, str]] = Field(None, alias='id', exclude=True)
account_id_int: Optional[Union[int, str]] = Field(None, alias='account_id', exclude=True)
person_id_int: Optional[Union[int, str]] = Field(None, alias='person_id', exclude=True)
user_id_int: Optional[Union[int, str]] = Field(None, alias='user_id', exclude=True)
for_type: Optional[str] for_type: Optional[str]
for_id_random: Optional[str] for_id: Optional[str] # Random ID string
for_id: Optional[int] for_id_random: Optional[str] # Svelte often uses this name
for_id_int: Optional[Union[int, str]] = Field(None, alias='for_id', exclude=True)
person_id_random: Optional[str]
person_id: Optional[int]
user_id_random: Optional[str]
user_id: Optional[int]
code: Optional[str] code: Optional[str]
name: Optional[str] name: Optional[str]
description: Optional[str] description: Optional[str]
type: Optional[str] # html, json, md, text type: Optional[str] # html, json, md, text
# The JSON fields are case sensitive # The JSON fields are case sensitive
# json: Optional[str] # "json" is reserved; need to change field name? json_str?
json_str: Optional[Union[Json, None]] = Field( json_str: Optional[Union[Json, None]] = Field(
alias = 'json', alias = 'json',
) )
@@ -71,58 +69,46 @@ class Data_Store_Base(BaseModel):
created_on: Optional[datetime.datetime] = None created_on: Optional[datetime.datetime] = None
updated_on: Optional[datetime.datetime] = None updated_on: Optional[datetime.datetime] = None
# Including convenience data
# This is only for convenience. Probably going to keep unless it causes a problem.
# Including JSON data
# other_json: Optional[Json]
# meta_json: Optional[Json]
# Including other related objects
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now) _processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
@validator('id', always=True) @root_validator(pre=True)
def data_store_id_lookup(cls, v, values, **kwargs): def map_v3_ids(cls, values):
if isinstance(v, int) and v > 0: return v """
elif id_random := values.get('id_random'): Vision Transformer:
return redis_lookup_id_random(record_id_random=id_random, table_name='data_store') Map DB keys to clean API keys and strip internal integers.
return None """
# 0. Scrub stringified NULLs from database
for k, v in list(values.items()):
if isinstance(v, str) and v.upper() == 'NULL':
values[k] = None
@validator('account_id', always=True) # 1. Map Random Strings to Clean Names
def account_id_lookup(cls, v, values, **kwargs): if rid := values.get('id_random') or values.get('data_store_id_random'):
if isinstance(v, int) and v > 0: return v values['id'] = rid
elif id_random := values.get('account_id_random'): values['data_store_id'] = rid
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
return None if a_rid := values.get('account_id_random'):
values['account_id'] = a_rid
@validator('for_id', always=True) if p_rid := values.get('person_id_random'):
def for_id_lookup(cls, v, values, **kwargs): values['person_id'] = p_rid
log.setLevel(logging.WARNING) if u_rid := values.get('user_id_random'):
log.debug(locals()) values['user_id'] = u_rid
if isinstance(v, int) and v > 0: return v if f_rid := values.get('for_id_random'):
elif values.get('for_id_random') and values.get('for_type'): values['for_id'] = f_rid
for_id_random = values.get('for_id_random') values['for_id_random'] = f_rid
for_type = values.get('for_type')
return redis_lookup_id_random(record_id_random=for_id_random, table_name=for_type) # 2. Prevent "Collision Population"
return None # We only want strings in our primary ID fields.
# If the key exists and isn't a string, it's a DB integer; remove it
@validator('person_id', always=True) # so it doesn't fail length validation on the string fields.
def person_id_lookup(cls, v, values, **kwargs): for k in ['id', 'data_store_id', 'account_id', 'person_id', 'user_id', 'for_id']:
if isinstance(v, int) and v > 0: return v if k in values and not isinstance(values[k], str):
elif id_random := values.get('person_id_random'): del values[k]
return redis_lookup_id_random(record_id_random=id_random, table_name='person')
return None return values
@validator('user_id', always=True)
def user_id_lookup(cls, v, values, **kwargs):
if isinstance(v, int) and v > 0: return v
elif id_random := values.get('user_id_random'):
return redis_lookup_id_random(record_id_random=id_random, table_name='user')
return None
class Config: class Config:
underscore_attrs_are_private = True underscore_attrs_are_private = True
allow_population_by_field_name = True allow_population_by_field_name = False
fields = base_fields fields = base_fields
# ### END ### API Data Store Models ### Data_Store_Base() ### # ### END ### API Data Store Models ### Data_Store_Base() ###

View File

@@ -20,7 +20,7 @@ router = APIRouter()
# ### BEGIN ### API Data Store Routers ### post_data_store_obj() ### # ### BEGIN ### API Data Store Routers ### post_data_store_obj() ###
# Updated 2022-03-11 # Updated 2026-01-28
@router.post('/data_store', response_model=Resp_Body_Base) @router.post('/data_store', response_model=Resp_Body_Base)
async def post_data_store_obj( async def post_data_store_obj(
data_store_obj: Data_Store_Base, data_store_obj: Data_Store_Base,
@@ -47,8 +47,6 @@ async def post_data_store_obj(
if return_obj: if return_obj:
data_store_obj = load_data_store_obj( data_store_obj = load_data_store_obj(
data_store_id = data_store_id, data_store_id = data_store_id,
inc_event_cfg = inc_event_cfg,
inc_event_location = inc_event_location,
) )
data = data_store_obj data = data_store_obj
else: else:
@@ -103,7 +101,7 @@ async def patch_data_store_obj(
# ### BEGIN ### API Data Store ### get_data_store_obj() ### # ### BEGIN ### API Data Store ### get_data_store_obj() ###
# Updated 2022-03-11 # Updated 2026-01-28
@router.get('/data_store/{data_store_id}', response_model=Resp_Body_Base) @router.get('/data_store/{data_store_id}', response_model=Resp_Body_Base)
async def get_data_store_obj( async def get_data_store_obj(
data_store_id: str = Path(min_length=11, max_length=22), data_store_id: str = Path(min_length=11, max_length=22),
@@ -121,8 +119,6 @@ async def get_data_store_obj(
data_store_id = data_store_id, data_store_id = data_store_id,
limit = commons.limit, limit = commons.limit,
enabled = commons.enabled, enabled = commons.enabled,
inc_event_cfg = inc_event_cfg,
inc_event_location = inc_event_location,
): ):
log.info('Loading successful. Returning result') log.info('Loading successful. Returning result')
return mk_resp(data=data_store_rec_result, response=commons.response) return mk_resp(data=data_store_rec_result, response=commons.response)
@@ -135,6 +131,46 @@ async def get_data_store_obj(
# ### END ### API Data Store ### get_data_store_obj() ### # ### END ### API Data Store ### get_data_store_obj() ###
# ### BEGIN ### API Data Store ### get_v3_data_store_obj_w_code() ###
# NEW V3 Endpoint for Code Lookup
# Updated 2026-01-28
from app.lib_general_v3 import AccountContext, get_account_context, SerializationParams, StatusFilterParams
@router.get('/v3/data_store/code/{data_store_code}', response_model=Resp_Body_Base, tags=['Data Store V3'])
async def get_v3_data_store_obj_w_code(
data_store_code: str = Path(min_length=3, max_length=50),
for_type: Optional[str] = Query(None, min_length=1, max_length=25),
for_id: Optional[str] = Query(None, min_length=11, max_length=22),
account: AccountContext = Depends(get_account_context),
serialization: SerializationParams = Depends(),
status_filter: StatusFilterParams = Depends(),
):
"""
V3 Standardized Data Store Lookup.
Uses JWT-based AccountContext and supports cascading fallback logic (Object > Account > Global).
"""
log.setLevel(logging.INFO)
# Map V3 params to the shared handler
# We create a dummy Common_Route_Params object to satisfy the handler's interface
# while using the more secure V3 dependencies.
v3_commons = Common_Route_Params(
x_account_id=account.account_id,
x_account_id_random=account.account_id_random,
enabled=status_filter.enabled,
response=Response()
)
return handle_get_data_store_obj_w_code(
data_store_code = data_store_code,
for_type = for_type,
for_id = for_id,
commons = v3_commons,
)
# ### END ### API Data Store ### get_v3_data_store_obj_w_code() ###
# ### BEGIN ### API Data Store ### get_data_store_obj_w_code() ### # ### BEGIN ### API Data Store ### get_data_store_obj_w_code() ###
# NOTE: Adding some explanation because this is not quickly obvious how it fully works. # NOTE: Adding some explanation because this is not quickly obvious how it fully works.
# The look up order starts with a required data_store_code. Then the first result that matches the most specific method. The for_type and for_id fields are not required. I think it makes the most sense to be a part of the URL path, not the GET params. Either should work with no problem though. # The look up order starts with a required data_store_code. Then the first result that matches the most specific method. The for_type and for_id fields are not required. I think it makes the most sense to be a part of the URL path, not the GET params. Either should work with no problem though.

View File

@@ -136,4 +136,35 @@ V3 returns machine-readable error objects in `meta.details` for failures.
- `database_duplicate`: Non-unique value (Code 1062). - `database_duplicate`: Non-unique value (Code 1062).
- `database_constraint`: Foreign key violation (Codes 1451, 1452). - `database_constraint`: Foreign key violation (Codes 1451, 1452).
- `database_schema`: Invalid column name (Codes 1054, 1146). - `database_schema`: Invalid column name (Codes 1054, 1146).
- `validation`: Pydantic validation failed (Check `details` for field-specific errors). - `validation`: Pydantic validation failed (Check `details` for field-specific errors).
---
## 8. Data Store Cascading Lookup (V3)
V3 provides a specialized endpoint for retrieving configuration or content snippets by a human-friendly `code`. This uses a **hierarchical fallback** logic to find the best fit for the current context.
### A. The V3 Lookup Endpoint
**Path:** `GET /v3/data_store/code/{code}`
**Query Parameters:**
| Parameter | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `for_type` | String | No | Parent object type (e.g., `event`, `person`). |
| `for_id` | String | No | Parent object random ID. |
### B. Cascading Logic (Specificity)
The API automatically resolves the "best fit" record in the following order:
1. **Record Specific:** Matches `for_type` + `for_id` (and account).
2. **Account Specific:** Matches the `x-account-id` header.
3. **Global Default:** Matches `code` where `account_id` is `NULL`.
### C. Example Implementation
```ts
// GET /v3/data_store/code/event_launcher_main_info?for_type=event&for_id=nmBfuGFeR0k
export async function get_data_store_v3({ api_cfg, code, for_type, for_id }) {
const endpoint = `/v3/data_store/code/${code}`;
const params = { for_type, for_id };
return await get_object({ api_cfg, endpoint, params });
}
```

View File

@@ -0,0 +1,93 @@
import requests
import json
import argparse
# --- Configuration ---
BASE_URL = "https://dev-api.oneskyit.com"
AGENT_API_KEY = "IDF68Em5X4HTZlswRNgepQ"
# Default Contexts for Testing
CONTEXTS = {
"account_1": "_XY7DXtc9MY",
"account_5": "xFP7AhU8Zlc",
"account_22": "3Iid1aIRY5j",
"account_23": "nqOzejLCDXM",
"event_1358": "nmBfuGFeR0k"
}
def run_lookup(code, description, account_id=None, for_type=None, for_id=None, version="v3"):
"""
Performs a Data Store lookup and prints standardized results.
"""
print(f"[{version.upper()}] {description}")
headers = {
"X-Aether-API-Key": AGENT_API_KEY,
"Content-Type": "application/json"
}
if account_id:
headers["X-Account-ID"] = account_id
else:
headers["X-No-Account-ID"] = "bypass"
if version == "v3":
url = f"{BASE_URL}/v3/data_store/code/{code}"
params = {"for_type": for_type, "for_id": for_id}
response = requests.get(url, headers=headers, params=params)
else:
# Legacy Endpoint
if for_type and for_id:
url = f"{BASE_URL}/data_store/code/{code}/{for_type}/{for_id}"
else:
url = f"{BASE_URL}/data_store/code/{code}"
response = requests.get(url, headers=headers)
print(f" URL: {response.url}")
print(f" Status: {response.status_code}")
if response.status_code == 200:
data = response.json().get('data')
if data:
obj = data[0] if isinstance(data, list) else data
rec_id = obj.get('id') or obj.get('data_store_id') or obj.get('data_store_id_random')
print(f" Result: SUCCESS")
print(f" ID: {rec_id}")
print(f" Name: {obj.get('name')}")
else:
print(f" Result: NULL (No record found or validation failed)")
else:
print(f" Result: ERROR - {response.text[:200]}")
print("-" * 60)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Aether Data Store V3 Lookup Tester")
parser.add_argument("--code", default="event_launcher_main_info", help="The Data Store code to test")
args = parser.parse_args()
print(f"=== Aether Data Store Unified Tester ===")
print(f"Target: {BASE_URL}")
print(f"Code: {args.code}\n")
# 1. Global Context
run_lookup(args.code, "Scenario: Global Context (Bypass Account)")
# 2. Account 1 Context
run_lookup(args.code, "Scenario: Account 1 Context", account_id=CONTEXTS["account_1"])
# 3. Account 22 Context
run_lookup(args.code, "Scenario: Account 22 Context", account_id=CONTEXTS["account_22"])
# 4. Object Specific Context (Event 1358 - belongs to Account 1)
run_lookup(args.code, "Scenario: Event 1358 (under Account 1)",
account_id=CONTEXTS["account_1"],
for_type="event",
for_id=CONTEXTS["event_1358"])
# 5. Cross-Account Security Check (Event 1358 requested by Account 23)
run_lookup(args.code, "Scenario: Security Check (Event 1358 by Account 23 - SHOULD BE NULL)",
account_id=CONTEXTS["account_23"],
for_type="event",
for_id=CONTEXTS["event_1358"])
print("\nTests Complete.")