From cffde249d376a06b627287f6a073dbac7a40884c Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Wed, 25 Mar 2026 17:40:27 -0400 Subject: [PATCH] fix(models): migrate Archive_Content_Base to Vision ID pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace integer `id` (alias archive_content_id) with Vision string fields: `id: Optional[str]` and `archive_content_id: Optional[str]` — both always hold the random string ID, never the DB integer. - Add `root_validator(pre=True)` (map_v3_ids) that maps id_random / archive_content_id_random → id and archive_content_id, with collision prevention to reject any integer that arrives in these fields. - Remove old `archive_content_id_lookup` integer validator (superseded by sanitize_payload + root_validator). - Keep `id_random` (alias archive_content_id_random) in responses for backward compatibility; add id, archive_content_id, id_random to fields_to_exclude_from_db so they never appear in INSERT/UPDATE payloads. Generic CRUD layer safety net (post_obj + post_child_obj): - After building resp_data on create, swap any integer {obj_type}_id with the corresponding {obj_type}_id_random value — catches models not yet migrated to Vision IDs. - Fix return_obj=False fallback to return obj_id as the random string. Docs: add Section 3D to GUIDE__AE_API_V3_for_Frontend.md documenting the Vision ID convention — {obj_type}_id is always the random string; the _id_random suffix is a legacy artifact that frontend code should phase out. Fixes: POST /v3/crud/archive/{id}/archive_content/ returning integer ID, breaking the subsequent PATCH flow (422 min_length validation failure). Co-Authored-By: Claude Sonnet 4.6 --- app/models/archive_content_models.py | 37 ++++++++++++------- app/routers/api_crud_v3.py | 7 +++- app/routers/api_crud_v3_nested.py | 7 +++- .../GUIDE__AE_API_V3_for_Frontend.md | 24 ++++++++++++ 4 files changed, 59 insertions(+), 16 deletions(-) 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