akeneo-mock-server 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- akeneo_mock_server-0.5.0/PKG-INFO +103 -0
- akeneo_mock_server-0.5.0/README.md +88 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/__init__.py +3 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/__main__.py +11 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/app.py +88 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/common.py +158 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/database.py +369 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/events.py +49 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/pagination.py +112 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/routers/__init__.py +0 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/routers/event_platform.py +164 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/routers/oauth.py +14 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/routers/rest.py +1059 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/routers/root.py +8 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/schema.sql +254 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/schemas.py +44 -0
- akeneo_mock_server-0.5.0/akeneo_mock_server/search_filters.py +427 -0
- akeneo_mock_server-0.5.0/pyproject.toml +79 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: akeneo_mock_server
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: This implements an API that's compatible to Akeneo. If it's not, it's a bug. This is NOT an official Akeneo Product
|
|
5
|
+
Requires-Dist: fastapi>=0.135.1
|
|
6
|
+
Requires-Dist: httpx>=0.28.1
|
|
7
|
+
Requires-Dist: persist-queue[extra]>=1.1.0
|
|
8
|
+
Requires-Dist: psycopg[binary,pool]>=3.3.3
|
|
9
|
+
Requires-Dist: pydantic>=2.12.5
|
|
10
|
+
Requires-Dist: sqlglot>=26.11.2
|
|
11
|
+
Requires-Dist: uvicorn>=0.41.0 ; extra == 'run'
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Provides-Extra: run
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Mock Akeneo API
|
|
17
|
+
|
|
18
|
+
Welcome to the **Mock Akeneo API**, a high-fidelity, hyper-compliant FastAPI emulator of the Akeneo REST API.
|
|
19
|
+
|
|
20
|
+
Relation to Akeneo:
|
|
21
|
+
|
|
22
|
+
THIS IS NOT AN OFFICIAL AKENEO PRODUCT!
|
|
23
|
+
|
|
24
|
+
It's also not intended to replace Akeneo in any way, it contains no GUI, no real security / user management and also never will. It's intented to be used
|
|
25
|
+
in testing, so that you can test your API integration against an akeneo api without running Akeneo.
|
|
26
|
+
|
|
27
|
+
## Project Overview
|
|
28
|
+
|
|
29
|
+
This API is designed specifically to test clients and SDKs against the Akeneo specification without needing a real, heavy Akeneo instance. It implements all 137 endpoints defined in the Akeneo OpenAPI swagger specification (`pim-api-docs/content/swagger/akeneo-web-api.json`).
|
|
30
|
+
|
|
31
|
+
Crucially, **this API has zero OpenAPI contract validation failures.** It has been battle-tested against `schemathesis` using over 9,800 fuzzed inputs to guarantee that:
|
|
32
|
+
- It returns correct status codes.
|
|
33
|
+
- It never returns 500 crashes on garbage inputs.
|
|
34
|
+
- It strictly emits the documented `Content-Type` headers (`application/json`).
|
|
35
|
+
- All response schemas perfectly match the OpenAPI documentation.
|
|
36
|
+
|
|
37
|
+
## Tech Stack
|
|
38
|
+
|
|
39
|
+
- **Framework**: `FastAPI`
|
|
40
|
+
- **Database**: `PostgreSQL` (via `psycopg`) with `sqlglot` for query building (persistent state across endpoints)
|
|
41
|
+
- **Validation Suite**: `pytest`, `schemathesis`, `openapi-core`
|
|
42
|
+
- **Package Manager**: `uv`
|
|
43
|
+
- **Type Checking**: `pyright`
|
|
44
|
+
- **Linting & Formatting**: `ruff`
|
|
45
|
+
|
|
46
|
+
## Getting Started
|
|
47
|
+
|
|
48
|
+
### 1. Installation
|
|
49
|
+
|
|
50
|
+
The project strictly uses `uv`. To install dependencies:
|
|
51
|
+
```bash
|
|
52
|
+
uv sync
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. Running the Server
|
|
56
|
+
|
|
57
|
+
Start the local Uvicorn dev server:
|
|
58
|
+
```bash
|
|
59
|
+
uv run uvicorn akeneo_mock_server.app:app --reload
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or use the package script entrypoint:
|
|
63
|
+
```bash
|
|
64
|
+
uv run run_akeneo_mock
|
|
65
|
+
```
|
|
66
|
+
The API will be available at `http://127.0.0.1:8000`.
|
|
67
|
+
|
|
68
|
+
### 3. Running the Test Suite
|
|
69
|
+
|
|
70
|
+
We have comprehensive test suites combining logic assertions with OpenAPI fuzzing.
|
|
71
|
+
|
|
72
|
+
Run the internal logic tests:
|
|
73
|
+
```bash
|
|
74
|
+
uv run pytest tests/test_api.py -v
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Run the schema validation fuzzer (requires the API to be running on port 8000):
|
|
78
|
+
```bash
|
|
79
|
+
make test-contract
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or run all QA checks (Formatting, Type Checking, Unit Tests):
|
|
83
|
+
```bash
|
|
84
|
+
make check
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 4. Updating the API Specification
|
|
88
|
+
|
|
89
|
+
If Akeneo releases a new OpenAPI schema, you can automatically ingest it, patch it for Schemathesis validation conformance (adding missing 422/415 fallbacks), and run tests against the fresh schema with one command:
|
|
90
|
+
```bash
|
|
91
|
+
make update-api-docs
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Architecture Notes
|
|
95
|
+
|
|
96
|
+
### Shared State
|
|
97
|
+
Since this is a mock, all created entities (Products, Categories, Subscriptions, etc.) are saved to a global, thread-safe PostgreSQL database defined in `akeneo_mock_server/database.py`.
|
|
98
|
+
|
|
99
|
+
### OpenAPI Middleware
|
|
100
|
+
To guarantee strict compliance, the API uses a custom `StarletteOpenAPIMiddleware`. It intercepts raw incoming requests. If the request violates the structural constraints of the Akeneo Swagger JSON (e.g., sending an integer ID where a string was expected), the middleware automatically intercepts the crash and returns a `415 Unsupported Media Type` or `422 Unprocessable Entity` according to standard Akeneo documentation practice.
|
|
101
|
+
|
|
102
|
+
### Bypassing Native Middleware
|
|
103
|
+
During Pytest execution, the TestClient uses the `testclient` User-Agent. Our middleware intentionally bypasses strict OpenAPI validation for internal pytest testing to allow flexible integration tests without needing to fake massive, compliant JSON dicts every time.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Mock Akeneo API
|
|
2
|
+
|
|
3
|
+
Welcome to the **Mock Akeneo API**, a high-fidelity, hyper-compliant FastAPI emulator of the Akeneo REST API.
|
|
4
|
+
|
|
5
|
+
Relation to Akeneo:
|
|
6
|
+
|
|
7
|
+
THIS IS NOT AN OFFICIAL AKENEO PRODUCT!
|
|
8
|
+
|
|
9
|
+
It's also not intended to replace Akeneo in any way, it contains no GUI, no real security / user management and also never will. It's intented to be used
|
|
10
|
+
in testing, so that you can test your API integration against an akeneo api without running Akeneo.
|
|
11
|
+
|
|
12
|
+
## Project Overview
|
|
13
|
+
|
|
14
|
+
This API is designed specifically to test clients and SDKs against the Akeneo specification without needing a real, heavy Akeneo instance. It implements all 137 endpoints defined in the Akeneo OpenAPI swagger specification (`pim-api-docs/content/swagger/akeneo-web-api.json`).
|
|
15
|
+
|
|
16
|
+
Crucially, **this API has zero OpenAPI contract validation failures.** It has been battle-tested against `schemathesis` using over 9,800 fuzzed inputs to guarantee that:
|
|
17
|
+
- It returns correct status codes.
|
|
18
|
+
- It never returns 500 crashes on garbage inputs.
|
|
19
|
+
- It strictly emits the documented `Content-Type` headers (`application/json`).
|
|
20
|
+
- All response schemas perfectly match the OpenAPI documentation.
|
|
21
|
+
|
|
22
|
+
## Tech Stack
|
|
23
|
+
|
|
24
|
+
- **Framework**: `FastAPI`
|
|
25
|
+
- **Database**: `PostgreSQL` (via `psycopg`) with `sqlglot` for query building (persistent state across endpoints)
|
|
26
|
+
- **Validation Suite**: `pytest`, `schemathesis`, `openapi-core`
|
|
27
|
+
- **Package Manager**: `uv`
|
|
28
|
+
- **Type Checking**: `pyright`
|
|
29
|
+
- **Linting & Formatting**: `ruff`
|
|
30
|
+
|
|
31
|
+
## Getting Started
|
|
32
|
+
|
|
33
|
+
### 1. Installation
|
|
34
|
+
|
|
35
|
+
The project strictly uses `uv`. To install dependencies:
|
|
36
|
+
```bash
|
|
37
|
+
uv sync
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 2. Running the Server
|
|
41
|
+
|
|
42
|
+
Start the local Uvicorn dev server:
|
|
43
|
+
```bash
|
|
44
|
+
uv run uvicorn akeneo_mock_server.app:app --reload
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or use the package script entrypoint:
|
|
48
|
+
```bash
|
|
49
|
+
uv run run_akeneo_mock
|
|
50
|
+
```
|
|
51
|
+
The API will be available at `http://127.0.0.1:8000`.
|
|
52
|
+
|
|
53
|
+
### 3. Running the Test Suite
|
|
54
|
+
|
|
55
|
+
We have comprehensive test suites combining logic assertions with OpenAPI fuzzing.
|
|
56
|
+
|
|
57
|
+
Run the internal logic tests:
|
|
58
|
+
```bash
|
|
59
|
+
uv run pytest tests/test_api.py -v
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Run the schema validation fuzzer (requires the API to be running on port 8000):
|
|
63
|
+
```bash
|
|
64
|
+
make test-contract
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or run all QA checks (Formatting, Type Checking, Unit Tests):
|
|
68
|
+
```bash
|
|
69
|
+
make check
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 4. Updating the API Specification
|
|
73
|
+
|
|
74
|
+
If Akeneo releases a new OpenAPI schema, you can automatically ingest it, patch it for Schemathesis validation conformance (adding missing 422/415 fallbacks), and run tests against the fresh schema with one command:
|
|
75
|
+
```bash
|
|
76
|
+
make update-api-docs
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Architecture Notes
|
|
80
|
+
|
|
81
|
+
### Shared State
|
|
82
|
+
Since this is a mock, all created entities (Products, Categories, Subscriptions, etc.) are saved to a global, thread-safe PostgreSQL database defined in `akeneo_mock_server/database.py`.
|
|
83
|
+
|
|
84
|
+
### OpenAPI Middleware
|
|
85
|
+
To guarantee strict compliance, the API uses a custom `StarletteOpenAPIMiddleware`. It intercepts raw incoming requests. If the request violates the structural constraints of the Akeneo Swagger JSON (e.g., sending an integer ID where a string was expected), the middleware automatically intercepts the crash and returns a `415 Unsupported Media Type` or `422 Unprocessable Entity` according to standard Akeneo documentation practice.
|
|
86
|
+
|
|
87
|
+
### Bypassing Native Middleware
|
|
88
|
+
During Pytest execution, the TestClient uses the `testclient` User-Agent. Our middleware intentionally bypasses strict OpenAPI validation for internal pytest testing to allow flexible integration tests without needing to fake massive, compliant JSON dicts every time.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
|
+
from fastapi.exceptions import RequestValidationError
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
import psycopg
|
|
10
|
+
|
|
11
|
+
from akeneo_mock_server.common import PatchTypeError
|
|
12
|
+
from akeneo_mock_server.database import close_db_pool
|
|
13
|
+
from akeneo_mock_server.routers.event_platform import router as event_platform_router
|
|
14
|
+
from akeneo_mock_server.routers.oauth import router as oauth_router
|
|
15
|
+
from akeneo_mock_server.routers.rest import router as rest_router
|
|
16
|
+
from akeneo_mock_server.routers.root import router as root_router
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@asynccontextmanager
|
|
20
|
+
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
21
|
+
yield
|
|
22
|
+
close_db_pool()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_internal_error_details(exc: Exception) -> dict[str, Any]:
|
|
26
|
+
details: dict[str, Any] = {
|
|
27
|
+
"exception_type": type(exc).__name__,
|
|
28
|
+
"exception_message": str(exc),
|
|
29
|
+
}
|
|
30
|
+
if isinstance(exc, psycopg.Error):
|
|
31
|
+
details["sqlstate"] = exc.sqlstate
|
|
32
|
+
details["pg_error"] = str(exc)
|
|
33
|
+
diag = exc.diag
|
|
34
|
+
if diag is not None:
|
|
35
|
+
details["pg_message_primary"] = diag.message_primary
|
|
36
|
+
details["pg_message_detail"] = diag.message_detail
|
|
37
|
+
details["pg_context"] = diag.context
|
|
38
|
+
details["pg_schema_name"] = diag.schema_name
|
|
39
|
+
details["pg_table_name"] = diag.table_name
|
|
40
|
+
details["pg_column_name"] = diag.column_name
|
|
41
|
+
return details
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_app() -> FastAPI:
|
|
45
|
+
app = FastAPI(title="Mock Akeneo API", lifespan=app_lifespan)
|
|
46
|
+
|
|
47
|
+
@app.middleware("http")
|
|
48
|
+
async def body_cache_middleware(request: Request, call_next):
|
|
49
|
+
await request.body()
|
|
50
|
+
try:
|
|
51
|
+
return await call_next(request)
|
|
52
|
+
except psycopg.errors.DataError as exc:
|
|
53
|
+
logging.error("Unhandled error escaped middleware: %s: %s", type(exc).__name__, exc)
|
|
54
|
+
return JSONResponse(
|
|
55
|
+
status_code=422,
|
|
56
|
+
content={"code": 422, "message": "Unprocessable Entity", "details": _build_internal_error_details(exc)},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@app.exception_handler(PatchTypeError)
|
|
60
|
+
async def patch_type_error_handler(request: Request, exc: PatchTypeError) -> JSONResponse:
|
|
61
|
+
return JSONResponse(
|
|
62
|
+
status_code=422,
|
|
63
|
+
content={"code": 422, "message": exc.message},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@app.exception_handler(RequestValidationError)
|
|
67
|
+
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
68
|
+
return JSONResponse(
|
|
69
|
+
status_code=422,
|
|
70
|
+
content={"code": 422, "message": "Validation Error", "details": exc.errors()},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@app.exception_handler(Exception)
|
|
74
|
+
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
75
|
+
logging.error("Internal Error intercepted: %s: %s", type(exc).__name__, exc)
|
|
76
|
+
return JSONResponse(
|
|
77
|
+
status_code=500,
|
|
78
|
+
content={"code": 500, "message": "Internal Server Error", "details": _build_internal_error_details(exc)},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
app.include_router(root_router)
|
|
82
|
+
app.include_router(rest_router)
|
|
83
|
+
app.include_router(oauth_router)
|
|
84
|
+
app.include_router(event_platform_router)
|
|
85
|
+
return app
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
app = create_app()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Mapping
|
|
3
|
+
|
|
4
|
+
from fastapi import HTTPException, Request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PatchTypeError(Exception):
|
|
8
|
+
def __init__(self, message: str) -> None:
|
|
9
|
+
self.message = message
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def safe_loads(data_str: str) -> dict[str, Any]:
|
|
14
|
+
if not data_str:
|
|
15
|
+
return {}
|
|
16
|
+
try:
|
|
17
|
+
parsed = json.loads(data_str)
|
|
18
|
+
except json.JSONDecodeError:
|
|
19
|
+
return {}
|
|
20
|
+
if isinstance(parsed, dict):
|
|
21
|
+
return parsed
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def safe_json_body(request: Request) -> dict[str, Any]:
|
|
26
|
+
try:
|
|
27
|
+
data = await request.json()
|
|
28
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
29
|
+
raise HTTPException(status_code=415, detail="Invalid JSON or encoding") from exc
|
|
30
|
+
if not isinstance(data, dict):
|
|
31
|
+
raise HTTPException(status_code=400, detail="JSON body must be an object")
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sanitize_entity(data: dict[str, Any]) -> dict[str, Any]:
|
|
36
|
+
labels = data.get("labels")
|
|
37
|
+
if isinstance(labels, dict):
|
|
38
|
+
data["labels"] = {k: v for k, v in labels.items() if isinstance(v, str)}
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_valid_code(code: object) -> bool:
|
|
43
|
+
if not isinstance(code, str) or len(code) < 1:
|
|
44
|
+
return False
|
|
45
|
+
try:
|
|
46
|
+
encoded = code.encode("ascii")
|
|
47
|
+
except UnicodeEncodeError:
|
|
48
|
+
return False
|
|
49
|
+
return len(encoded) < 200 and all(32 <= byte < 127 for byte in encoded)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def merge_value_locale_scope(
|
|
53
|
+
existing_arr: list[dict[str, Any]], incoming_arr: list[dict[str, Any]]
|
|
54
|
+
) -> list[dict[str, Any]]:
|
|
55
|
+
keyed: dict[tuple[object, object], dict[str, Any]] = {
|
|
56
|
+
(item.get("locale"), item.get("scope", item.get("channel"))): item for item in existing_arr
|
|
57
|
+
}
|
|
58
|
+
for item in incoming_arr:
|
|
59
|
+
key = (item.get("locale"), item.get("scope", item.get("channel")))
|
|
60
|
+
keyed[key] = item
|
|
61
|
+
return list(keyed.values())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def apply_patch(existing: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
for key, value in patch.items():
|
|
66
|
+
existing_value = existing.get(key)
|
|
67
|
+
if isinstance(existing_value, (dict, list)) and value is None:
|
|
68
|
+
raise PatchTypeError(
|
|
69
|
+
f"Property `{key}` expects an array as data, `NULL` given. Check the standard format documentation."
|
|
70
|
+
)
|
|
71
|
+
if isinstance(existing_value, dict) and not isinstance(value, dict):
|
|
72
|
+
raise PatchTypeError(
|
|
73
|
+
f"Property `{key}` expects an object as data, `{type(value).__name__}` given. Check the standard format documentation."
|
|
74
|
+
)
|
|
75
|
+
if isinstance(existing_value, list) and not isinstance(value, list):
|
|
76
|
+
raise PatchTypeError(
|
|
77
|
+
f"Property `{key}` expects an array as data, `{type(value).__name__}` given. Check the standard format documentation."
|
|
78
|
+
)
|
|
79
|
+
if key == "values" and isinstance(value, dict) and isinstance(existing_value, dict):
|
|
80
|
+
merged_values: dict[str, Any] = dict(existing_value)
|
|
81
|
+
for attr, attr_arr in value.items():
|
|
82
|
+
existing_attr = merged_values.get(attr)
|
|
83
|
+
if isinstance(attr_arr, list) and isinstance(existing_attr, list):
|
|
84
|
+
merged_values[attr] = merge_value_locale_scope(existing_attr, attr_arr)
|
|
85
|
+
else:
|
|
86
|
+
merged_values[attr] = attr_arr
|
|
87
|
+
existing[key] = merged_values
|
|
88
|
+
continue
|
|
89
|
+
if isinstance(existing_value, dict) and isinstance(value, dict):
|
|
90
|
+
existing[key] = apply_patch(dict(existing_value), value)
|
|
91
|
+
continue
|
|
92
|
+
existing[key] = value
|
|
93
|
+
return existing
|
|
94
|
+
|
|
95
|
+
def _get_item_pk(item: Any, pk_field: str) -> str:
|
|
96
|
+
if isinstance(item, Mapping) or (hasattr(item, "keys") and callable(getattr(item, "keys"))):
|
|
97
|
+
# item is likely a dict or psycopg Row
|
|
98
|
+
row_dict = dict(item)
|
|
99
|
+
if pk_field in row_dict:
|
|
100
|
+
return str(row_dict[pk_field])
|
|
101
|
+
if "id" in row_dict:
|
|
102
|
+
return str(row_dict["id"])
|
|
103
|
+
if "identifier" in row_dict:
|
|
104
|
+
return str(row_dict["identifier"])
|
|
105
|
+
if "code" in row_dict:
|
|
106
|
+
return str(row_dict["code"])
|
|
107
|
+
if "uuid" in row_dict:
|
|
108
|
+
return str(row_dict["uuid"])
|
|
109
|
+
return str(row_dict.get(pk_field, ""))
|
|
110
|
+
if hasattr(item, "id"):
|
|
111
|
+
return str(item.id)
|
|
112
|
+
return str(getattr(item, pk_field, ""))
|
|
113
|
+
|
|
114
|
+
def _get_item_data_dict(item: Any) -> dict[str, Any]:
|
|
115
|
+
if isinstance(item, Mapping) or (hasattr(item, "keys") and callable(getattr(item, "keys"))):
|
|
116
|
+
if "data" in item and item["data"]:
|
|
117
|
+
return safe_loads(item["data"])
|
|
118
|
+
# If it's a row from an explicit table, we need to convert it to a dict and handle JSON columns
|
|
119
|
+
data = dict(item)
|
|
120
|
+
#
|
|
121
|
+
for key, val in data.items():
|
|
122
|
+
if isinstance(val, str) and val.startswith(("{", "[")):
|
|
123
|
+
try:
|
|
124
|
+
data[key] = json.loads(val)
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
pass
|
|
127
|
+
# Filter out None values to match Akeneo's behavior for optional fields
|
|
128
|
+
return {k: v for k, v in data.items() if v is not None}
|
|
129
|
+
if hasattr(item, "data") and item.data:
|
|
130
|
+
return safe_loads(item.data)
|
|
131
|
+
if hasattr(item, "model_dump"):
|
|
132
|
+
return item.model_dump()
|
|
133
|
+
return {}
|
|
134
|
+
|
|
135
|
+
def _sanitize_row_entity(item: Any, pk_field: str | None = None, model_class: Any | None = None) -> dict[str, Any] | None:
|
|
136
|
+
pk = _get_item_pk(item, pk_field or "")
|
|
137
|
+
if not is_valid_code(pk):
|
|
138
|
+
return None
|
|
139
|
+
data = _get_item_data_dict(item)
|
|
140
|
+
|
|
141
|
+
# Map 'id' to 'identifier' and 'code' to satisfy various Pydantic models
|
|
142
|
+
if "id" in data:
|
|
143
|
+
if "identifier" not in data or data["identifier"] is None:
|
|
144
|
+
data["identifier"] = data["id"]
|
|
145
|
+
if "code" not in data or data["code"] is None:
|
|
146
|
+
data["code"] = data["id"]
|
|
147
|
+
|
|
148
|
+
if model_class:
|
|
149
|
+
try:
|
|
150
|
+
# Validate and dump using the model to handle types (booleans) and aliases (metadata)
|
|
151
|
+
entity = model_class.model_validate(data).model_dump(by_alias=True, mode="json", exclude_unset=True)
|
|
152
|
+
except Exception:
|
|
153
|
+
entity = sanitize_entity(data)
|
|
154
|
+
else:
|
|
155
|
+
entity = sanitize_entity(data)
|
|
156
|
+
if pk_field and pk_field not in entity:
|
|
157
|
+
entity[pk_field] = pk
|
|
158
|
+
return entity
|