├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── README.md ├── addons └── odoo-rest-api │ ├── __init__.py │ ├── __manifest__.py │ └── controllers │ ├── RestAuth.py │ ├── RestCheck.py │ ├── RestController.py │ ├── RestHelper.py │ └── __init__.py ├── config ├── nginx │ └── default.conf ├── odoo │ └── odoo.conf.example └── postgres │ └── postgres.env.example ├── docker-compose.yaml └── requirements.txt /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Build the Docker image 18 | run: docker build . --file Dockerfile --tag my-image-name:$(date +%s) 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.env 4 | notes.txt 5 | __pycache__ 6 | *.pyc 7 | config/odoo/odoo.conf -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM odoo:14.0 2 | 3 | USER root 4 | 5 | RUN apt-get update 6 | RUN apt-get install -y nano git build-essential libssl-dev libffi-dev cargo 7 | RUN pip3 install --no-cache-dir --upgrade pip 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Odoo REST API Module 2 | This is simple odoo module for REST API 3 | 4 | ## How to run? 5 | - Fresh Install: 6 | - `docker-compose up -d` in your terminal (if you want to run using docker and/or you want to fresh install odoo) 7 | 8 | - Existing Installation 9 | - move `addons/odoo-rest-api` into your odoo addons directory (if you have odoo), install in Apps then check the API in `GET /api/ping` 10 | - if you have existing odoo in docker and want to install this addons, mount directory `addons/odoo-rest-api` to odoo image which is running in docker and follow a step above 11 | 12 | ## Endpoint 13 | - Check if API is working or server is active 14 | - `GET /api/ping` 15 | - `GET /api/check` 16 | - `GET /api/check/health` 17 | - `GET /api/health` 18 | - List of Database in Odoo (which is model in odoo) 19 | - `GET /api/db` 20 | - `GET /api/database` 21 | - Authentication and Authorization 22 | - `POST /api/auth/login` 23 | - `GET /api/auth/logout` 24 | - `POST /api/auth/logout` 25 | - Get Data 26 | - `GET /api/model/:model` 27 | - `GET /api/model/:field` 28 | - `GET /api/model/:rec_id` 29 | - `GET /api/model/:rec_id/:field` 30 | - Post Data 31 | - `POST /api/model/:model` 32 | - Put Data 33 | - `PUT /api/model/:model` 34 | - `PUT /api/model/:model/:rec_id` 35 | - Delete Data 36 | - `DELETE /api/model/:model` 37 | - `DELETE /api/model/:model/:rec_id` 38 | 39 | ## TODOS 40 | - Create API documentation such as OpenAPI (for JSON) or Swagger UI (for web view) 41 | - Create unit test 42 | - Implement more secure Auth such as Session, JWT, OAuth or even SSO 43 | - Create more endpoint or improve endpoint (right now, i don't have an idea what endpoint should i created) 44 | -------------------------------------------------------------------------------- /addons/odoo-rest-api/__init__.py: -------------------------------------------------------------------------------- 1 | from . import controllers 2 | -------------------------------------------------------------------------------- /addons/odoo-rest-api/__manifest__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | { 3 | 'name': 'Odoo Rest API', 4 | 'version': '1.0', 5 | 'summary': 'Rest API Odoo', 6 | 'sequence': 40, 7 | 'description': """Rest API Odoo""", 8 | 'category': 'Generic Modules', 9 | 'website': '', 10 | 'license': '', 11 | 'depends': [], 12 | 'data': [], 13 | 'demo': [], 14 | 'qweb': [], 15 | 'installable': True, 16 | 'application': True, 17 | 'auto_install': False, 18 | } 19 | -------------------------------------------------------------------------------- /addons/odoo-rest-api/controllers/RestAuth.py: -------------------------------------------------------------------------------- 1 | from odoo import http, _ 2 | from odoo.http import request 3 | from .RestHelper import RestHelper 4 | from typing import Dict 5 | 6 | ENDPOINT_AUTH = '/api/auth' 7 | 8 | 9 | class RestAuth(http.Controller): 10 | 11 | """ 12 | just for example\n 13 | Do not use json.dumps if type=='json' 14 | 15 | ``` 16 | @http.route( 17 | f'{ENDPOINT}/test/', 18 | auth="user", type="json", methods=['GET'], csrf=False 19 | ) 20 | def SampleRoute(self, param: dict) -> Dict[str, any]: 21 | args = request.httprequest.args # get parameter from url 22 | jsonargs = request.jsonrequest # get parameter from json 23 | data = { 24 | 'param1': args.get('param1'), 25 | 'param2': args.get('param2'), 26 | } 27 | return JsonValidResponse(data) 28 | ``` 29 | """ 30 | 31 | @http.route( 32 | f'{ENDPOINT_AUTH}/login', 33 | auth="none", type="json", methods=['POST'], csrf=False 34 | ) 35 | def Login(self) -> Dict[str, any]: 36 | 37 | params = request.jsonrequest 38 | try: 39 | request.session.authenticate(params['db'], params['username'], params['password']) 40 | return RestHelper.JsonValidResponse(request.env['ir.http'].session_info()) 41 | except Exception as e: 42 | if str(e) == _('Access Denied'): 43 | return RestHelper.JsonErrorResponse(_('Unauthorized, Access Denied. Check your db, username or password'), 401) 44 | return RestHelper.JsonErrorResponse(_(f'Missing required fields: {e}'), 400) 45 | 46 | @http.route( 47 | f'{ENDPOINT_AUTH}/logout', 48 | auth="user", type="json", methods=['GET', 'POST'], csrf=False 49 | ) 50 | def Logout(self) -> Dict[str, any]: 51 | 52 | try: 53 | request.session.logout(keep_db=True) 54 | return RestHelper.JsonValidResponse(_('Logout successful')) 55 | except Exception as e: 56 | return RestHelper.JsonErrorResponse(_(e)) 57 | -------------------------------------------------------------------------------- /addons/odoo-rest-api/controllers/RestCheck.py: -------------------------------------------------------------------------------- 1 | from odoo import http, _ 2 | from .RestHelper import RestHelper 3 | from typing import Dict 4 | 5 | 6 | class RestCheck(http.Controller): 7 | 8 | """ 9 | Checking if REST API is active, and also checking for available database 10 | """ 11 | 12 | @http.route([ 13 | '/api/ping', 14 | '/api/check/health', 15 | '/api/check', 16 | '/api/health', 17 | ], auth="public", type="json", csrf=False 18 | ) 19 | def CheckServer(self) -> Dict[str, any]: 20 | """ 21 | Checking the server 22 | """ 23 | try: 24 | return RestHelper.JsonValidResponse(_('Okay')) 25 | except Exception as e: 26 | return RestHelper.JsonErrorResponse(_(e)) 27 | 28 | @http.route([ 29 | '/api/db', 30 | '/api/database' 31 | ], auth="public", type="json", csrf=False 32 | ) 33 | def DBList(self) -> Dict[str, any]: 34 | """ 35 | List of Available Database 36 | """ 37 | try: 38 | return RestHelper.JsonValidResponse(http.db_list()) 39 | except Exception as e: 40 | return RestHelper.JsonErrorResponse(_(e)) 41 | -------------------------------------------------------------------------------- /addons/odoo-rest-api/controllers/RestController.py: -------------------------------------------------------------------------------- 1 | import math 2 | from odoo import http, _, exceptions 3 | from odoo.http import request 4 | from .RestHelper import RestHelper 5 | from typing import Optional, Dict 6 | 7 | ENDPOINT = '/api/model' 8 | 9 | 10 | class RestController(http.Controller): 11 | 12 | """ 13 | just for example\n 14 | Do not use json.dumps if type=='json' 15 | 16 | ``` 17 | @http.route( 18 | f'{ENDPOINT}/test/', 19 | auth="user", type="json", methods=['GET'], csrf=False 20 | ) 21 | def SampleRoute(self, param: Dict[str, any]) -> Dict[str, any]: 22 | args = request.httprequest.args # get parameter from url 23 | jsonargs = request.jsonrequest # get parameter from json 24 | data = { 25 | 'param1': args.get('param1'), 26 | 'param2': args.get('param2'), 27 | } 28 | return JsonValidResponse(data) 29 | ``` 30 | """ 31 | 32 | @http.route([ 33 | f'{ENDPOINT}/', 34 | f'{ENDPOINT}//', 35 | f'{ENDPOINT}//', 36 | f'{ENDPOINT}///', 37 | ], auth="user", type="json", methods=['GET'], csrf=False) 38 | def GetData( 39 | self, 40 | model: str, rec_id: Optional[int] = None, 41 | field: Optional[str] = None 42 | ) -> Dict[str, any]: 43 | 44 | args = request.httprequest.args 45 | 46 | # Querying all data 47 | try: 48 | if rec_id: 49 | record = request.env[model].sudo().browse(rec_id) 50 | else: 51 | if args.get('rec_ids'): 52 | rec_ids = list(map(int, args.get('rec_ids').split(','))) 53 | record = request.env[model].sudo().browse(rec_ids) 54 | else: 55 | if args.get('order'): 56 | order = args.get('order') 57 | else: 58 | order = None 59 | 60 | if args.get('limit'): 61 | limit = int(args.get('limit')) 62 | else: 63 | limit = None 64 | 65 | if args.get('filter'): 66 | filter = eval(args.get('filter')) 67 | else: 68 | filter = [] 69 | 70 | if args.get('offset'): 71 | offset = int(args.get('offset')) 72 | else: 73 | offset = 0 74 | record = request.env[model].sudo().search( 75 | filter, order=order, limit=limit, offset=offset) 76 | except Exception as e: 77 | return RestHelper.JsonErrorResponse(_(f"Invalid: {e}")) 78 | 79 | # Querying all data but based from a field or the fields 80 | try: 81 | if field: 82 | records = record.read([field]) 83 | else: 84 | if args.get('fields') and args.get('fields').strip() != '': 85 | fields = args.get('fields').split(',') 86 | records = record.read(fields) 87 | else: 88 | records = record.read() 89 | except Exception as e: 90 | return RestHelper.JsonErrorResponse(_(e)) 91 | 92 | # from: https://github.com/yezyilomo/odoo-rest-api/blob/master/controllers/controllers.py 93 | prev_page = None 94 | next_page = None 95 | total_page_number = 1 96 | current_page = 1 97 | 98 | if args.get('page_size'): 99 | page_size = int(args.get('page_size')) 100 | count = len(records) 101 | total_page_number = math.ceil(count / page_size) 102 | 103 | if args.get('page'): 104 | current_page = int(args.get('page')) 105 | else: 106 | current_page = 1 # Default page Number 107 | start = page_size * (current_page - 1) 108 | stop = current_page * page_size 109 | records = records[start:stop] 110 | next_page = current_page + 1 if 0 < current_page + 1 <= total_page_number else None 111 | prev_page = current_page - 1 if 0 < current_page - 1 <= total_page_number else None 112 | 113 | return RestHelper.JsonValidResponse({ 114 | "prev": prev_page, 115 | "current": current_page, 116 | "next": next_page, 117 | "total_pages": total_page_number, 118 | 'length_record': len(records), 119 | 'record': records, 120 | }) 121 | 122 | @http.route([ 123 | f'{ENDPOINT}/', 124 | ], auth="user", type="json", methods=['POST'], csrf=False) 125 | def PostData(self, model: str) -> Dict[str, any]: 126 | 127 | params = request.jsonrequest 128 | 129 | try: 130 | record = request.env[model].sudo().create(params) 131 | except Exception as e: 132 | return RestHelper.JsonErrorResponse(_(e)) 133 | 134 | return RestHelper.JsonValidResponse({ 135 | 'result': record.id, 136 | }) 137 | 138 | @http.route([ 139 | f'{ENDPOINT}/', 140 | f'{ENDPOINT}//', 141 | ], auth="user", type="json", methods=['PUT'], csrf=False) 142 | def PutData(self, model: str, rec_id: Optional[int] = None) -> Dict[str, any]: 143 | 144 | params = request.jsonrequest 145 | args = request.httprequest.args 146 | 147 | try: 148 | records = request.env[model].sudo() 149 | if rec_id: # return singleton record 150 | record = records.browse(rec_id).ensure_one() 151 | data = rec_id 152 | else: 153 | if args.get('rec_ids'): # return multiple records 154 | rec_ids = list(map(int, args.get('rec_ids').split(','))) 155 | record = records.browse(rec_ids) 156 | data = rec_ids 157 | else: 158 | # return multiple records (or maybe single record) by filter 159 | if args.get('filter'): 160 | filter = eval(args.get('filter')) 161 | record = records.search(filter) 162 | data = args.get('filter') 163 | else: # if no filter, raise error 164 | data = None 165 | raise exceptions.ValidationError(_('Invalid filter')) 166 | except Exception as e: 167 | return RestHelper.JsonErrorResponse({ 168 | 'result': False, 169 | 'message': _(f"Invalid update {data}: {e}"), 170 | }) 171 | 172 | try: 173 | result = record.write(params) 174 | except Exception as e: 175 | return RestHelper.JsonErrorResponse({ 176 | 'result': False, 177 | 'message': _(f"Invalid update {data}: {e}"), 178 | }) 179 | 180 | return RestHelper.JsonValidResponse({ 181 | 'result': True, 182 | 'message': _(f"Successfully update {data}"), 183 | }) 184 | 185 | @http.route([ 186 | f'{ENDPOINT}/', 187 | f'{ENDPOINT}//', 188 | ], auth="user", type="json", methods=['DELETE'], csrf=False) 189 | def DeleteData(self, model: str, rec_id: Optional[int] = None) -> Dict[str, any]: 190 | 191 | params = request.jsonrequest 192 | args = request.httprequest.args 193 | 194 | try: 195 | records = request.env[model].sudo() 196 | if rec_id: # return singleton record 197 | record = records.browse(rec_id).ensure_one() 198 | data = rec_id 199 | else: 200 | if args.get('rec_ids'): # return multiple records 201 | rec_ids = list(map(int, args.get('rec_ids').split(','))) 202 | record = records.browse(rec_ids) 203 | data = rec_ids 204 | else: 205 | # return multiple records (or maybe single record) by filter 206 | if args.get('filter'): 207 | filter = eval(args.get('filter')) 208 | record = records.search(filter) 209 | data = args.get('filter') 210 | else: # if no filter, raise error 211 | data = None 212 | raise exceptions.ValidationError(_('Invalid filter')) 213 | except Exception as e: 214 | return RestHelper.JsonErrorResponse({ 215 | 'result': False, 216 | 'message': _(f"Invalid delete {data}: {e}"), 217 | }) 218 | 219 | try: 220 | result = record.unlink() 221 | except Exception as e: 222 | return RestHelper.JsonErrorResponse({ 223 | 'result': False, 224 | 'message': _(f"Invalid delete {data}: {e}"), 225 | }) 226 | 227 | return RestHelper.JsonValidResponse({ 228 | 'result': True, 229 | 'message': _(f"Successfully delete {data}"), 230 | }) 231 | -------------------------------------------------------------------------------- /addons/odoo-rest-api/controllers/RestHelper.py: -------------------------------------------------------------------------------- 1 | from odoo import _ 2 | from odoo.http import Response 3 | from typing import Optional, Dict 4 | 5 | 6 | class RestHelper: 7 | 8 | @staticmethod 9 | def JsonValidResponse(data: any, valid_code: Optional[int] = 200) -> Dict[str, any]: 10 | """ 11 | Return a JsonResponse with the given data and status code if code is valid or no exceptions. 12 | """ 13 | Response.status = str(valid_code) 14 | return { 15 | 'status_code': valid_code, 16 | 'message': _('success'), 17 | 'data': data, 18 | 'success': True 19 | } 20 | 21 | @staticmethod 22 | def JsonErrorResponse(error: any, error_code: Optional[int] = 400) -> Dict[str, any]: 23 | """ 24 | Return a JsonResponse with the given data and status code if code is not valid or with exceptions. 25 | """ 26 | Response.status = str(error_code) 27 | return { 28 | 'code': error_code, 29 | 'message': _('failed'), 30 | 'error': error, 31 | 'success': False 32 | } 33 | -------------------------------------------------------------------------------- /addons/odoo-rest-api/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from . import RestController 2 | from . import RestAuth 3 | from . import RestHelper 4 | from . import RestCheck 5 | -------------------------------------------------------------------------------- /config/nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen [::]:80; 3 | listen 80; 4 | 5 | location ~ /.well-known/acme-challenge { 6 | allow all; 7 | root /var/www/html; 8 | } 9 | 10 | location / { 11 | proxy_set_header X-Real-IP $remote_addr; 12 | proxy_set_header X-Forwarded-For $remote_addr; 13 | proxy_set_header X-Forwarded-Proto $scheme; 14 | proxy_set_header Host $host; 15 | proxy_pass http://odoo:8069; 16 | } 17 | 18 | location ~* /web/static { 19 | proxy_set_header X-Real-IP $remote_addr; 20 | proxy_set_header X-Forwarded-For $remote_addr; 21 | proxy_set_header X-Forwarded-Proto $scheme; 22 | proxy_set_header Host $host; 23 | proxy_pass http://odoo:8069; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /config/odoo/odoo.conf.example: -------------------------------------------------------------------------------- 1 | [options] 2 | addons_path = /mnt/extra-addons 3 | data_dir = /var/lib/odoo 4 | admin_passwd = your_super_secure_password 5 | db_host = postgres 6 | ; csv_internal_sep = , 7 | ; db_maxconn = 64 8 | ; db_name = False 9 | ; db_template = template1 10 | ; dbfilter = .* 11 | ; debug_mode = False 12 | ; email_from = False 13 | ; limit_memory_hard = 2684354560 14 | ; limit_memory_soft = 2147483648 15 | ; limit_request = 8192 16 | ; limit_time_cpu = 60 17 | ; limit_time_real = 120 18 | ; list_db = True 19 | ; log_db = False 20 | ; log_handler = [':INFO'] 21 | ; log_level = info 22 | ; logfile = None 23 | ; longpolling_port = 8072 24 | ; max_cron_threads = 2 25 | ; osv_memory_age_limit = 1.0 26 | ; osv_memory_count_limit = False 27 | ; smtp_password = False 28 | ; smtp_port = 25 29 | ; smtp_server = localhost 30 | ; smtp_ssl = False 31 | ; smtp_user = False 32 | ; workers = 0 33 | ; xmlrpc = True 34 | ; xmlrpc_interface = 35 | ; xmlrpc_port = 8069 36 | ; xmlrpcs = True 37 | ; xmlrpcs_interface = 38 | ; xmlrpcs_port = 8071 39 | ; db_port = 5432 40 | ; db_name = odoo 41 | ; db_password = odoo 42 | ; db_user = odoo 43 | -------------------------------------------------------------------------------- /config/postgres/postgres.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=postgres 2 | POSTGRES_PASSWORD= 3 | POSTGRES_USER=odoo 4 | PGDATA=/var/lib/postgresql/data/odoo 5 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | container_name: odoo-postgres 6 | image: postgres:13-alpine 7 | restart: unless-stopped 8 | env_file: 9 | - ./config/postgres/postgres.env 10 | volumes: 11 | - odoo-db-data:/var/lib/postgresql/data/odoo/ 12 | ports: 13 | - 5432:5432/tcp 14 | 15 | odoo: 16 | container_name: odoo-frontend 17 | build: . 18 | restart: unless-stopped 19 | depends_on: 20 | - postgres 21 | links: 22 | - postgres:postgres 23 | volumes: 24 | - ./addons:/mnt/extra-addons 25 | - ./config/odoo:/etc/odoo 26 | - odoo-web-data:/var/lib/odoo 27 | ports: 28 | - 8069:8069/tcp 29 | 30 | nginx: 31 | container_name: odoo-proxy-pass 32 | image: nginx:latest 33 | volumes: 34 | - ./config/nginx:/etc/nginx/conf.d 35 | ports: 36 | - 80:80 37 | - 443:443 38 | depends_on: 39 | - odoo 40 | restart: unless-stopped 41 | links: 42 | - odoo:odoo 43 | 44 | volumes: 45 | odoo-db-data: 46 | odoo-web-data: 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | 2 | --------------------------------------------------------------------------------