A decorator-based REST API framework for Odoo. Create clean, standardized REST endpoints inside your Odoo modules with a FastAPI-like developer experience.
- Decorator-based routing:
@api.get(),@api.post(),@api.put(),@api.patch(),@api.delete() - Standardized JSON responses: Consistent
{success, data, error}format - Automatic recordset serialization: Return
env['res.partner'].search()directly, recordsets are auto-converted to dicts - Automatic request parsing: JSON body, query params, and path params injected via signature inspection
- Pydantic validation: Optional
input_model/output_modelfor request validation and response serialization (supports Pydantic v1 & v2) - Auto-generated API docs: Swagger UI at
/docsand OpenAPI 3.0 spec at/openapi.json - Route overriding with priority: Override routes across addons with
priority=10(higher wins) - Error handling: Exception classes map to proper HTTP status codes
- Configurable error format:
simple_error=Truereturns error as a plain string instead of an object - Pluggable authentication: Bring your own auth logic
- Multi-file support: Share one API instance across partner.py, order.py, etc.
- Odoo 16+ compatible
pip install odoo-rest-apiWith Pydantic validation support:
pip install odoo-rest-api[pydantic]No Odoo module dependency needed, just a pip package.
# my_addon/controllers/partner_api.py
from odoo_rest_api import OdooRestAPI, NotFound, BadRequest
api = OdooRestAPI(prefix='/api/v1')
@api.get('/partners')
def list_partners(env, **params):
limit = min(int(params.get('limit', 80)), 1000)
offset = int(params.get('offset', 0))
return env['res.partner'].search_read(
[], ['name', 'email', 'phone'], limit=limit, offset=offset
)
@api.get('/partners/{id}')
def get_partner(env, id):
partner = env['res.partner'].browse(int(id))
if not partner.exists():
raise NotFound('Partner not found')
return partner.read(['name', 'email', 'phone'])[0]
@api.post('/partners')
def create_partner(env, body):
if not body or not body.get('name'):
raise BadRequest("'name' is required")
partner = env['res.partner'].create(body)
return partner.read(['name', 'email', 'phone'])[0]
@api.put('/partners/{id}')
def update_partner(env, id, body):
partner = env['res.partner'].browse(int(id))
if not partner.exists():
raise NotFound('Partner not found')
partner.write(body)
return partner.read(['name', 'email', 'phone'])[0]
@api.delete('/partners/{id}')
def delete_partner(env, id):
partner = env['res.partner'].browse(int(id))
if not partner.exists():
raise NotFound('Partner not found')
partner.unlink()
return {'deleted': True}
api.register()# my_addon/controllers/__init__.py
from . import partner_apiShare one API instance across multiple files:
# controllers/app.py: shared instance
from odoo_rest_api import OdooRestAPI
api = OdooRestAPI(prefix='/api/v1')
# controllers/partner.py
from .app import api
from odoo_rest_api import NotFound
@api.get('/partners')
def list_partners(env, **params): ...
@api.get('/partners/{id}')
def get_partner(env, id): ...
# controllers/order.py
from .app import api
from odoo_rest_api import NotFound
@api.get('/orders')
def list_orders(env, **params): ...
@api.get('/orders/{id}')
def get_order(env, id): ...
# controllers/__init__.py: import routes then register
from . import partner
from . import order
from .app import api
api.register()Test:
curl http://localhost:8069/api/v1/partners
curl http://localhost:8069/api/v1/partners/1
curl http://localhost:8069/api/v1/orders
curl -X POST -H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john@example.com"}' \
http://localhost:8069/api/v1/partners{
"success": true,
"data": [{"id": 1, "name": "Alice", "email": "alice@example.com"}],
"error": null
}{
"success": false,
"data": null,
"error": {
"type": "NotFound",
"message": "Partner not found"
}
}By default, routes have no authentication (auth="none"). You add auth by providing your own handler, a function that takes request and returns a user_id.
from odoo import SUPERUSER_ID, api as odoo_api
from odoo_rest_api import OdooRestAPI, Unauthorized
def my_auth(request):
api_key = request.httprequest.headers.get('X-API-Key')
if not api_key:
raise Unauthorized('Missing X-API-Key header')
env = odoo_api.Environment(request.env.cr, SUPERUSER_ID, {})
expected = env['ir.config_parameter'].sudo().get_param('my_api.secret_key')
if api_key != expected:
raise Unauthorized('Invalid API key')
return SUPERUSER_ID # or a specific user_id
api = OdooRestAPI(prefix='/api/v1', auth_handler=my_auth)from odoo_rest_api import register_auth_handler
register_auth_handler('my_key', my_auth)
api = OdooRestAPI(prefix='/api/v1', auth='my_key')from odoo import SUPERUSER_ID, api as odoo_api
from odoo_rest_api import OdooRestAPI, Unauthorized
def odoo_apikey_auth(request):
api_key = request.httprequest.headers.get('X-API-Key')
if not api_key:
raise Unauthorized('Missing X-API-Key header')
try:
env = odoo_api.Environment(request.env.cr, SUPERUSER_ID, {})
uid = env['res.users']._api_key_authenticate(api_key)
except Exception:
raise Unauthorized('Invalid API key')
return uid
api = OdooRestAPI(prefix='/api/v1', auth_handler=odoo_apikey_auth)See examples/ for a complete working addon with auth and multi-file routing.
Handler arguments are injected based on parameter names:
| Parameter | Value |
|---|---|
env |
Odoo Environment (authenticated if auth handler provided) |
body |
Parsed JSON request body (POST/PUT/PATCH) |
{name} matching path param |
Path parameter value (e.g. id from /partners/{id}) |
**params or **kwargs |
Remaining query string parameters |
| Named param matching query key | Individual query parameter |
| Exception | Status Code |
|---|---|
BadRequest |
400 |
Unauthorized |
401 |
Forbidden |
403 |
NotFound |
404 |
MethodNotAllowed |
405 |
Conflict |
409 |
ValidationError |
422 |
RateLimitExceeded |
429 |
Return recordsets directly, and they are auto-converted to dicts via .read():
@api.get('/partners')
def list_partners(env):
return env['res.partner'].search([]) # Auto-serialized to list of dictsValidate request bodies and serialize responses with Pydantic models (optional, install pydantic separately):
from pydantic import BaseModel
from odoo_rest_api import OdooRestAPI
api = OdooRestAPI(prefix='/api/v1')
class PartnerCreate(BaseModel):
name: str
email: str = None
phone: str = None
class PartnerOut(BaseModel):
id: int
name: str
email: str = None
@api.post('/partners', input_model=PartnerCreate, output_model=PartnerOut)
def create_partner(env, body):
partner = env['res.partner'].create(body)
return partner.read(['id', 'name', 'email'])[0]input_model: Validates request body. Returns 422 with field-level error details on failure.output_model: Serializes response data through the model, stripping extra fields. Fails silently (returns raw data) if output doesn't match.
Models also enrich the auto-generated OpenAPI spec with proper schemas.
Swagger UI is auto-generated at {prefix}/docs:
api = OdooRestAPI(
prefix='/api/v1',
title='My API',
version='1.0.0',
description='Partner management API',
)GET /api/v1/docs: Interactive Swagger UIGET /api/v1/openapi.json: OpenAPI 3.0 spec
Disable with docs=False.
Override routes from other addons by decorating the same method+path. The last decorator wins:
# Base addon
@api.get('/partners')
def list_partners(env): ...
# Custom addon: overrides the above
@api.get('/partners')
def list_partners_custom(env): ...Use priority for controlled overriding across addons:
# Base addon (default priority=0)
@api.get('/partners')
def list_partners(env): ...
# Custom addon (priority=10 wins over priority=0)
@api.get('/partners', priority=10)
def list_partners_custom(env): ...Lower priority routes are silently ignored.
By default, errors include type and message as an object:
{"success": false, "data": null, "error": {"type": "NotFound", "message": "Partner not found"}}Set simple_error=True to return error as a plain string:
api = OdooRestAPI(prefix='/api/v1', simple_error=True){"success": false, "data": null, "error": "Partner not found"}LGPL-3.0