odoo-fastapi-gateway 18.1.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.
Files changed (25) hide show
  1. odoo_fastapi_gateway-18.1.0/PKG-INFO +14 -0
  2. odoo_fastapi_gateway-18.1.0/README.md +145 -0
  3. odoo_fastapi_gateway-18.1.0/pyproject.toml +27 -0
  4. odoo_fastapi_gateway-18.1.0/setup.cfg +4 -0
  5. odoo_fastapi_gateway-18.1.0/src/odoo_api/__init__.py +29 -0
  6. odoo_fastapi_gateway-18.1.0/src/odoo_api/app.py +35 -0
  7. odoo_fastapi_gateway-18.1.0/src/odoo_api/core/__init__.py +0 -0
  8. odoo_fastapi_gateway-18.1.0/src/odoo_api/core/config.py +65 -0
  9. odoo_fastapi_gateway-18.1.0/src/odoo_api/core/connection.py +62 -0
  10. odoo_fastapi_gateway-18.1.0/src/odoo_api/dependencies/__init__.py +0 -0
  11. odoo_fastapi_gateway-18.1.0/src/odoo_api/dependencies/auth.py +147 -0
  12. odoo_fastapi_gateway-18.1.0/src/odoo_api/dependencies/odoo_session.py +59 -0
  13. odoo_fastapi_gateway-18.1.0/src/odoo_api/exceptions.py +35 -0
  14. odoo_fastapi_gateway-18.1.0/src/odoo_api/models/__init__.py +0 -0
  15. odoo_fastapi_gateway-18.1.0/src/odoo_api/models/base.py +11 -0
  16. odoo_fastapi_gateway-18.1.0/src/odoo_api/models/request.py +95 -0
  17. odoo_fastapi_gateway-18.1.0/src/odoo_api/routers/__init__.py +7 -0
  18. odoo_fastapi_gateway-18.1.0/src/odoo_api/routers/auth.py +17 -0
  19. odoo_fastapi_gateway-18.1.0/src/odoo_api/routers/health.py +8 -0
  20. odoo_fastapi_gateway-18.1.0/src/odoo_api/routers/models.py +103 -0
  21. odoo_fastapi_gateway-18.1.0/src/odoo_fastapi_gateway.egg-info/PKG-INFO +14 -0
  22. odoo_fastapi_gateway-18.1.0/src/odoo_fastapi_gateway.egg-info/SOURCES.txt +23 -0
  23. odoo_fastapi_gateway-18.1.0/src/odoo_fastapi_gateway.egg-info/dependency_links.txt +1 -0
  24. odoo_fastapi_gateway-18.1.0/src/odoo_fastapi_gateway.egg-info/requires.txt +10 -0
  25. odoo_fastapi_gateway-18.1.0/src/odoo_fastapi_gateway.egg-info/top_level.txt +1 -0
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: odoo-fastapi-gateway
3
+ Version: 18.1.0
4
+ Summary: FastAPI gateway library for Odoo XML-RPC integration
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: fastapi>=0.100.0
7
+ Requires-Dist: uvicorn>=0.20.0
8
+ Requires-Dist: python-jose[cryptography]>=3.3.0
9
+ Requires-Dist: python-dotenv>=1.0.0
10
+ Requires-Dist: cryptography>=41.0.0
11
+ Requires-Dist: pydantic-settings>=2.0.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == "dev"
14
+ Requires-Dist: httpx>=0.24.0; extra == "dev"
@@ -0,0 +1,145 @@
1
+ # odoo-fastapi-gateway
2
+
3
+ <p align="center"><em>A modular FastAPI library for integrating with Odoo via XML-RPC. Install as a package, plug in pre-built routers, and start building.</em></p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.11+-blue.svg" alt="Python 3.11+"></a>
7
+ <a href="https://fastapi.tiangolo.com"><img src="https://img.shields.io/badge/FastAPI-0.100+-009688.svg" alt="FastAPI"></a>
8
+ <a href="https://www.odoo.com/documentation/18.0/developer/reference/external_api.html"><img src="https://img.shields.io/badge/Odoo-18-714B67.svg" alt="Odoo 18"></a>
9
+ </p>
10
+
11
+ ---
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ pip install odoo-fastapi-gateway
17
+ ```
18
+
19
+ ```python
20
+ from fastapi import FastAPI
21
+ from odoo_api import get_routers, register_exception_handlers
22
+
23
+ app = FastAPI()
24
+ register_exception_handlers(app)
25
+
26
+ for router in get_routers():
27
+ app.include_router(router)
28
+ ```
29
+
30
+ ```bash
31
+ uvicorn app:app --reload
32
+ ```
33
+
34
+ That's it — you now have login, CRUD, and execute endpoints for any Odoo model.
35
+
36
+ ## Features
37
+
38
+ - **Plug & play routers** — Health, auth, and full CRUD included out of the box
39
+ - **JWT + AES-256-CBC** — Passwords encrypted in tokens, never stored in plain text
40
+ - **Pydantic validation** — Type-safe request/response models with limit enforcement
41
+ - **Async XML-RPC** — All Odoo calls run in a thread pool, non-blocking
42
+ - **Extensible** — Add custom routers using the same auth dependency
43
+
44
+ ## API Endpoints
45
+
46
+ | Method | Path | Auth | Description |
47
+ |--------|------|------|-------------|
48
+ | `GET` | `/api/health` | — | Health check |
49
+ | `POST` | `/api/login` | — | Authenticate with Odoo, returns JWT |
50
+ | `GET` | `/api/model/{model}/{record_id}` | Bearer | Read a single record |
51
+ | `POST` | `/api/models/search/{model}` | Bearer | Search/list records |
52
+ | `POST` | `/api/models/{model}` | Bearer | Create records |
53
+ | `PUT` | `/api/models/{model}` | Bearer | Update records |
54
+ | `DELETE` | `/api/models/{model}` | Bearer | Delete records |
55
+ | `POST` | `/api/models/{model}/execute/{func_name}` | Bearer | Call custom Odoo method |
56
+
57
+ > **Model name convention:** Use hyphens in URLs — converted to dots automatically.
58
+ > `/api/models/search/sale-order` → `sale.order`
59
+
60
+ ## Configuration
61
+
62
+ Create a `.env` file (or set environment variables):
63
+
64
+ ```ini
65
+ ODOO_HOST=http://localhost:8069
66
+ JWT_SECRET_KEY=<your-64-char-hex-key>
67
+ AES_SECRET_KEY=<your-64-char-hex-key>
68
+ ```
69
+
70
+ Generate keys: `python3 -c "import secrets; print(secrets.token_hex(32))"`
71
+
72
+ | Variable | Default | Description |
73
+ |----------|---------|-------------|
74
+ | `ODOO_HOST` | `http://odoo:8069` | Odoo server URL |
75
+ | `JWT_SECRET_KEY` | — *(required)* | JWT signing key (min 32 chars) |
76
+ | `AES_SECRET_KEY` | — *(required)* | AES encryption key (min 32 chars) |
77
+ | `HASH_ALGORITHM` | `HS256` | JWT hash algorithm |
78
+ | `ACCESS_TOKEN_EXPIRE_MINUTES` | `None` | Token TTL (`None` = no expiry) |
79
+ | `LIMIT_SEARCH_RECORDS` | `50` | Max records per search |
80
+ | `LIMIT_CREATE_RECORDS` | `50` | Max records per create |
81
+ | `RATE_LIMIT` | `100/minute` | Rate limit string |
82
+
83
+ ## Installation
84
+
85
+ ### From PyPI
86
+
87
+ ```bash
88
+ pip install odoo-fastapi-gateway
89
+ ```
90
+
91
+ ### Development (editable)
92
+
93
+ ```bash
94
+ git clone https://github.com/vdx-vn/odoo-api.git
95
+ cd odoo-api && pip install -e .
96
+ ```
97
+
98
+ ### Dependencies
99
+
100
+ Installed automatically:
101
+
102
+ `fastapi` · `uvicorn` · `python-jose[cryptography]` · `cryptography` · `pydantic-settings` · `python-dotenv`
103
+
104
+ Optional (not included): `slowapi` (rate limiting) · `gunicorn` (production)
105
+
106
+ ## Documentation
107
+
108
+ | Topic | Link |
109
+ |-------|------|
110
+ | Architecture diagrams | [docs/architecture.md](docs/architecture.md) |
111
+ | Public API reference | [docs/api-reference.md](docs/api-reference.md) |
112
+ | Extending the library | [docs/extending.md](docs/extending.md) |
113
+ | Security details | [docs/security.md](docs/security.md) |
114
+ | Docker deployment | [docs/docker.md](docs/docker.md) |
115
+ | Publishing to PyPI | [docs/publishing.md](docs/publishing.md) |
116
+ | Request body examples | [docs/request-examples.md](docs/request-examples.md) |
117
+
118
+ ## Project Structure
119
+
120
+ ```
121
+ src/odoo_api/
122
+ ├── __init__.py # Public API exports
123
+ ├── exceptions.py # APIException, UnauthorizedException, DecryptionError
124
+ ├── core/
125
+ │ ├── config.py # AppSettings (pydantic-settings) + get_settings()
126
+ │ └── connection.py # call_xmlrpc, execute, sanitize_model
127
+ ├── dependencies/
128
+ │ ├── auth.py # JWT creation, AES encrypt/decrypt, authenticate()
129
+ │ └── odoo_session.py # get_logged_in_user dependency, auth models
130
+ ├── models/
131
+ │ ├── base.py # APIBaseModel (Pydantic base with settings access)
132
+ │ └── request.py # Search, Create, Update, Delete, ExecuteFunction
133
+ └── routers/
134
+ ├── __init__.py # get_routers() helper
135
+ ├── health.py # GET /api/health
136
+ ├── auth.py # POST /api/login
137
+ └── models.py # CRUD + execute endpoints
138
+ ```
139
+
140
+ ## References
141
+
142
+ - [FastAPI](https://fastapi.tiangolo.com/)
143
+ - [Odoo 18 External API](https://www.odoo.com/documentation/18.0/developer/reference/external_api.html)
144
+ - [SlowAPI](https://github.com/laurentS/slowapi)
145
+ - [Gunicorn](https://docs.gunicorn.org/en/stable/settings.html)
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+
3
+ requires = ["setuptools>=68.0", "wheel"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "odoo-fastapi-gateway"
8
+ version = "18.1.0"
9
+ description = "FastAPI gateway library for Odoo XML-RPC integration"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "fastapi>=0.100.0",
13
+ "uvicorn>=0.20.0",
14
+ "python-jose[cryptography]>=3.3.0",
15
+ "python-dotenv>=1.0.0",
16
+ "cryptography>=41.0.0",
17
+ "pydantic-settings>=2.0.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "pytest>=7.0",
23
+ "httpx>=0.24.0",
24
+ ]
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,29 @@
1
+ from odoo_api.core.config import AppSettings, get_settings
2
+ from odoo_api.core.connection import execute, sanitize_model, call_xmlrpc
3
+ from odoo_api.dependencies.odoo_session import (
4
+ AuthenticatedUser, Token, AuthenticationBody, get_logged_in_user
5
+ )
6
+ from odoo_api.dependencies.auth import authenticate, create_access_token
7
+ from odoo_api.models.request import (
8
+ SearchDataModel, CreateDataModel, UpdateDataModel,
9
+ DeleteDataModel, ExecuteFunctionModel
10
+ )
11
+ from odoo_api.exceptions import (
12
+ APIException, UnauthorizedException, DecryptionError,
13
+ register_exception_handlers,
14
+ )
15
+ from odoo_api.routers import get_routers
16
+ from odoo_api.app import create_app
17
+
18
+ __all__ = [
19
+ "AppSettings", "get_settings",
20
+ "execute", "sanitize_model", "call_xmlrpc",
21
+ "AuthenticatedUser", "Token", "AuthenticationBody", "get_logged_in_user",
22
+ "authenticate", "create_access_token",
23
+ "SearchDataModel", "CreateDataModel", "UpdateDataModel",
24
+ "DeleteDataModel", "ExecuteFunctionModel",
25
+ "APIException", "UnauthorizedException", "DecryptionError",
26
+ "register_exception_handlers",
27
+ "get_routers",
28
+ "create_app",
29
+ ]
@@ -0,0 +1,35 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from fastapi import FastAPI
4
+
5
+ from odoo_api.routers import get_routers
6
+ from odoo_api.exceptions import register_exception_handlers
7
+
8
+ OPENAPI_TAGS = [
9
+ {"name": "Health", "description": "Server health check"},
10
+ {"name": "Authentication", "description": "Login and token management"},
11
+ {"name": "Models", "description": "CRUD operations and function execution on Odoo models"},
12
+ ]
13
+
14
+
15
+ def create_app(
16
+ title: str = "Odoo FastAPI Gateway",
17
+ description: str = "REST API gateway for Odoo ERP via XML-RPC.",
18
+ version: str = "0.1.0",
19
+ **kwargs,
20
+ ) -> FastAPI:
21
+ """Create a FastAPI app pre-configured with Odoo gateway routers, exception handlers, and OpenAPI docs."""
22
+ app = FastAPI(
23
+ title=title,
24
+ description=description,
25
+ version=version,
26
+ openapi_tags=OPENAPI_TAGS,
27
+ **kwargs,
28
+ )
29
+
30
+ register_exception_handlers(app)
31
+
32
+ for router in get_routers():
33
+ app.include_router(router)
34
+
35
+ return app
@@ -0,0 +1,65 @@
1
+ # -*- coding:utf-8 -*-
2
+
3
+ import re
4
+ from functools import lru_cache
5
+ from typing import Optional
6
+
7
+ from pydantic import field_validator
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class AppSettings(BaseSettings):
12
+ odoo_host: str = 'http://odoo:8069'
13
+ jwt_secret_key: str
14
+ aes_secret_key: str
15
+ hash_algorithm: str = 'HS256'
16
+ rate_limit: str = '100/minute'
17
+ access_token_expire_minutes: Optional[int] = None
18
+ limit_search_records: Optional[int] = 50
19
+ limit_create_records: Optional[int] = 50
20
+
21
+ model_config = SettingsConfigDict(
22
+ extra='ignore',
23
+ env_file=('.env',),
24
+ env_file_encoding='utf-8',
25
+ )
26
+
27
+ @field_validator('jwt_secret_key', 'aes_secret_key')
28
+ @classmethod
29
+ def validate_secret_keys(cls, v: str, info) -> str:
30
+ if len(v) < 32:
31
+ raise ValueError(f'{info.field_name} must be at least 32 characters')
32
+ return v
33
+
34
+ @field_validator('rate_limit')
35
+ @classmethod
36
+ def validate_rate_limit(cls, v: str) -> str:
37
+ if not re.match(r'^\d+/(second|minute|hour|day)$', v):
38
+ raise ValueError('rate_limit must match format like "100/minute"')
39
+ return v
40
+
41
+ @field_validator('access_token_expire_minutes')
42
+ @classmethod
43
+ def validate_expire_minutes(cls, v: Optional[int]) -> Optional[int]:
44
+ if v is not None and v <= 0:
45
+ raise ValueError('access_token_expire_minutes must be positive')
46
+ return v
47
+
48
+ @field_validator('limit_search_records', 'limit_create_records')
49
+ @classmethod
50
+ def validate_limits(cls, v: Optional[int], info) -> Optional[int]:
51
+ if v is not None and v <= 0:
52
+ raise ValueError(f'{info.field_name} must be positive')
53
+ return v
54
+
55
+ @field_validator('odoo_host')
56
+ @classmethod
57
+ def validate_odoo_host(cls, v: str) -> str:
58
+ if not re.match(r'^https?://', v):
59
+ raise ValueError('odoo_host must be a valid HTTP(S) URL')
60
+ return v
61
+
62
+
63
+ @lru_cache()
64
+ def get_settings():
65
+ return AppSettings()
@@ -0,0 +1,62 @@
1
+ # -*- coding:utf-8 -*-
2
+
3
+ import asyncio
4
+ import re
5
+ import xmlrpc.client
6
+
7
+ from odoo_api.exceptions import APIException
8
+
9
+
10
+ def call_xmlrpc(url: str, method: str, *args):
11
+ proxy = xmlrpc.client.ServerProxy(url)
12
+ return getattr(proxy, method)(*args)
13
+
14
+
15
+ def get_additional_context():
16
+ return {
17
+ "active_test": False
18
+ }
19
+
20
+
21
+ def sanitize_error_message(err: Exception) -> str:
22
+ if hasattr(err, 'faultString'):
23
+ error_message = err.faultString
24
+ else:
25
+ error_message = str(err)
26
+ messages = re.findall(r'^\S.*(?=error|exception).*', error_message, re.IGNORECASE | re.M)
27
+ if not messages:
28
+ messages = [error_message]
29
+ return '\n'.join(messages)
30
+
31
+
32
+ async def execute(model, func_name, args, kwargs, additional_context=False):
33
+ try:
34
+ uid = kwargs.pop('uid', '')
35
+ password = kwargs.pop('password', '')
36
+ host = kwargs.pop('host', '')
37
+ db = kwargs.pop('db', '')
38
+ url = '{}/xmlrpc/2/object'.format(host)
39
+ # add context for customized behavior
40
+ context = kwargs.pop('context', {})
41
+ if additional_context:
42
+ merged = get_additional_context()
43
+ merged.update(context)
44
+ context = merged
45
+ kwargs.update(dict(context=context))
46
+ loop = asyncio.get_running_loop()
47
+ data = await loop.run_in_executor(
48
+ None, lambda: call_xmlrpc(url, 'execute_kw', db, uid, password, model, func_name, args, kwargs)
49
+ )
50
+ return data
51
+ except Exception as e:
52
+ error_message = sanitize_error_message(e)
53
+ raise APIException(message=error_message)
54
+
55
+
56
+ def sanitize_model(model):
57
+ """Use hyphen character to make RESTful API endpoint"""
58
+ model = model.replace('-', '.')
59
+ if not re.match(r'^[a-z][a-z0-9_.]+$', model):
60
+ raise APIException(message="Invalid model name: %s" % model)
61
+ return model
62
+
@@ -0,0 +1,147 @@
1
+ # -*- coding:utf-8 -*-
2
+
3
+
4
+ import asyncio
5
+ import base64
6
+ import os
7
+ import re
8
+ from datetime import UTC, datetime, timedelta
9
+ from functools import lru_cache
10
+
11
+ from cryptography.hazmat.backends import default_backend
12
+ from cryptography.hazmat.primitives import hashes
13
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
14
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
15
+ from fastapi.security import OAuth2PasswordBearer
16
+ from jose import jwt
17
+
18
+ from odoo_api.core.connection import call_xmlrpc, sanitize_error_message
19
+ from odoo_api.core.config import get_settings
20
+ from odoo_api.exceptions import UnauthorizedException, DecryptionError
21
+
22
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
23
+
24
+
25
+ def create_access_token(user_data):
26
+ settings = get_settings()
27
+ expire_minutes = settings.access_token_expire_minutes
28
+ user_password = user_data.get('password')
29
+ encrypted_user_password = encrypt(user_password, settings.aes_secret_key)
30
+ user_data.update({
31
+ "password": encrypted_user_password
32
+ })
33
+ if expire_minutes:
34
+ expire_datetime = datetime.now(UTC) + timedelta(minutes=expire_minutes)
35
+ user_data.update({
36
+ "exp": expire_datetime
37
+ })
38
+ return jwt.encode(user_data, settings.jwt_secret_key, algorithm=settings.hash_algorithm)
39
+
40
+
41
+ async def authenticate(db, username, password):
42
+ """Authenticate Odoo user with provided API key, using Odoo external API
43
+
44
+ Args:
45
+ db (str): Odoo database name
46
+ username (str): Odoo username
47
+ password (str): Odoo user API key (or user password)
48
+
49
+ Returns:
50
+ str: Access token
51
+ """
52
+ try:
53
+ host = get_settings().odoo_host
54
+ common_url = re.sub(r'(?<!:)/+', '/', f"{host}/xmlrpc/2/common")
55
+ loop = asyncio.get_running_loop()
56
+ uid = await loop.run_in_executor(
57
+ None, lambda: call_xmlrpc(common_url, 'authenticate', db, username, password, {})
58
+ )
59
+ if uid:
60
+ user_data = {
61
+ "host": host,
62
+ "db": db,
63
+ "uid": uid,
64
+ "password": password,
65
+ }
66
+ token = create_access_token(user_data)
67
+ return token
68
+ except Exception as e:
69
+ error_message = sanitize_error_message(e)
70
+ raise UnauthorizedException(message="Could not validate credentials: %s" % error_message)
71
+
72
+
73
+ # ========== Encryption / Decryption ===============
74
+
75
+ @lru_cache(maxsize=256)
76
+ def generate_key(aes_secret_key: str, salt: bytes) -> bytes:
77
+ """Derives a 32-byte key from an aes_secret_key and salt."""
78
+ kdf = PBKDF2HMAC(
79
+ algorithm=hashes.SHA256(),
80
+ length=32,
81
+ salt=salt,
82
+ iterations=100000,
83
+ backend=default_backend()
84
+ )
85
+ return kdf.derive(aes_secret_key.encode())
86
+
87
+
88
+ def encrypt(text: str, aes_secret_key: str) -> str:
89
+ """
90
+ Encrypts a text string using AES-256.
91
+ Works with text of any length, including very short strings.
92
+ """
93
+ # Even empty strings can be encrypted
94
+ text = text or ""
95
+ salt = os.urandom(16) # Generate a random salt
96
+ key = generate_key(aes_secret_key, salt)
97
+ iv = os.urandom(16) # Generate a random IV
98
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
99
+ encryptor = cipher.encryptor()
100
+
101
+ # Pad text to be a multiple of 16 bytes (AES block size) using PKCS#7
102
+ # For a very short text, this ensures it's at least 16 bytes after padding
103
+ padding_length = 16 - (len(text) % 16)
104
+ padded_text = text + chr(padding_length) * padding_length
105
+
106
+ ciphertext = encryptor.update(padded_text.encode()) + encryptor.finalize()
107
+
108
+ # For very short inputs, the final structure is still:
109
+ # 16 bytes (salt) + 16 bytes (IV) + at least 16 bytes (padded ciphertext)
110
+ return base64.b64encode(salt + iv + ciphertext).decode()
111
+
112
+
113
+ def decrypt(encrypted_text: str, aes_secret_key: str) -> str:
114
+ """Decrypts a previously encrypted text string using AES-256."""
115
+ try:
116
+ # Decode the base64 string to raw bytes
117
+ encrypted_data = base64.b64decode(encrypted_text)
118
+
119
+ # Ensure we have enough data (at minimum: salt + iv + 16 bytes of content)
120
+ if len(encrypted_data) < 48:
121
+ raise DecryptionError("Encrypted data is too short")
122
+
123
+ # Extract components
124
+ salt, iv, ciphertext = encrypted_data[:16], encrypted_data[16:32], encrypted_data[32:]
125
+ key = generate_key(aes_secret_key, salt)
126
+
127
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
128
+ decryptor = cipher.decryptor()
129
+ decrypted_padded_text = decryptor.update(ciphertext) + decryptor.finalize()
130
+
131
+ # Validate and remove PKCS#7 padding
132
+ padding_length = decrypted_padded_text[-1]
133
+
134
+ # Check if padding_length is valid
135
+ if padding_length > 16 or padding_length < 1:
136
+ raise DecryptionError("Invalid padding")
137
+
138
+ # Verify the padding is consistent (all padding bytes should be equal to padding_length)
139
+ padding = decrypted_padded_text[-padding_length:]
140
+ if not all(p == padding_length for p in padding):
141
+ raise DecryptionError("Padding validation failed")
142
+
143
+ # Remove padding to get the original text (could be empty string)
144
+ return decrypted_padded_text[:-padding_length].decode('utf-8')
145
+
146
+ except Exception as e:
147
+ raise DecryptionError(f"Decryption failed: {str(e)}")
@@ -0,0 +1,59 @@
1
+ # -*- coding:utf-8 -*-
2
+
3
+ from operator import itemgetter
4
+
5
+ from fastapi import Depends
6
+ from fastapi.security import OAuth2PasswordBearer
7
+ from jose import jwt
8
+ from jose.exceptions import ExpiredSignatureError
9
+ from pydantic import BaseModel, Field
10
+
11
+ from odoo_api.core.connection import sanitize_error_message
12
+ from odoo_api.dependencies.auth import decrypt
13
+ from odoo_api.core.config import get_settings
14
+ from odoo_api.exceptions import UnauthorizedException, DecryptionError
15
+
16
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/login")
17
+
18
+
19
+ def get_logged_in_user(token: str = Depends(oauth2_scheme)):
20
+ try:
21
+ settings = get_settings()
22
+ payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.hash_algorithm])
23
+ resp_db, resp_uid, resp_password = itemgetter("db", "uid", "password")(payload)
24
+ decrypted_password = decrypt(resp_password, settings.aes_secret_key)
25
+ return AuthenticatedUser(**{
26
+ "uid": resp_uid,
27
+ "password": decrypted_password,
28
+ "host": settings.odoo_host,
29
+ "db": resp_db
30
+ })
31
+ except ExpiredSignatureError:
32
+ raise UnauthorizedException(message="Access token has expired.")
33
+ except DecryptionError:
34
+ raise UnauthorizedException(message="Could not validate credentials")
35
+ except Exception as e:
36
+ error_message = sanitize_error_message(e)
37
+ raise UnauthorizedException(message="Could not validate credentials: %s" % error_message)
38
+
39
+
40
+ class Token(BaseModel):
41
+ access_token: str = Field(..., description="JWT access token")
42
+ token_type: str = Field(..., description="Token type (bearer)")
43
+
44
+ model_config = {"json_schema_extra": {"examples": [{"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "bearer"}]}}
45
+
46
+
47
+ class AuthenticationBody(BaseModel):
48
+ db: str = Field(..., description="Odoo database name")
49
+ username: str = Field(..., description="Odoo username")
50
+ password: str = Field(..., description="Odoo password")
51
+
52
+ model_config = {"json_schema_extra": {"examples": [{"db": "mydb", "username": "admin", "password": "admin"}]}}
53
+
54
+
55
+ class AuthenticatedUser(BaseModel):
56
+ uid: int
57
+ password: str
58
+ host: str
59
+ db: str
@@ -0,0 +1,35 @@
1
+ # -*- coding:utf-8 -*-
2
+
3
+ from fastapi import FastAPI, status
4
+ from fastapi.responses import JSONResponse
5
+
6
+
7
+ class APIException(Exception):
8
+ def __init__(self, message: str):
9
+ self.message = message
10
+
11
+
12
+ class UnauthorizedException(Exception):
13
+ def __init__(self, message: str):
14
+ self.message = message
15
+
16
+
17
+ class DecryptionError(Exception):
18
+ def __init__(self, message: str):
19
+ self.message = message
20
+
21
+
22
+ def register_exception_handlers(app: FastAPI):
23
+ @app.exception_handler(APIException)
24
+ async def handle_api_error(request, exc):
25
+ return JSONResponse(
26
+ status_code=status.HTTP_400_BAD_REQUEST,
27
+ content={"message": exc.message, "data": None, "code": 0}
28
+ )
29
+
30
+ @app.exception_handler(UnauthorizedException)
31
+ async def handle_auth_error(request, exc):
32
+ return JSONResponse(
33
+ status_code=status.HTTP_401_UNAUTHORIZED,
34
+ content={"message": exc.message, "data": None, "code": 0}
35
+ )
@@ -0,0 +1,11 @@
1
+ # -*- coding:utf-8 -*-
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from odoo_api.core.config import get_settings
6
+
7
+
8
+ class APIBaseModel(BaseModel):
9
+ @classmethod
10
+ def settings(cls):
11
+ return get_settings()
@@ -0,0 +1,95 @@
1
+ # -*- coding:utf-8 -*-
2
+
3
+ from typing import List, Union, Dict, Optional, Any
4
+
5
+ from pydantic import Field, field_validator
6
+
7
+ from odoo_api.models.base import APIBaseModel
8
+
9
+
10
+ class SearchDataModel(APIBaseModel):
11
+ model_config = {"json_schema_extra": {"examples": [{"domain": [["name", "ilike", "test"]], "fields": ["id", "name", "email"], "offset": 0, "limit": 10, "order": "id desc", "context": {}}]}}
12
+
13
+ domain: List[Union[list, str]] = Field(default_factory=list, description="Odoo domain filter, e.g. [['name', 'ilike', 'test']]")
14
+ fields: List[str] = Field(default_factory=lambda: ['id'], description="List of field names to return")
15
+ offset: int = Field(default=0, description="Number of records to skip")
16
+ limit: int = Field(default=50, description="Maximum number of records to return")
17
+ order: str = Field(default='id', description="Sort order, e.g. 'id desc'")
18
+ context: Dict[str, Any] = Field(default_factory=dict, description="Optional Odoo context")
19
+
20
+ @field_validator('limit')
21
+ @classmethod
22
+ def check_limit(cls, v):
23
+ limit_search_records = cls.settings().limit_search_records
24
+ if v > limit_search_records:
25
+ raise ValueError("The maximum number of records given per request is %s" % limit_search_records)
26
+ return v
27
+
28
+ @field_validator('fields')
29
+ @classmethod
30
+ def check_fields(cls, v):
31
+ return v if v else ['id']
32
+
33
+ @field_validator('domain')
34
+ @classmethod
35
+ def validate_domain(cls, v):
36
+ for item in v:
37
+ if isinstance(item, str):
38
+ if item not in {'&', '|', '!'}:
39
+ raise ValueError(f"Domain string contains invalid characters: {item}")
40
+ else:
41
+ if len(item) != 3:
42
+ raise ValueError(f"Domain item must be a list of 3 elements: {item}")
43
+ return v
44
+
45
+
46
+ class CreateDataModel(APIBaseModel):
47
+ model_config = {"json_schema_extra": {"examples": [{"values": [{"name": "New Partner", "email": "partner@example.com"}], "context": {}}]}}
48
+
49
+ values: List[dict] = Field(..., description="List of record dicts to create")
50
+ context: Dict[str, Any] = Field(default_factory=dict, description="Optional Odoo context")
51
+
52
+ @field_validator('values')
53
+ @classmethod
54
+ def check_values(cls, v):
55
+ limit_create_records = cls.settings().limit_create_records
56
+ if len(v) > limit_create_records:
57
+ raise ValueError("The maximum number of records that can be created is %r" % limit_create_records)
58
+ return v
59
+
60
+
61
+ class UpdateDataModel(APIBaseModel):
62
+ model_config = {"json_schema_extra": {"examples": [{"ids": [1, 2], "values": {"name": "Updated Name"}, "context": {}}]}}
63
+
64
+ ids: List[int] = Field(..., min_length=1, description="List of record IDs to update")
65
+ values: Dict[str, Any] = Field(..., description="Field values to write")
66
+ context: Dict[str, Any] = Field(default_factory=dict, description="Optional Odoo context")
67
+
68
+ @field_validator('ids')
69
+ @classmethod
70
+ def check_ids(cls, v):
71
+ if any(i <= 0 for i in v):
72
+ raise ValueError("All IDs must be positive integers")
73
+ return v
74
+
75
+
76
+ class DeleteDataModel(APIBaseModel):
77
+ model_config = {"json_schema_extra": {"examples": [{"ids": [1, 2], "context": {}}]}}
78
+
79
+ ids: List[int] = Field(..., min_length=1, description="List of record IDs to delete")
80
+ context: Dict[str, Any] = Field(default_factory=dict, description="Optional Odoo context")
81
+
82
+ @field_validator('ids')
83
+ @classmethod
84
+ def check_ids(cls, v):
85
+ if any(i <= 0 for i in v):
86
+ raise ValueError("All IDs must be positive integers")
87
+ return v
88
+
89
+
90
+ class ExecuteFunctionModel(APIBaseModel):
91
+ model_config = {"json_schema_extra": {"examples": [{"args": [[1, 2]], "kwargs": {"force": True}, "context": {}}]}}
92
+
93
+ args: List[Any] = Field(default_factory=list, description="Positional arguments for the function")
94
+ kwargs: Dict[str, Any] = Field(default_factory=dict, description="Keyword arguments for the function")
95
+ context: Dict[str, Any] = Field(default_factory=dict, description="Optional Odoo context")
@@ -0,0 +1,7 @@
1
+ from odoo_api.routers.auth import router as auth_router
2
+ from odoo_api.routers.health import router as health_router
3
+ from odoo_api.routers.models import router as models_router
4
+
5
+
6
+ def get_routers():
7
+ return [health_router, auth_router, models_router]
@@ -0,0 +1,17 @@
1
+ # -*- coding:utf-8 -*-
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from odoo_api.dependencies.auth import authenticate
6
+ from odoo_api.dependencies.odoo_session import Token, AuthenticationBody
7
+ from odoo_api.exceptions import UnauthorizedException
8
+
9
+ router = APIRouter(tags=["Authentication"])
10
+
11
+
12
+ @router.post('/api/login', response_model=Token, summary="Login", description="Authenticate with Odoo credentials and receive a JWT access token.")
13
+ async def login(info: AuthenticationBody):
14
+ token = await authenticate(info.db, info.username, info.password)
15
+ if not token:
16
+ raise UnauthorizedException(message="Incorrect login information")
17
+ return {"access_token": token, "token_type": "bearer"}
@@ -0,0 +1,8 @@
1
+ from fastapi import APIRouter
2
+
3
+ router = APIRouter(tags=["Health"])
4
+
5
+
6
+ @router.get("/api/health", summary="Health check", description="Returns the health status of the API server.")
7
+ async def health():
8
+ return {"status": "ok"}
@@ -0,0 +1,103 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from fastapi import APIRouter, Depends, Query
4
+
5
+ from odoo_api.core.connection import execute, sanitize_model
6
+ from odoo_api.dependencies.odoo_session import AuthenticatedUser, get_logged_in_user
7
+ from odoo_api.models.request import (
8
+ SearchDataModel, CreateDataModel, UpdateDataModel,
9
+ DeleteDataModel, ExecuteFunctionModel,
10
+ )
11
+
12
+ router = APIRouter(tags=["Models"])
13
+
14
+
15
+ @router.get('/api/model/{model}/{record_id}', summary="Read record", description="Read a single record by ID from an Odoo model.")
16
+ async def retrieve(model: str,
17
+ record_id: int,
18
+ fields: str = Query(default='id'),
19
+ user: AuthenticatedUser = Depends(get_logged_in_user)):
20
+ model = sanitize_model(model)
21
+ kwargs = dict(uid=user.uid, password=user.password, host=user.host, db=user.db, fields=fields.split(','))
22
+ args = [[record_id]]
23
+ res = await execute(model, "read", args, kwargs, additional_context=True)
24
+ return {
25
+ "data": res
26
+ }
27
+
28
+
29
+ @router.post('/api/models/search/{model}', summary="Search records", description="Search and list records from an Odoo model with filtering, pagination, and field selection.")
30
+ async def search(model: str,
31
+ request_data: SearchDataModel = None,
32
+ user: AuthenticatedUser = Depends(get_logged_in_user)):
33
+ model = sanitize_model(model)
34
+ args = []
35
+ if not request_data:
36
+ request_data = SearchDataModel()
37
+ kwargs = dict(uid=user.uid, password=user.password, host=user.host, db=user.db, context=request_data.context)
38
+
39
+ param_keys = ['domain', 'fields', 'offset', 'limit', 'order']
40
+ domain, fields, offset, limit, order = map(lambda k: getattr(request_data, k, None), param_keys)
41
+ kwargs.update(dict(domain=domain, fields=fields, offset=offset, limit=limit, order=order))
42
+ res = await execute(model, 'search_read', args, kwargs, additional_context=True)
43
+ return {
44
+ "data": res
45
+ }
46
+
47
+
48
+ @router.post('/api/models/{model}', summary="Create records", description="Create one or more records in an Odoo model.")
49
+ async def create(request_data: CreateDataModel,
50
+ model: str,
51
+ user: AuthenticatedUser = Depends(get_logged_in_user)):
52
+ model = sanitize_model(model)
53
+ values = request_data.values
54
+ args = [values]
55
+ kwargs = dict(uid=user.uid, password=user.password, host=user.host, db=user.db, context=request_data.context)
56
+ res = await execute(model, 'create', args, kwargs)
57
+ return {
58
+ "data": res
59
+ }
60
+
61
+
62
+ @router.put('/api/models/{model}', summary="Update records", description="Update one or more records in an Odoo model by IDs.")
63
+ async def update(request_data: UpdateDataModel,
64
+ model: str,
65
+ user: AuthenticatedUser = Depends(get_logged_in_user)):
66
+ model = sanitize_model(model)
67
+ ids = request_data.ids
68
+ values = request_data.values
69
+ args = [ids, values]
70
+ kwargs = dict(uid=user.uid, password=user.password, host=user.host, db=user.db, context=request_data.context)
71
+ res = await execute(model, 'write', args, kwargs)
72
+ return {
73
+ "data": res
74
+ }
75
+
76
+
77
+ @router.delete('/api/models/{model}', summary="Delete records", description="Delete one or more records from an Odoo model by IDs.")
78
+ async def delete(request_data: DeleteDataModel,
79
+ model: str,
80
+ user: AuthenticatedUser = Depends(get_logged_in_user)):
81
+ model = sanitize_model(model)
82
+ ids = request_data.ids
83
+ args = [ids]
84
+ kwargs = dict(uid=user.uid, password=user.password, host=user.host, db=user.db, context=request_data.context)
85
+ res = await execute(model, 'unlink', args, kwargs)
86
+ return {
87
+ "data": res
88
+ }
89
+
90
+
91
+ @router.post('/api/models/{model}/execute/{func_name}', summary="Execute function", description="Execute a custom function on an Odoo model.")
92
+ async def execute_func(request_data: ExecuteFunctionModel,
93
+ model: str,
94
+ func_name: str,
95
+ user: AuthenticatedUser = Depends(get_logged_in_user)):
96
+ model = sanitize_model(model)
97
+ args = request_data.args
98
+ kwargs = request_data.kwargs
99
+ kwargs.update(dict(uid=user.uid, password=user.password, host=user.host, db=user.db, context=request_data.context))
100
+ res = await execute(model, func_name, args, kwargs)
101
+ return {
102
+ "data": res
103
+ }
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: odoo-fastapi-gateway
3
+ Version: 18.1.0
4
+ Summary: FastAPI gateway library for Odoo XML-RPC integration
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: fastapi>=0.100.0
7
+ Requires-Dist: uvicorn>=0.20.0
8
+ Requires-Dist: python-jose[cryptography]>=3.3.0
9
+ Requires-Dist: python-dotenv>=1.0.0
10
+ Requires-Dist: cryptography>=41.0.0
11
+ Requires-Dist: pydantic-settings>=2.0.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == "dev"
14
+ Requires-Dist: httpx>=0.24.0; extra == "dev"
@@ -0,0 +1,23 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/odoo_api/__init__.py
4
+ src/odoo_api/app.py
5
+ src/odoo_api/exceptions.py
6
+ src/odoo_api/core/__init__.py
7
+ src/odoo_api/core/config.py
8
+ src/odoo_api/core/connection.py
9
+ src/odoo_api/dependencies/__init__.py
10
+ src/odoo_api/dependencies/auth.py
11
+ src/odoo_api/dependencies/odoo_session.py
12
+ src/odoo_api/models/__init__.py
13
+ src/odoo_api/models/base.py
14
+ src/odoo_api/models/request.py
15
+ src/odoo_api/routers/__init__.py
16
+ src/odoo_api/routers/auth.py
17
+ src/odoo_api/routers/health.py
18
+ src/odoo_api/routers/models.py
19
+ src/odoo_fastapi_gateway.egg-info/PKG-INFO
20
+ src/odoo_fastapi_gateway.egg-info/SOURCES.txt
21
+ src/odoo_fastapi_gateway.egg-info/dependency_links.txt
22
+ src/odoo_fastapi_gateway.egg-info/requires.txt
23
+ src/odoo_fastapi_gateway.egg-info/top_level.txt
@@ -0,0 +1,10 @@
1
+ fastapi>=0.100.0
2
+ uvicorn>=0.20.0
3
+ python-jose[cryptography]>=3.3.0
4
+ python-dotenv>=1.0.0
5
+ cryptography>=41.0.0
6
+ pydantic-settings>=2.0.0
7
+
8
+ [dev]
9
+ pytest>=7.0
10
+ httpx>=0.24.0