├── requirements.txt ├── .github └── FUNDING.yml ├── demo └── demo.xml ├── views ├── views.xml └── templates.xml ├── models ├── __init__.py └── models.py ├── controllers ├── __init__.py ├── exceptions.py ├── parser.py ├── serializers.py └── controllers.py ├── __init__.py ├── security └── ir.model.access.csv ├── __manifest__.py ├── LICENSE ├── .gitignore └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | pypeg2 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: yezyilomo 2 | -------------------------------------------------------------------------------- /demo/demo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /views/views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import models -------------------------------------------------------------------------------- /views/templates.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /controllers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import controllers -------------------------------------------------------------------------------- /models/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from odoo import models, fields, api 4 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import controllers 4 | from . import models -------------------------------------------------------------------------------- /controllers/exceptions.py: -------------------------------------------------------------------------------- 1 | class QueryFormatError(Exception): 2 | """Invalid Query Format.""" -------------------------------------------------------------------------------- /security/ir.model.access.csv: -------------------------------------------------------------------------------- 1 | id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -------------------------------------------------------------------------------- /__manifest__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | { 3 | 'name': "Odoo REST API", 4 | 5 | 'summary': """ 6 | Odoo REST API""", 7 | 8 | 'description': """ 9 | Odoo REST API 10 | """, 11 | 12 | 'author': "Yezileli Ilomo", 13 | 'website': "https://github.com/yezyilomo/odoo-rest-api", 14 | 15 | # Categories can be used to filter modules in modules listing 16 | # Check https://github.com/odoo/odoo/blob/12.0/odoo/addons/base/data/ir_module_category_data.xml 17 | # for the full list 18 | 'category': 'developers', 19 | 'version': '0.1', 20 | 21 | # any module necessary for this one to work correctly 22 | 'depends': ['base'], 23 | 24 | # always loaded 25 | 'data': [ 26 | # 'security/ir.model.access.csv', 27 | 'views/views.xml', 28 | 'views/templates.xml', 29 | ], 30 | # only loaded in demonstration mode 31 | 'demo': [ 32 | 'demo/demo.xml', 33 | ], 34 | 35 | "application": True, 36 | "installable": True, 37 | "auto_install": False, 38 | 39 | 'external_dependencies': { 40 | 'python': ['pypeg2'] 41 | } 42 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yezy Ilomo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=python,visualstudiocode 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # celery beat schedule file 90 | celerybeat-schedule 91 | 92 | # SageMath parsed files 93 | *.sage.py 94 | 95 | # Environments 96 | .env 97 | .venv 98 | env/ 99 | venv/ 100 | ENV/ 101 | env.bak/ 102 | venv.bak/ 103 | 104 | # Spyder project settings 105 | .spyderproject 106 | .spyproject 107 | 108 | # Rope project settings 109 | .ropeproject 110 | 111 | # mkdocs documentation 112 | /site 113 | 114 | # mypy 115 | .mypy_cache/ 116 | .dmypy.json 117 | dmypy.json 118 | 119 | # Pyre type checker 120 | .pyre/ 121 | 122 | ### Python Patch ### 123 | .venv/ 124 | 125 | ### VisualStudioCode ### 126 | XDG_CACHE_HOME 127 | .vscode/ 128 | !.vscode/settings.json 129 | !.vscode/tasks.json 130 | !.vscode/launch.json 131 | !.vscode/extensions.json 132 | 133 | ### VisualStudioCode Patch ### 134 | # Ignore all local history of files 135 | .history 136 | 137 | # End of https://www.gitignore.io/api/python,visualstudiocode 138 | 139 | -------------------------------------------------------------------------------- /controllers/parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from pypeg2 import List, contiguous, csl, name, optional, parse 4 | 5 | from .exceptions import QueryFormatError 6 | 7 | 8 | class IncludedField(List): 9 | grammar = name() 10 | 11 | 12 | class ExcludedField(List): 13 | grammar = contiguous('-', name()) 14 | 15 | 16 | class AllFields(str): 17 | grammar = '*' 18 | 19 | 20 | class BaseArgument(List): 21 | @property 22 | def value(self): 23 | return self[0] 24 | 25 | 26 | class ArgumentWithoutQuotes(BaseArgument): 27 | grammar = name(), ':', re.compile(r'[^,:"\'\)]+') 28 | 29 | 30 | class ArgumentWithSingleQuotes(BaseArgument): 31 | grammar = name(), ':', "'", re.compile(r'[^\']+'), "'" 32 | 33 | 34 | class ArgumentWithDoubleQuotes(BaseArgument): 35 | grammar = name(), ':', '"', re.compile(r'[^"]+'), '"' 36 | 37 | 38 | class Arguments(List): 39 | grammar = optional(csl( 40 | [ 41 | ArgumentWithoutQuotes, 42 | ArgumentWithSingleQuotes, 43 | ArgumentWithDoubleQuotes 44 | ], 45 | separator=',' 46 | )) 47 | 48 | 49 | class ArgumentsBlock(List): 50 | grammar = optional('(', Arguments, ')') 51 | 52 | @property 53 | def arguments(self): 54 | if self[0] is None: 55 | return [] # No arguments 56 | return self[0] 57 | 58 | 59 | class ParentField(List): 60 | """ 61 | According to ParentField grammar: 62 | self[0] returns IncludedField instance, 63 | self[1] returns Block instance 64 | """ 65 | @property 66 | def name(self): 67 | return self[0].name 68 | 69 | @property 70 | def block(self): 71 | return self[1] 72 | 73 | 74 | class BlockBody(List): 75 | grammar = optional(csl( 76 | [ParentField, IncludedField, ExcludedField, AllFields], 77 | separator=',' 78 | )) 79 | 80 | 81 | class Block(List): 82 | grammar = ArgumentsBlock, '{', BlockBody, '}' 83 | 84 | @property 85 | def arguments(self): 86 | return self[0].arguments 87 | 88 | @property 89 | def body(self): 90 | return self[1] 91 | 92 | 93 | # ParentField grammar, 94 | # We don't include `ExcludedField` here because 95 | # exclude operator(-) on a parent field should 96 | # raise syntax error, e.g {name, -location{city}} 97 | # `IncludedField` is a parent field and `Block` 98 | # contains its sub fields 99 | ParentField.grammar = IncludedField, Block 100 | 101 | 102 | class Parser(object): 103 | def __init__(self, query): 104 | self._query = query 105 | 106 | def get_parsed(self): 107 | parse_tree = parse(self._query, Block) 108 | return self._transform_block(parse_tree) 109 | 110 | def _transform_block(self, block): 111 | fields = { 112 | "include": [], 113 | "exclude": [], 114 | "arguments": {} 115 | } 116 | 117 | for argument in block.arguments: 118 | argument = {str(argument.name): argument.value} 119 | fields['arguments'].update(argument) 120 | 121 | for field in block.body: 122 | # A field may be a parent or included field or excluded field 123 | field = self._transform_field(field) 124 | 125 | if isinstance(field, dict): 126 | # A field is a parent 127 | fields["include"].append(field) 128 | elif isinstance(field, IncludedField): 129 | fields["include"].append(str(field.name)) 130 | elif isinstance(field, ExcludedField): 131 | fields["exclude"].append(str(field.name)) 132 | elif isinstance(field, AllFields): 133 | # include all fields 134 | fields["include"].append("*") 135 | 136 | if fields["exclude"]: 137 | # fields['include'] should contain only nested fields 138 | 139 | # We should add `*` operator in fields['include'] 140 | add_include_all_operator = True 141 | for field in fields["include"]: 142 | if field == "*": 143 | # `*` operator is alredy in fields['include'] 144 | add_include_all_operator = False 145 | continue 146 | 147 | if isinstance(field, str): 148 | # Including and excluding fields on the same field level 149 | msg = ( 150 | "Can not include and exclude fields on the same " 151 | "field level" 152 | ) 153 | raise QueryFormatError(msg) 154 | 155 | if add_include_all_operator: 156 | # Make sure we include * operator 157 | fields["include"].append("*") 158 | return fields 159 | 160 | def _transform_field(self, field): 161 | # A field may be a parent or included field or excluded field 162 | if isinstance(field, ParentField): 163 | return self._transform_parent_field(field) 164 | elif isinstance(field, (IncludedField, ExcludedField, AllFields)): 165 | return field 166 | 167 | def _transform_parent_field(self, parent_field): 168 | parent_field_name = str(parent_field.name) 169 | parent_field_value = self._transform_block(parent_field.block) 170 | return {parent_field_name: parent_field_value} 171 | -------------------------------------------------------------------------------- /controllers/serializers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from itertools import chain 4 | 5 | from .parser import Parser 6 | from .exceptions import QueryFormatError 7 | 8 | 9 | class Serializer(object): 10 | def __init__(self, record, query="{*}", many=False): 11 | self.many = many 12 | self._record = record 13 | self._raw_query = query 14 | super().__init__() 15 | 16 | def get_parsed_restql_query(self): 17 | parser = Parser(self._raw_query) 18 | try: 19 | parsed_restql_query = parser.get_parsed() 20 | return parsed_restql_query 21 | except SyntaxError as e: 22 | msg = "QuerySyntaxError: " + e.msg + " on " + e.text 23 | raise SyntaxError(msg) from None 24 | except QueryFormatError as e: 25 | msg = "QueryFormatError: " + str(e) 26 | raise QueryFormatError(msg) from None 27 | 28 | @property 29 | def data(self): 30 | parsed_restql_query = self.get_parsed_restql_query() 31 | if self.many: 32 | return [ 33 | self.serialize(rec, parsed_restql_query) 34 | for rec 35 | in self._record 36 | ] 37 | return self.serialize(self._record, parsed_restql_query) 38 | 39 | @classmethod 40 | def build_flat_field(cls, rec, field_name): 41 | all_fields = rec.fields_get() 42 | if field_name not in all_fields: 43 | msg = "'%s' field is not found" % field_name 44 | raise LookupError(msg) 45 | field_type = rec.fields_get(field_name).get(field_name).get('type') 46 | if field_type in ['one2many', 'many2many']: 47 | return { 48 | field_name: [record.id for record in rec[field_name]] 49 | } 50 | elif field_type in ['many2one']: 51 | return {field_name: rec[field_name].id} 52 | elif field_type == 'datetime' and rec[field_name]: 53 | return { 54 | field_name: rec[field_name].strftime("%Y-%m-%d-%H-%M") 55 | } 56 | elif field_type == 'date' and rec[field_name]: 57 | return { 58 | field_name: rec[field_name].strftime("%Y-%m-%d") 59 | } 60 | elif field_type == 'time' and rec[field_name]: 61 | return { 62 | field_name: rec[field_name].strftime("%H-%M-%S") 63 | } 64 | elif field_type == "binary" and isinstance(rec[field_name], bytes) and rec[field_name]: 65 | return {field_name: rec[field_name].decode("utf-8")} 66 | else: 67 | return {field_name: rec[field_name]} 68 | 69 | @classmethod 70 | def build_nested_field(cls, rec, field_name, nested_parsed_query): 71 | all_fields = rec.fields_get() 72 | if field_name not in all_fields: 73 | msg = "'%s' field is not found" % field_name 74 | raise LookupError(msg) 75 | field_type = rec.fields_get(field_name).get(field_name).get('type') 76 | if field_type in ['one2many', 'many2many']: 77 | return { 78 | field_name: [ 79 | cls.serialize(record, nested_parsed_query) 80 | for record 81 | in rec[field_name] 82 | ] 83 | } 84 | elif field_type in ['many2one']: 85 | return { 86 | field_name: cls.serialize(rec[field_name], nested_parsed_query) 87 | } 88 | else: 89 | # Not a neste field 90 | msg = "'%s' is not a nested field" % field_name 91 | raise ValueError(msg) 92 | 93 | @classmethod 94 | def serialize(cls, rec, parsed_query): 95 | data = {} 96 | 97 | # NOTE: self.parsed_restql_query["include"] not being empty 98 | # is not a guarantee that the exclude operator(-) has not been 99 | # used because the same self.parsed_restql_query["include"] 100 | # is used to store nested fields when the exclude operator(-) is used 101 | if parsed_query["exclude"]: 102 | # Exclude fields from a query 103 | all_fields = rec.fields_get() 104 | for field in parsed_query["include"]: 105 | if field == "*": 106 | continue 107 | for nested_field, nested_parsed_query in field.items(): 108 | built_nested_field = cls.build_nested_field( 109 | rec, 110 | nested_field, 111 | nested_parsed_query 112 | ) 113 | data.update(built_nested_field) 114 | 115 | flat_fields= set(all_fields).symmetric_difference(set(parsed_query['exclude'])) 116 | for field in flat_fields: 117 | flat_field = cls.build_flat_field(rec, field) 118 | data.update(flat_field) 119 | 120 | elif parsed_query["include"]: 121 | # Here we are sure that self.parsed_restql_query["exclude"] 122 | # is empty which means the exclude operator(-) is not used, 123 | # so self.parsed_restql_query["include"] contains only fields 124 | # to include 125 | all_fields = rec.fields_get() 126 | if "*" in parsed_query['include']: 127 | # Include all fields 128 | parsed_query['include'] = filter( 129 | lambda item: item != "*", 130 | parsed_query['include'] 131 | ) 132 | fields = chain(parsed_query['include'], all_fields) 133 | parsed_query['include'] = list(fields) 134 | 135 | for field in parsed_query["include"]: 136 | if isinstance(field, dict): 137 | for nested_field, nested_parsed_query in field.items(): 138 | built_nested_field = cls.build_nested_field( 139 | rec, 140 | nested_field, 141 | nested_parsed_query 142 | ) 143 | data.update(built_nested_field) 144 | else: 145 | flat_field = cls.build_flat_field(rec, field) 146 | data.update(flat_field) 147 | else: 148 | # The query is empty i.e query={} 149 | # return nothing 150 | return {} 151 | return data -------------------------------------------------------------------------------- /controllers/controllers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import math 4 | import logging 5 | import requests 6 | 7 | from odoo import http, _, exceptions 8 | from odoo.http import request 9 | 10 | from .serializers import Serializer 11 | from .exceptions import QueryFormatError 12 | 13 | 14 | _logger = logging.getLogger(__name__) 15 | 16 | 17 | def error_response(error, msg): 18 | return { 19 | "jsonrpc": "2.0", 20 | "id": None, 21 | "error": { 22 | "code": 200, 23 | "message": msg, 24 | "data": { 25 | "name": str(error), 26 | "debug": "", 27 | "message": msg, 28 | "arguments": list(error.args), 29 | "exception_type": type(error).__name__ 30 | } 31 | } 32 | } 33 | 34 | 35 | class OdooAPI(http.Controller): 36 | @http.route( 37 | '/auth/', 38 | type='json', auth='none', methods=["POST"], csrf=False) 39 | def authenticate(self, *args, **post): 40 | try: 41 | login = post["login"] 42 | except KeyError: 43 | raise exceptions.AccessDenied(message='`login` is required.') 44 | 45 | try: 46 | password = post["password"] 47 | except KeyError: 48 | raise exceptions.AccessDenied(message='`password` is required.') 49 | 50 | try: 51 | db = post["db"] 52 | except KeyError: 53 | raise exceptions.AccessDenied(message='`db` is required.') 54 | 55 | http.request.session.authenticate(db, login, password) 56 | res = request.env['ir.http'].session_info() 57 | return res 58 | 59 | @http.route( 60 | '/object//', 61 | type='json', auth='user', methods=["POST"], csrf=False) 62 | def call_model_function(self, model, function, **post): 63 | args = [] 64 | kwargs = {} 65 | if "args" in post: 66 | args = post["args"] 67 | if "kwargs" in post: 68 | kwargs = post["kwargs"] 69 | model = request.env[model] 70 | result = getattr(model, function)(*args, **kwargs) 71 | return result 72 | 73 | @http.route( 74 | '/object///', 75 | type='json', auth='user', methods=["POST"], csrf=False) 76 | def call_obj_function(self, model, rec_id, function, **post): 77 | args = [] 78 | kwargs = {} 79 | if "args" in post: 80 | args = post["args"] 81 | if "kwargs" in post: 82 | kwargs = post["kwargs"] 83 | obj = request.env[model].browse(rec_id).ensure_one() 84 | result = getattr(obj, function)(*args, **kwargs) 85 | return result 86 | 87 | @http.route( 88 | '/api/', 89 | type='http', auth='user', methods=['GET'], csrf=False) 90 | def get_model_data(self, model, **params): 91 | try: 92 | records = request.env[model].search([]) 93 | except KeyError as e: 94 | msg = "The model `%s` does not exist." % model 95 | res = error_response(e, msg) 96 | return http.Response( 97 | json.dumps(res), 98 | status=200, 99 | mimetype='application/json' 100 | ) 101 | 102 | if "query" in params: 103 | query = params["query"] 104 | else: 105 | query = "{*}" 106 | 107 | if "order" in params: 108 | orders = json.loads(params["order"]) 109 | else: 110 | orders = "" 111 | 112 | if "filter" in params: 113 | filters = json.loads(params["filter"]) 114 | records = request.env[model].search(filters, order=orders) 115 | 116 | prev_page = None 117 | next_page = None 118 | total_page_number = 1 119 | current_page = 1 120 | 121 | if "page_size" in params: 122 | page_size = int(params["page_size"]) 123 | count = len(records) 124 | total_page_number = math.ceil(count/page_size) 125 | 126 | if "page" in params: 127 | current_page = int(params["page"]) 128 | else: 129 | current_page = 1 # Default page Number 130 | start = page_size*(current_page-1) 131 | stop = current_page*page_size 132 | records = records[start:stop] 133 | next_page = current_page+1 \ 134 | if 0 < current_page + 1 <= total_page_number \ 135 | else None 136 | prev_page = current_page-1 \ 137 | if 0 < current_page - 1 <= total_page_number \ 138 | else None 139 | 140 | if "limit" in params: 141 | limit = int(params["limit"]) 142 | records = records[0:limit] 143 | 144 | try: 145 | serializer = Serializer(records, query, many=True) 146 | data = serializer.data 147 | except (SyntaxError, QueryFormatError) as e: 148 | res = error_response(e, e.msg) 149 | return http.Response( 150 | json.dumps(res), 151 | status=200, 152 | mimetype='application/json' 153 | ) 154 | 155 | res = { 156 | "count": len(records), 157 | "prev": prev_page, 158 | "current": current_page, 159 | "next": next_page, 160 | "total_pages": total_page_number, 161 | "result": data 162 | } 163 | return http.Response( 164 | json.dumps(res), 165 | status=200, 166 | mimetype='application/json' 167 | ) 168 | 169 | @http.route( 170 | '/api//', 171 | type='http', auth='user', methods=['GET'], csrf=False) 172 | def get_model_rec(self, model, rec_id, **params): 173 | try: 174 | records = request.env[model].search([]) 175 | except KeyError as e: 176 | msg = "The model `%s` does not exist." % model 177 | res = error_response(e, msg) 178 | return http.Response( 179 | json.dumps(res), 180 | status=200, 181 | mimetype='application/json' 182 | ) 183 | 184 | if "query" in params: 185 | query = params["query"] 186 | else: 187 | query = "{*}" 188 | 189 | # TODO: Handle the error raised by `ensure_one` 190 | record = records.browse(rec_id).ensure_one() 191 | 192 | try: 193 | serializer = Serializer(record, query) 194 | data = serializer.data 195 | except (SyntaxError, QueryFormatError) as e: 196 | res = error_response(e, e.msg) 197 | return http.Response( 198 | json.dumps(res), 199 | status=200, 200 | mimetype='application/json' 201 | ) 202 | 203 | return http.Response( 204 | json.dumps(data), 205 | status=200, 206 | mimetype='application/json' 207 | ) 208 | 209 | @http.route( 210 | '/api//', 211 | type='json', auth="user", methods=['POST'], csrf=False) 212 | def post_model_data(self, model, **post): 213 | try: 214 | data = post['data'] 215 | except KeyError: 216 | msg = "`data` parameter is not found on POST request body" 217 | raise exceptions.ValidationError(msg) 218 | 219 | try: 220 | model_to_post = request.env[model] 221 | except KeyError: 222 | msg = "The model `%s` does not exist." % model 223 | raise exceptions.ValidationError(msg) 224 | 225 | # TODO: Handle data validation 226 | 227 | if "context" in post: 228 | context = post["context"] 229 | record = model_to_post.with_context(**context).create(data) 230 | else: 231 | record = model_to_post.create(data) 232 | return record.id 233 | 234 | # This is for single record update 235 | @http.route( 236 | '/api///', 237 | type='json', auth="user", methods=['PUT'], csrf=False) 238 | def put_model_record(self, model, rec_id, **post): 239 | try: 240 | data = post['data'] 241 | except KeyError: 242 | msg = "`data` parameter is not found on PUT request body" 243 | raise exceptions.ValidationError(msg) 244 | 245 | try: 246 | model_to_put = request.env[model] 247 | except KeyError: 248 | msg = "The model `%s` does not exist." % model 249 | raise exceptions.ValidationError(msg) 250 | 251 | if "context" in post: 252 | # TODO: Handle error raised by `ensure_one` 253 | rec = model_to_put.with_context(**post["context"])\ 254 | .browse(rec_id).ensure_one() 255 | else: 256 | rec = model_to_put.browse(rec_id).ensure_one() 257 | 258 | # TODO: Handle data validation 259 | for field in data: 260 | if isinstance(data[field], dict): 261 | operations = [] 262 | for operation in data[field]: 263 | if operation == "push": 264 | operations.extend( 265 | (4, rec_id, _) 266 | for rec_id 267 | in data[field].get("push") 268 | ) 269 | elif operation == "pop": 270 | operations.extend( 271 | (3, rec_id, _) 272 | for rec_id 273 | in data[field].get("pop") 274 | ) 275 | elif operation == "delete": 276 | operations.extend( 277 | (2, rec_id, _) 278 | for rec_id 279 | in data[field].get("delete") 280 | ) 281 | else: 282 | data[field].pop(operation) # Invalid operation 283 | 284 | data[field] = operations 285 | elif isinstance(data[field], list): 286 | data[field] = [(6, _, data[field])] # Replace operation 287 | else: 288 | pass 289 | 290 | try: 291 | return rec.write(data) 292 | except Exception as e: 293 | # TODO: Return error message(e.msg) on a response 294 | return False 295 | 296 | # This is for bulk update 297 | @http.route( 298 | '/api//', 299 | type='json', auth="user", methods=['PUT'], csrf=False) 300 | def put_model_records(self, model, **post): 301 | try: 302 | data = post['data'] 303 | except KeyError: 304 | msg = "`data` parameter is not found on PUT request body" 305 | raise exceptions.ValidationError(msg) 306 | 307 | try: 308 | model_to_put = request.env[model] 309 | except KeyError: 310 | msg = "The model `%s` does not exist." % model 311 | raise exceptions.ValidationError(msg) 312 | 313 | # TODO: Handle errors on filter 314 | filters = post["filter"] 315 | 316 | if "context" in post: 317 | recs = model_to_put.with_context(**post["context"])\ 318 | .search(filters) 319 | else: 320 | recs = model_to_put.search(filters) 321 | 322 | # TODO: Handle data validation 323 | for field in data: 324 | if isinstance(data[field], dict): 325 | operations = [] 326 | for operation in data[field]: 327 | if operation == "push": 328 | operations.extend( 329 | (4, rec_id, _) 330 | for rec_id 331 | in data[field].get("push") 332 | ) 333 | elif operation == "pop": 334 | operations.extend( 335 | (3, rec_id, _) 336 | for rec_id 337 | in data[field].get("pop") 338 | ) 339 | elif operation == "delete": 340 | operations.extend( 341 | (2, rec_id, _) 342 | for rec_id in 343 | data[field].get("delete") 344 | ) 345 | else: 346 | pass # Invalid operation 347 | 348 | data[field] = operations 349 | elif isinstance(data[field], list): 350 | data[field] = [(6, _, data[field])] # Replace operation 351 | else: 352 | pass 353 | 354 | if recs.exists(): 355 | try: 356 | return recs.write(data) 357 | except Exception as e: 358 | # TODO: Return error message(e.msg) on a response 359 | return False 360 | else: 361 | # No records to update 362 | return True 363 | 364 | # This is for deleting one record 365 | @http.route( 366 | '/api///', 367 | type='http', auth="user", methods=['DELETE'], csrf=False) 368 | def delete_model_record(self, model, rec_id, **post): 369 | try: 370 | model_to_del_rec = request.env[model] 371 | except KeyError as e: 372 | msg = "The model `%s` does not exist." % model 373 | res = error_response(e, msg) 374 | return http.Response( 375 | json.dumps(res), 376 | status=200, 377 | mimetype='application/json' 378 | ) 379 | 380 | # TODO: Handle error raised by `ensure_one` 381 | rec = model_to_del_rec.browse(rec_id).ensure_one() 382 | 383 | try: 384 | is_deleted = rec.unlink() 385 | res = { 386 | "result": is_deleted 387 | } 388 | return http.Response( 389 | json.dumps(res), 390 | status=200, 391 | mimetype='application/json' 392 | ) 393 | except Exception as e: 394 | res = error_response(e, str(e)) 395 | return http.Response( 396 | json.dumps(res), 397 | status=200, 398 | mimetype='application/json' 399 | ) 400 | 401 | # This is for bulk deletion 402 | @http.route( 403 | '/api//', 404 | type='http', auth="user", methods=['DELETE'], csrf=False) 405 | def delete_model_records(self, model, **post): 406 | filters = json.loads(post["filter"]) 407 | 408 | try: 409 | model_to_del_rec = request.env[model] 410 | except KeyError as e: 411 | msg = "The model `%s` does not exist." % model 412 | res = error_response(e, msg) 413 | return http.Response( 414 | json.dumps(res), 415 | status=200, 416 | mimetype='application/json' 417 | ) 418 | 419 | # TODO: Handle error raised by `filters` 420 | recs = model_to_del_rec.search(filters) 421 | 422 | try: 423 | is_deleted = recs.unlink() 424 | res = { 425 | "result": is_deleted 426 | } 427 | return http.Response( 428 | json.dumps(res), 429 | status=200, 430 | mimetype='application/json' 431 | ) 432 | except Exception as e: 433 | res = error_response(e, str(e)) 434 | return http.Response( 435 | json.dumps(res), 436 | status=200, 437 | mimetype='application/json' 438 | ) 439 | 440 | @http.route( 441 | '/api///', 442 | type='http', auth="user", methods=['GET'], csrf=False) 443 | def get_binary_record(self, model, rec_id, field, **post): 444 | try: 445 | request.env[model] 446 | except KeyError as e: 447 | msg = "The model `%s` does not exist." % model 448 | res = error_response(e, msg) 449 | return http.Response( 450 | json.dumps(res), 451 | status=200, 452 | mimetype='application/json' 453 | ) 454 | 455 | rec = request.env[model].browse(rec_id).ensure_one() 456 | if rec.exists(): 457 | src = getattr(rec, field).decode("utf-8") 458 | else: 459 | src = False 460 | return http.Response( 461 | src 462 | ) 463 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Odoo REST API 2 | This is a module which expose Odoo as a REST API 3 | 4 | 5 | ## Installing 6 | * Download this module and put it to your Odoo addons directory 7 | * Install requirements with `pip install -r requirements.txt` 8 | 9 | ## Getting Started 10 | 11 | ### Authenticating users 12 | Before making any request make sure you are authenticated. The route which is used to authenticate users is `/auth/`. Below is an example showing how to authenticate users. 13 | ```py 14 | import json 15 | import requests 16 | import sys 17 | 18 | AUTH_URL = 'http://localhost:8069/auth/' 19 | 20 | headers = {'Content-type': 'application/json'} 21 | 22 | 23 | # Remember to configure default db on odoo configuration file(dbfilter = ^db_name$) 24 | # Authentication credentials 25 | data = { 26 | 'params': { 27 | 'login': 'your@email.com', 28 | 'password': 'yor_password', 29 | 'db': 'your_db_name' 30 | } 31 | } 32 | 33 | # Authenticate user 34 | res = requests.post( 35 | AUTH_URL, 36 | data=json.dumps(data), 37 | headers=headers 38 | ) 39 | 40 | # Get response cookies 41 | # This hold information for authenticated user 42 | cookies = res.cookies 43 | 44 | 45 | # Example 1 46 | # Get users 47 | USERS_URL = 'http://localhost:8069/api/res.users/' 48 | 49 | # This will take time since it retrives all res.users fields 50 | # You can use query param to fetch specific fields 51 | 52 | res = requests.get( 53 | USERS_URL, 54 | cookies=cookies # Here we are sending cookies which holds info for authenticated user 55 | ) 56 | 57 | # This will be a very long response since it has many data 58 | print(res.text) 59 | 60 | 61 | # Example 2 62 | # Get products(assuming you have products in you db) 63 | # Here am using query param to fetch only product id and name(This will be faster) 64 | USERS_URL = 'http://localhost:8069/api/product.product/' 65 | 66 | # Use query param to fetch only id and name 67 | params = {'query': '{id, name}'} 68 | 69 | res = requests.get( 70 | USERS_URL, 71 | params=params, 72 | cookies=cookies # Here we are sending cookies which holds info for authenticated user 73 | ) 74 | 75 | # This will be small since we've retrieved only id and name 76 | print(res.text) 77 | ``` 78 | 79 | 80 | ## Allowed HTTP methods 81 | 82 | ## 1. GET 83 | 84 | ### Model records: 85 | 86 | `GET /api/{model}/` 87 | #### Parameters 88 | * **query (optional):** 89 | 90 | This parameter is used to dynamically select fields to include on a response. For example if we want to select `id` and `name` fields from `res.users` model here is how we would do it. 91 | 92 | `GET /api/res.users/?query={id, name}` 93 | 94 | ```js 95 | { 96 | "count": 2, 97 | "prev": null, 98 | "current": 1, 99 | "next": null, 100 | "total_pages": 1, 101 | "result": [ 102 | { 103 | "id": 2, 104 | "name": "Administrator" 105 | }, 106 | { 107 | "id": 6, 108 | "name": "Sailors Co Ltd" 109 | } 110 | ] 111 | } 112 | ``` 113 | 114 | For nested records, for example if we want to select `id`, `name` and `company_id` fields from `res.users` model, but under `company_id` we want to select `name` field only. here is how we would do it. 115 | 116 | `GET /api/res.users/?query={id, name, company_id{name}}` 117 | 118 | ```js 119 | { 120 | "count": 2, 121 | "prev": null, 122 | "current": 1, 123 | "next": null, 124 | "total_pages": 1, 125 | "result": [ 126 | { 127 | "id": 2, 128 | "name": "Administrator", 129 | "company_id": { 130 | "name": "Singo Africa" 131 | } 132 | }, 133 | { 134 | "id": 6, 135 | "name": "Sailors Co Ltd", 136 | "company_id": { 137 | "name": "Singo Africa" 138 | } 139 | } 140 | ] 141 | } 142 | ``` 143 | 144 | For nested iterable records, for example if we want to select `id`, `name` and `related_products` fields from `product.template` model, but under `related_products` we want to select `name` field only. here is how we would do it. 145 | 146 | `GET /api/product.template/?query={id, name, related_products{name}}` 147 | 148 | ```js 149 | { 150 | "count": 2, 151 | "prev": null, 152 | "current": 1, 153 | "next": null, 154 | "total_pages": 1, 155 | "result": [ 156 | { 157 | "id": 16, 158 | "name": "Alaf Resincot Steel Roof-16", 159 | "related_products": [ 160 | {"name": "Alloy Steel AISI 4140 Bright Bars - All 5.8 meter longs"}, 161 | {"name": "Test product"} 162 | ] 163 | }, 164 | { 165 | "id": 18, 166 | "name": "Alaf Resincot Steel Roof-43", 167 | "related_products": [ 168 | {"name": "Alloy Steel AISI 4140 Bright Bars - All 5.8 meter longs"}, 169 | {"name": "Aluminium Sheets & Plates"}, 170 | {"name": "Test product"} 171 | ] 172 | } 173 | ] 174 | } 175 | ``` 176 | 177 | If you want to fetch all fields except few you can use exclude(-) operator. For example in the case above if we want to fetch all fields except `name` field, here is how we could do it 178 | `GET /api/product.template/?query={-name}` 179 | 180 | ```js 181 | { 182 | "count": 3, 183 | "prev": null, 184 | "current": 1, 185 | "next": null, 186 | "total_pages": 1, 187 | "result": [ 188 | { 189 | "id": 1, 190 | ... // All fields except name 191 | }, 192 | { 193 | "id": 2 194 | ... // All fields except name 195 | } 196 | ... 197 | ] 198 | } 199 | ``` 200 | 201 | There is also a wildcard(\*) operator which can be used to fetch all fields, Below is an example which shows how you can fetch all product's fields but under `related_products` field get all fields except `id`. 202 | 203 | `GET /api/product.template/?query={*, related_products{-id}}` 204 | 205 | ```js 206 | { 207 | "count": 3, 208 | "prev": null, 209 | "current": 1, 210 | "next": null, 211 | "total_pages": 1, 212 | "result": [ 213 | { 214 | "id": 1, 215 | "name": "Pen", 216 | "related_products"{ 217 | "name": "Pencil", 218 | ... // All fields except id 219 | } 220 | ... // All fields 221 | }, 222 | ... 223 | ] 224 | } 225 | ``` 226 | 227 | **If you don't specify query parameter all fields will be returned.** 228 | 229 | 230 | * **filter (optional):** 231 | 232 | This is used to filter out data returned. For example if we want to get all products with id ranging from 60 to 70, here's how we would do it. 233 | 234 | `GET /api/product.template/?query={id, name}&filter=[["id", ">", 60], ["id", "<", 70]]` 235 | 236 | ```js 237 | { 238 | "count": 3, 239 | "prev": null, 240 | "current": 1, 241 | "next": null, 242 | "total_pages": 1, 243 | "result": [ 244 | { 245 | "id": 67, 246 | "name": "Crown Paints Economy Superplus Emulsion" 247 | }, 248 | { 249 | "id": 69, 250 | "name": "Crown Paints Permacote" 251 | } 252 | ] 253 | } 254 | ``` 255 | 256 | * **page_size (optional) & page (optional):** 257 | 258 | These two allows us to do pagination. Hre page_size is used to specify number of records on a single page and page is used to specify the current page. For example if we want our page_size to be 5 records and we want to fetch data on page 3 here is how we would do it. 259 | 260 | `GET /api/product.template/?query={id, name}&page_size=5&page=3` 261 | 262 | ```js 263 | { 264 | "count": 5, 265 | "prev": 2, 266 | "current": 3, 267 | "next": 4, 268 | "total_pages": 15, 269 | "result": [ 270 | {"id": 141, "name": "Borewell Slotting Pipes"}, 271 | {"id": 114, "name": "Bright Bars"}, 272 | {"id": 128, "name": "Chain Link Fence"}, 273 | {"id": 111, "name": "Cold Rolled Sheets - CRCA & GI Sheets"}, 274 | {"id": 62, "name": "Crown Paints Acrylic Primer/Sealer Undercoat"} 275 | ] 276 | } 277 | ``` 278 | 279 | Note: `prev`, `current`, `next` and `total_pages` shows the previous page, current page, next page and the total number of pages respectively. 280 | 281 | * **limit (optional):** 282 | 283 | This is used to limit the number of results returned on a request regardless of pagination. For example 284 | 285 | `GET /api/product.template/?query={id, name}&limit=3` 286 | 287 | ```js 288 | { 289 | "count": 3, 290 | "prev": null, 291 | "current": 1, 292 | "next": null, 293 | "total_pages": 1, 294 | "result": [ 295 | {"id": 16, "name": "Alaf Resincot Steel Roof-16"}, 296 | {"id": 18, "name": "Alaf Resincot Steel Roof-43"}, 297 | {"id": 95, "name": "Alaf versatile steel roof"} 298 | ] 299 | } 300 | ``` 301 | 302 | ### Model record: 303 | 304 | `GET /api/{model}/{id}` 305 | #### Parameters 306 | * **query (optional):** 307 | 308 | Here query parameter works exactly the same as explained before except it selects fields on a single record. For example 309 | 310 | `GET /api/product.template/95/?query={id, name}` 311 | 312 | ```js 313 | { 314 | "id": 95, 315 | "name": "Alaf versatile steel roof" 316 | } 317 | ``` 318 | 319 | 320 | ## 2. POST 321 | 322 | `POST /api/{model}/` 323 | #### Headers 324 | * Content-Type: application/json 325 | #### Parameters 326 | * **data (mandatory):** 327 | 328 | This is used to pass data to be posted. For example 329 | 330 | `POST /api/product.public.category/` 331 | 332 | Request Body 333 | 334 | ```js 335 | { 336 | "params": { 337 | "data": { 338 | "name": "Test category_2" 339 | } 340 | } 341 | } 342 | ``` 343 | 344 | Response 345 | 346 | ```js 347 | { 348 | "jsonrpc": "2.0", 349 | "id": null, 350 | "result": 398 351 | } 352 | ``` 353 | 354 | The number on `result` is the `id` of the newly created record. 355 | 356 | * **context (optional):** 357 | 358 | This is used to pass any context if it's needed when creating new record. The format of passing it is 359 | 360 | Request Body 361 | 362 | ```js 363 | { 364 | "params": { 365 | "context": { 366 | "context_1": "context_1_value", 367 | "context_2": "context_2_value", 368 | .... 369 | }, 370 | "data": { 371 | "field_1": "field_1_value", 372 | "field_2": "field_2_value", 373 | .... 374 | } 375 | } 376 | } 377 | ``` 378 | 379 | ## 3. PUT 380 | 381 | ### Model records: 382 | 383 | `PUT /api/{model}/` 384 | #### Headers 385 | * Content-Type: application/json 386 | #### Parameters 387 | * **data (mandatory):** 388 | 389 | This is used to pass data to update, it works with filter parameter, See example below 390 | 391 | * **filter (mandatory):** 392 | 393 | This is used to filter data to update. For example 394 | 395 | `PUT /api/product.template/` 396 | 397 | Request Body 398 | 399 | ```js 400 | { 401 | "params": { 402 | "filter": [["id", "=", 95]], 403 | "data": { 404 | "name": "Test product" 405 | } 406 | } 407 | } 408 | ``` 409 | 410 | Response 411 | 412 | ```js 413 | { 414 | "jsonrpc": "2.0", 415 | "id": null, 416 | "result": true 417 | } 418 | ``` 419 | 420 | Note: If the result is true it means success and if false or otherwise it means there was an error during update. 421 | 422 | * **context (optional):** 423 | Just like in GET context is used to pass any context associated with record update. The format of passing it is 424 | 425 | Request Body 426 | 427 | ```js 428 | { 429 | "params": { 430 | "context": { 431 | "context_1": "context_1_value", 432 | "context_2": "context_2_value", 433 | .... 434 | }, 435 | "filter": [["id", "=", 95]], 436 | "data": { 437 | "field_1": "field_1_value", 438 | "field_2": "field_2_value", 439 | .... 440 | } 441 | } 442 | } 443 | ``` 444 | 445 | * **operation (optional)**: 446 | 447 | This is only applied to `one2many` and `many2many` fields. The concept is sometimes you might not want to replace all records on either `one2many` or `many2many` fields, instead you might want to add other records or remove some records, this is where put operations comes in place. Thre are basically three PUT operations which are push, pop and delete. 448 | * push is used to add/append other records to existing linked records 449 | * pop is used to remove/unlink some records from the record being updated but it doesn't delete them on the system 450 | * delete is used to remove/unlink and delete records permanently on the system 451 | 452 | For example here is how you would update `related_product_ids` which is `many2many` field with PUT operations 453 | 454 | `PUT /api/product.template/` 455 | 456 | Request Body 457 | 458 | ```js 459 | { 460 | "params": { 461 | "filter": [["id", "=", 95]], 462 | "data": { 463 | "related_product_ids": { 464 | "push": [102, 30], 465 | "pop": [45], 466 | "delete": [55] 467 | } 468 | } 469 | } 470 | } 471 | ``` 472 | 473 | This will append product with ids 102 and 30 as related products to product with id 95 and from there unlink product with id 45 and again unlink product with id 55 and delete it from the system. So if befor this request product with id 95 had [20, 37, 45, 55] as related product ids, after this request it will be [20, 37, 102, 30]. 474 | 475 | Note: You can use one operation or two or all three at a time depending on what you want to update on your field. If you dont use these operations on `one2many` and `many2many` fields, existing values will be replaced by new values passed, so you need to be very carefull on this part. 476 | 477 | Response: 478 | 479 | ```js 480 | { 481 | "jsonrpc": "2.0", 482 | "id": null, 483 | "result": true 484 | } 485 | ``` 486 | 487 | ### Model record: 488 | 489 | `PUT /api/{model}/{id}` 490 | #### Headers 491 | * Content-Type: application/json 492 | #### Parameters 493 | * data (mandatory) 494 | * context (optional) 495 | * PUT operation(push, pop, delete) (optional) 496 | 497 | All parameters works the same as explained on previous section, what changes is that here they apply to a single record being updated and we don't have filter parameter because `id` of record to be updated is passed on URL as `{id}`. Example to give us an idea of how this works. 498 | 499 | `PUT /api/product.template/95/` 500 | 501 | Request Body 502 | 503 | ```js 504 | { 505 | "params": { 506 | "data": { 507 | "related_product_ids": { 508 | "push": [102, 30], 509 | "pop": [45], 510 | "delete": [55] 511 | } 512 | } 513 | } 514 | } 515 | ``` 516 | 517 | ## 4. DELETE 518 | 519 | ### Model records: 520 | 521 | `DELETE /api/{model}/` 522 | #### Parameters 523 | * **filter (mandatory):** 524 | 525 | This is used to filter data to delete. For example 526 | 527 | `DELETE /api/product.template/?filter=[["id", "=", 95]]` 528 | 529 | Response 530 | 531 | ```js 532 | { 533 | "result": true 534 | } 535 | ``` 536 | 537 | Note: If the result is true it means success and if false or otherwise it means there was an error during deletion. 538 | 539 | 540 | ### Model records: 541 | 542 | `DELETE /api/{model}/{id}` 543 | #### Parameters 544 | This takes no parameter and we don't have filter parameter because `id` of record to be deleted is passed on URL as `{id}`. Example to give us an idea of how this works. 545 | 546 | `DELETE /api/product.template/95/` 547 | 548 | Response 549 | 550 | ```js 551 | { 552 | "result": true 553 | } 554 | ``` 555 | 556 | ## Calling Model's Function 557 | 558 | Sometimes you might need to call model's function or a function bound to a record, inorder to do so, send a `POST` request with a body containing arguments(args) and keyword arguments(kwargs) required by the function you want to call. 559 | 560 | Below is how you can call model's function 561 | 562 | `POST /object/{model}/{function name}` 563 | 564 | Request Body 565 | 566 | ```js 567 | { 568 | "params": { 569 | "args": [arg1, arg2, ..], 570 | "kwargs ": { 571 | "key1": "value1", 572 | "key2": "value2", 573 | ... 574 | } 575 | } 576 | } 577 | ``` 578 | 579 | And below is how you can call a function bound to a record 580 | 581 | `POST /object/{model}/{record_id}/{function name}` 582 | 583 | Request Body 584 | 585 | ```js 586 | { 587 | "params": { 588 | "args": [arg1, arg2, ..], 589 | "kwargs ": { 590 | "key1": "value1", 591 | "key2": "value2", 592 | ... 593 | } 594 | } 595 | } 596 | ``` 597 | 598 | In both cases the response will be the result returned by the function called 599 | --------------------------------------------------------------------------------