diff --git a/app/models/archive_content_models.py b/app/models/archive_content_models.py index 3c80183..4fd26bd 100644 --- a/app/models/archive_content_models.py +++ b/app/models/archive_content_models.py @@ -1,7 +1,7 @@ import datetime, pytz 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.lib_general import log, logging @@ -18,13 +18,12 @@ class Archive_Content_Base(BaseModel): # log.debug(test_var) # return test_var - id_random: Optional[str] = Field( - # **base_fields['archive_content_id_random'], - alias = 'archive_content_id_random', - ) - id: Optional[int] = Field( - alias = 'archive_content_id' - ) + # --- Vision IDs (primary public identifiers — always random strings) --- + id: Optional[str] = Field(None, **base_fields['archive_content_id_random']) + archive_content_id: Optional[str] = Field(None, **base_fields['archive_content_id_random']) + # Legacy alias kept for backward compatibility; populated by root_validator + id_random: Optional[str] = Field(None, alias='archive_content_id_random') + account_id_random: Optional[str] account_id: Optional[int] @@ -94,6 +93,7 @@ class Archive_Content_Base(BaseModel): # Fields that are part of the model (for reading) but should not be saved to the DB table fields_to_exclude_from_db: ClassVar[list] = [ + 'id', 'archive_content_id', 'id_random', 'account_id', 'account_id_random', 'archive_id_random', 'hosted_file_id_random', 'hosted_file_path', 'api_hosted_file_path_download', 'api_hosted_file_path_stream', 'hosted_file_hash_sha256', 'hosted_file_content_type', 'hosted_file_size' @@ -101,12 +101,21 @@ class Archive_Content_Base(BaseModel): _processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now) - @validator('id', always=True) - def archive_content_id_lookup(cls, v, values, **kwargs): - if isinstance(v, int) and v > 0: return v - elif id_random := values.get('id_random'): - return redis_lookup_id_random(record_id_random=id_random, table_name='archive_content') - return None + @root_validator(pre=True) + def map_v3_ids(cls, values): + """ + Vision Transformer: Map DB random-string keys to clean Vision ID fields. + Collision prevention strips any integer that snuck into the string ID fields. + """ + rid = values.get('id_random') or values.get('archive_content_id_random') + if rid and isinstance(rid, str): + values['id'] = rid + values['archive_content_id'] = rid + # Collision prevention: reject integer values in Vision string fields + for k in ['id', 'archive_content_id']: + if k in values and not isinstance(values[k], str): + del values[k] + return values @validator('archive_id', always=True) def archive_id_lookup(cls, v, values, **kwargs): diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 9d6e181..c5f67bd 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -464,8 +464,13 @@ async def post_obj( 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) + # V3 contract: {obj_type}_id in responses must be the random string, never the integer. + _id_key = f'{obj_name}_id' if serialization.by_alias else 'id' + _rand_key = f'{obj_name}_id_random' if serialization.by_alias else 'id_random' + if isinstance(resp_data.get(_id_key), int) and resp_data.get(_rand_key): + resp_data[_id_key] = resp_data[_rand_key] 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) + return mk_resp(data={"obj_id": new_obj_id_random, "obj_id_random": new_obj_id_random}, response=response) else: # Standardized rich error bubbling db_err = format_db_error(get_last_sql_error()) diff --git a/app/routers/api_crud_v3_nested.py b/app/routers/api_crud_v3_nested.py index ed94015..d3218b8 100644 --- a/app/routers/api_crud_v3_nested.py +++ b/app/routers/api_crud_v3_nested.py @@ -306,8 +306,13 @@ async def post_child_obj( 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) + # V3 contract: {obj_type}_id in responses must be the random string, never the integer. + _id_key = f'{child_obj_type}_id' if serialization.by_alias else 'id' + _rand_key = f'{child_obj_type}_id_random' if serialization.by_alias else 'id_random' + if isinstance(resp_data.get(_id_key), int) and resp_data.get(_rand_key): + resp_data[_id_key] = resp_data[_rand_key] 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) + return mk_resp(data={"obj_id": new_obj_id_random, "obj_id_random": new_obj_id_random}, response=response) else: # Standardized rich error bubbling db_err = format_db_error(get_last_sql_error()) diff --git a/documentation/GUIDE__AE_API_V3_for_Frontend.md b/documentation/GUIDE__AE_API_V3_for_Frontend.md index d0fcbbc..8bcec8c 100644 --- a/documentation/GUIDE__AE_API_V3_for_Frontend.md +++ b/documentation/GUIDE__AE_API_V3_for_Frontend.md @@ -68,6 +68,30 @@ Modify data in the system. * **Header:** `x-ae-ignore-extra-fields: true` * **Behavior:** When set to `true`, the backend will automatically strip any fields from the payload that are not defined in the object's model before attempting to save to the database. +### D. ID Fields in Responses (Vision ID Convention) + +> [!IMPORTANT] +> **V3 responses always use random string IDs — never database integers.** + +After a successful `POST` create or any `GET`, the response contains: + +| Field | Type | Use | +| :--- | :--- | :--- | +| `{obj_type}_id` | `string` | **Primary public ID.** Use this for subsequent `PATCH` calls and UI routing. | +| `{obj_type}_id_random` | `string` | Legacy alias. Same value as `{obj_type}_id`. Present for backward compat only. | + +**Example — create then immediately PATCH:** +```ts +const created = await postArchiveContent(archiveId, payload); +const newId = created.data.archive_content_id; // random string e.g. "xK9mP3qRtL2" + +// Use it directly in the PATCH URL — no lookup needed +await patchArchiveContent(newId, { name: 'Updated Name' }); +// PATCH /v3/crud/archive/{archive_id}/archive_content/{newId} +``` + +> **Note on `_id_random` suffix:** The `{obj_type}_id_random` field is a legacy artifact from the pre-Vision model. Once you confirm `{obj_type}_id` is a random string (length 11–22), you do not need `_id_random` as a fallback. New code should only read `{obj_type}_id`. + --- ## 4. V3 Uniform Lookup System