From 119a1ecd93d3d172585cab90c36e87157981237d Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 23 Sep 2022 17:21:22 +0200 Subject: [PATCH 001/118] [ADD] Allows use of fastapi into Odoo! --- fastapi/README.rst | 95 ++++++ fastapi/__init__.py | 2 + fastapi/__manifest__.py | 24 ++ fastapi/demo/fastapi_app_demo.xml | 53 ++++ fastapi/depends.py | 8 + fastapi/fastapi_dispatcher.py | 40 +++ fastapi/models/__init__.py | 1 + fastapi/models/fastapi_app.py | 192 ++++++++++++ fastapi/models/fastapi_app_demo.py | 41 +++ fastapi/readme/CONTRIBUTORS.rst | 1 + fastapi/readme/DESCRIPTION.rst | 8 + fastapi/readme/ROADMAP.rst | 3 + fastapi/security/fastapi_app.xml | 25 ++ fastapi/security/res_groups.xml | 26 ++ fastapi/static/description/icon.png | Bin 0 -> 36542 bytes fastapi/static/description/index.html | 431 ++++++++++++++++++++++++++ fastapi/tests/__init__.py | 1 + fastapi/tests/test_fastapi.py | 44 +++ fastapi/views/fastapi_app.xml | 93 ++++++ fastapi/views/fastapi_menu.xml | 11 + 20 files changed, 1099 insertions(+) create mode 100644 fastapi/README.rst create mode 100644 fastapi/__init__.py create mode 100644 fastapi/__manifest__.py create mode 100644 fastapi/demo/fastapi_app_demo.xml create mode 100644 fastapi/depends.py create mode 100644 fastapi/fastapi_dispatcher.py create mode 100644 fastapi/models/__init__.py create mode 100644 fastapi/models/fastapi_app.py create mode 100644 fastapi/models/fastapi_app_demo.py create mode 100644 fastapi/readme/CONTRIBUTORS.rst create mode 100644 fastapi/readme/DESCRIPTION.rst create mode 100644 fastapi/readme/ROADMAP.rst create mode 100644 fastapi/security/fastapi_app.xml create mode 100644 fastapi/security/res_groups.xml create mode 100644 fastapi/static/description/icon.png create mode 100644 fastapi/static/description/index.html create mode 100644 fastapi/tests/__init__.py create mode 100644 fastapi/tests/test_fastapi.py create mode 100644 fastapi/views/fastapi_app.xml create mode 100644 fastapi/views/fastapi_menu.xml diff --git a/fastapi/README.rst b/fastapi/README.rst new file mode 100644 index 000000000..93745186d --- /dev/null +++ b/fastapi/README.rst @@ -0,0 +1,95 @@ +============ +Odoo FastAPI +============ + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/16.0/fastapi + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-16-0/rest-framework-16-0-fastapi + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/271/16.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This addon provides the basis smoothly integrate the `FastAPI`_ +framework into Odoo. + +This integration allows you to use all the goodies from `FastAPI` to build custom +APIs for your Odoo server based on standard Python type hints. + + +.. _FastAPI: https://fastapi.tiangolo.com/ + +**Table of contents** + +.. contents:: + :local: + +Known issues / Roadmap +====================== + +The `roadmap `_ +and `known issues `_ can +be found on GitHub. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Laurent Mignon + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-lmignon| image:: https://github.com/lmignon.png?size=40px + :target: https://github.com/lmignon + :alt: lmignon + +Current `maintainer `__: + +|maintainer-lmignon| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fastapi/__init__.py b/fastapi/__init__.py new file mode 100644 index 000000000..d54296502 --- /dev/null +++ b/fastapi/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import fastapi_dispatcher diff --git a/fastapi/__manifest__.py b/fastapi/__manifest__.py new file mode 100644 index 000000000..52e3b62c8 --- /dev/null +++ b/fastapi/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2022 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + +{ + "name": "Odoo FastAPI", + "summary": """ + Odoo FastApi instegration""", + "version": "16.0.0.0.1", + "license": "LGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "maintainers": ["lmignon"], + "website": "https://github.com/OCA/rest-framework", + "depends": ["endpoint_route_handler"], + "data": [ + "security/res_groups.xml", + "security/fastapi_app.xml", + "views/fastapi_menu.xml", + "views/fastapi_app.xml", + ], + "demo": ["demo/fastapi_app_demo.xml"], + "external_dependencies": { + "python": ["fastapi", "python-multipart", "ujson", "a2wsgi"] + }, +} diff --git a/fastapi/demo/fastapi_app_demo.xml b/fastapi/demo/fastapi_app_demo.xml new file mode 100644 index 000000000..b7bd8aaeb --- /dev/null +++ b/fastapi/demo/fastapi_app_demo.xml @@ -0,0 +1,53 @@ + + + + + Fastapi Demo App + List[APIRouter]: + if self.app == "demo": + return [demo_api_router] + return super()._get_fastapi_routers() + + +demo_api_router = APIRouter() + + +@demo_api_router.get("/") +async def read_root(): + return {"Hello": "World"} + +``` + +]]> + demo + /fastapi_demo + + diff --git a/fastapi/depends.py b/fastapi/depends.py new file mode 100644 index 000000000..41999b339 --- /dev/null +++ b/fastapi/depends.py @@ -0,0 +1,8 @@ +from odoo.api import Environment +from odoo.http import request + + +def odoo_env() -> Environment: + # TODO the env must be retreaved from contextvar to ensure that + # it's properly shared with the thread runner in aswgi + yield request.env diff --git a/fastapi/fastapi_dispatcher.py b/fastapi/fastapi_dispatcher.py new file mode 100644 index 000000000..6bbbcb3ac --- /dev/null +++ b/fastapi/fastapi_dispatcher.py @@ -0,0 +1,40 @@ +from io import BytesIO + +from odoo.http import Dispatcher, request + +middelware = None + + +class FastApiDispatcher(Dispatcher): + routing_type = "fastapi" + + @classmethod + def is_compatible_with(cls, request): + return True + + def dispatch(self, endpoint, args): + # don't parse the httprequest let starlette parse the stream + self.request.params = {} # dict(self.request.get_http_params(), **args) + environ = self._get_environ() + root_path = "/" + environ["PATH_INFO"].split("/")[1] + # TODO store the env into contextvar to be used by the odoo_env + # depends method + app = request.env["fastapi.app"].sudo().get_app(root_path) + data = BytesIO() + for r in app(environ, self._make_response): + data.write(r) + return self.request.make_response( + data.getvalue(), headers=self.headers, status=self.status + ) + + def handle_error(self, exc): + pass + + def _make_response(self, status_mapping, headers_tuple, content): + self.status = status_mapping[:3] + self.headers = dict(headers_tuple) + + def _get_environ(self): + environ = self.request.httprequest.environ + environ["wsgi.input"] = self.request.httprequest._get_stream_for_parsing() + return environ diff --git a/fastapi/models/__init__.py b/fastapi/models/__init__.py new file mode 100644 index 000000000..ced2219a6 --- /dev/null +++ b/fastapi/models/__init__.py @@ -0,0 +1 @@ +from . import fastapi_app, fastapi_app_demo diff --git a/fastapi/models/fastapi_app.py b/fastapi/models/fastapi_app.py new file mode 100644 index 000000000..ce4fd50ff --- /dev/null +++ b/fastapi/models/fastapi_app.py @@ -0,0 +1,192 @@ +# Copyright 2022 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + +import logging +from typing import Any, Dict, List + +from a2wsgi import ASGIMiddleware + +from odoo import _, api, exceptions, fields, models, tools + +from odoo.addons.endpoint_route_handler.registry import EndpointRegistry + +from fastapi import APIRouter, FastAPI + +_logger = logging.getLogger(__name__) + + +class FastapiEndpoint(models.Model): + + _name = "fastapi.app" + _description = "Fastapi Application" + + name: str = fields.Char(required=True, help="The title of the API.") + description: str = fields.Text( + help="A short description of the API. It can use Markdown" + ) + root_path: str = fields.Char( + required=True, + index=True, + compute="_compute_root_path", + inverse="_inverse_root_path", + readonly=False, + store=True, + copy=False, + ) + app: str = fields.Selection(selection=[], required=True) + docs_url: str = fields.Char(compute="_compute_urls") + redoc_url: str = fields.Char(compute="_compute_urls") + openapi_url: str = fields.Char(compute="_compute_urls") + + active: bool = fields.Boolean(default=True) + + @api.depends("root_path") + def _compute_root_path(self): + for rec in self: + rec.root_path = rec._clean_root_path() + + def _inverse_root_path(self): + for rec in self: + rec.root_path = rec._clean_root_path() + + def _clean_root_path(self): + root_path = (self.root_path or "").strip() + if not root_path.startswith("/"): + root_path = "/" + root_path + return root_path + + _blacklist_root_paths = {"/", "/web", "/website"} + + @api.constrains("root_path") + def _check_root_path(self): + for rec in self: + if rec.root_path in self._blacklist_root_paths: + raise exceptions.UserError( + _( + "`%(name)s` uses a blacklisted root_path = `%(root_path)s`", + name=rec.name, + root_path=rec.root_path, + ) + ) + + @api.depends("root_path") + def _compute_urls(self): + for rec in self: + rec.docs_url = f"{rec.root_path}/docs" + rec.redoc_url = f"{rec.root_path}/redoc" + rec.openapi_url = f"{rec.root_path}/openapi.json" + + @api.model_create_multi + def create(self, vals_list): + rec = super().create(vals_list) + if rec.active: + rec._register_endpoints() + return rec + + def write(self, vals): + res = super().write(vals) + self._handle_route_updates(vals) + return res + + def _handle_route_updates(self, vals): + if "active" in vals: + if vals["active"]: + self._register_endpoints() + else: + self._unregister_endpoints() + return True + if any([x in vals for x in self._routing_fields()]): + self._register_endpoints() + return True + return False + + def unlink(self): + self._unregister_endpoints() + return super().unlink() + + def _routing_fields(self): + return ["root_path"] + + @property + def _endpoint_registry(self) -> EndpointRegistry: + return EndpointRegistry.registry_for(self.env.cr.dbname) + + def _register_hook(self): + self.search([("active", "=", True)])._register_endpoints(init=True) + + def _register_endpoints(self, init: bool = False): + for rec in self: + for rule in rec._make_routing_rule(): + rec._endpoint_registry.add_or_update_rule(rule, init=init) + + def _make_routing_rule(self): + """Generator of rule for every route ino the routing info""" + self.ensure_one() + routing = self._get_routing_info() + for route in routing["routes"]: + key = self._endpoint_registry_route_unique_key(route) + endpoint_hash = hash(route) + + def endpoint(self): + """Dummy method only used to register a route with type='fastapi'""" + + endpoint.routing = routing + rule = self._endpoint_registry.make_rule( + key, route, endpoint, routing, endpoint_hash + ) + yield rule + + def _get_routing_info(self): + self.ensure_one() + return { + "type": "fastapi", + "auth": "public", + "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + "routes": [ + f"{self.root_path}/", + f"{self.root_path}/", + ], + # csrf ????? + } + + def _endpoint_registry_route_unique_key(self, route: str): + path = route.replace(self.root_path, "") + return f"{self._name}:{self.id}:{path}" + + def _unregister_endpoints(self): + for rec in self: + for route in rec._get_routing_info()["routes"]: + key = rec._endpoint_registry_route_unique_key(route) + rec._endpoint_registry.drop_rule(key) + + @api.model + @tools.ormcache("root_path") + # TODO cache on thread local by db to enable to get 1 middelware by + # thread when odoo runs in multi threads mode + def get_app(self, root_path): + record = self.search([("root_path", "=", root_path)]) + if not record: + return None + return ASGIMiddleware(record._get_app()) + + def _get_app(self) -> ASGIMiddleware: + app = FastAPI(**self._prepare_fastapi_app_params()) + for router in self._get_fastapi_routers(): + app.include_router(prefix=self.root_path, router=router) + return app + + def _prepare_fastapi_app_params(self) -> Dict[str, Any]: + return { + "title": self.name, + "description": self.description, + "openapi_url": self.openapi_url, + "docs_url": self.docs_url, + "redoc_url": self.redoc_url, + } + + def _get_fastapi_routers(self) -> List[APIRouter]: + """Return the api routers to use for the innstance. + + This methoud must be implemented when registering a new api type. + """ + return [] diff --git a/fastapi/models/fastapi_app_demo.py b/fastapi/models/fastapi_app_demo.py new file mode 100644 index 000000000..9baef4984 --- /dev/null +++ b/fastapi/models/fastapi_app_demo.py @@ -0,0 +1,41 @@ +# Copyright 2022 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + +from typing import List + +from odoo import fields, models +from odoo.api import Environment + +from fastapi import APIRouter, Depends + +from ..depends import odoo_env + + +class FastapiEndpoint(models.Model): + + _inherit = "fastapi.app" + + app: str = fields.Selection( + selection_add=[("demo", "Demo App")], ondelete={"demo": "cascade"} + ) + + def _get_fastapi_routers(self) -> List[APIRouter]: + if self.app == "demo": + return [demo_api_router] + return super()._get_fastapi_routers() + + +demo_api_router = APIRouter() + + +@demo_api_router.get("/") +async def hello_word(): + """Hello World!""" + return {"Hello": "World"} + + +@demo_api_router.get("/contacts") +async def count_partners(env: Environment = Depends(odoo_env)): # noqa: B008 + """Returns the number of contacts into the database""" + count = env["res.partner"].sudo().search_count([]) + return {"count": count} diff --git a/fastapi/readme/CONTRIBUTORS.rst b/fastapi/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..172b2d223 --- /dev/null +++ b/fastapi/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Laurent Mignon diff --git a/fastapi/readme/DESCRIPTION.rst b/fastapi/readme/DESCRIPTION.rst new file mode 100644 index 000000000..0bd29b5cf --- /dev/null +++ b/fastapi/readme/DESCRIPTION.rst @@ -0,0 +1,8 @@ +This addon provides the basis smoothly integrate the `FastAPI`_ +framework into Odoo. + +This integration allows you to use all the goodies from `FastAPI` to build custom +APIs for your Odoo server based on standard Python type hints. + + +.. _FastAPI: https://fastapi.tiangolo.com/ diff --git a/fastapi/readme/ROADMAP.rst b/fastapi/readme/ROADMAP.rst new file mode 100644 index 000000000..97adf4165 --- /dev/null +++ b/fastapi/readme/ROADMAP.rst @@ -0,0 +1,3 @@ +The `roadmap `_ +and `known issues `_ can +be found on GitHub. diff --git a/fastapi/security/fastapi_app.xml b/fastapi/security/fastapi_app.xml new file mode 100644 index 000000000..333dba9f1 --- /dev/null +++ b/fastapi/security/fastapi_app.xml @@ -0,0 +1,25 @@ + + + + + + fastapi.app view + + + + + + + + + + fastapi.app manage + + + + + + + + diff --git a/fastapi/security/res_groups.xml b/fastapi/security/res_groups.xml new file mode 100644 index 000000000..f707dbadf --- /dev/null +++ b/fastapi/security/res_groups.xml @@ -0,0 +1,26 @@ + + + + + FastAPI + Helps you manage your Fastapi Applications + 99 + + + + User + + + + + + Administrator + + + + + diff --git a/fastapi/static/description/icon.png b/fastapi/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e09b3918c29d2347d658f0386d1e3d6a34bc009f GIT binary patch literal 36542 zcmV(#K;*xPP)%qhP)=ggV;=E8`dKYsIH zAF_JehI{O@e#8Cd=2jiC``WdKZJV3hJ%nw=({o{NF8^rjwrwGP8b2)ChA_7(eqMiE zQG{K_j-hGMeb}~bYxXIOwJkO(+tI`EKR8~bX>Jw$UNua^uua|haUHB)k}PLg>Ltu5)m7(y$V5!g1_X6px@DXpJdUw!qBH~cWt_TB5Z zY<=JEYc_r8Nx$_Q-)(lpUv$hhgg?3P!sBkb=iVRMxNhAMYH!Ts=7Vu2tD=#s@VR|O z2+y44)=?G)00N=LsK$8EFrcN2ZYUKs#yJ<+m{NWoWeA{Ea@|e{jZ)eq!)80~S!ND+_Xjk^LEHjZ3 zA9|ogeUpsSPyCeEt+wG3cB}@Y!{%&ku@m9GzhJO-GQgA|lFb!60)^j_bb18X0ce$~ zT>~gD{31|Czd4OJz(;_y&t7%<3AcF7rVVQ!Ee~c$3V&>=nG9`(00Q0A{W#@rk!e0} zD1hxhqtz1=at~IjxCSDjJv8DuH5696H^4hrczu=x>%HKpV8F)nBjGnd3w49MIB;WJ zQ0BqxYTWg-tj3VWCUQH*MkGXqV}-SIR2@+7l#x^WPaxpvF-ikhbDnB$%p@zX z)>(AZUs$s$oM2Q}%s}Of51N$$2Sejyo`Zl_!)lY@fL0Pf)wH^ai6C(3ilD4h zD8cDL6IB2!V48dhidASM3c-dWf9lGbSq~YNe1xQA?B_%l5g;~Ug9QZ|wihuOWtI_<{F;G{8nY~(Bm^Fk z@z>e`11|HBIV;Mf4-yafISoI-uItz-s?=AHI_il3ai4<@x~Alb{{4hEzj@D%n|8l= z?dnzgl}ZkcF1zZgaAUOGUtG7@1~DYLas}ZIFr2cpwH*T=M1c?xa>9wx^nf}ciNiE? zYe}r!*#w*MH3%G5SRx-mOZ(yvMM65J%v0?+b4cWIorP5xLzDtRi2@9eIA8paRSN1r z9|77U^Mq%Wym)NVE@5;vAol64q!2O*9Z8wB(`?>AC&@zk7vU36CA>@up9CfIEW~KW zrlIKjoT2bp?%8lFVFoUac8MWDs+^R+`T&n4fM6ar9bt-&{}u_QA(OePz(n@IC;NZ` z2P_<`d2H^<63!11uardOhxs%Az07s83aHJfetG;Lf;CYi0~hKedZ9F^^IzD!Oj>k- zSMohJt_y3TN4VdnO{nYT{BGE?^(TkiWZz>Rc+yE%DGTM7JD+{_n*VXj+k9*7>bb+L zr}F(Pt_(lE^2%`8HP?h2ZzKa3w;Sn)za=aXrU=hLzu^DMJTVK6u_(!v+Y3!Pm?l+8 zQ)G?C6;@=J*$OB?Qq7daObV~O$J_@^DieU!ghUYLK4e^L4cNR;GExrl)7WrSag6dx zN1{z2SkcRJ^(ioc^bT4lk1*#SF^HyU4HJnI%|ye`=r3#-dq?@uj{=CSlt?GbMg(i- zQ6_k0XjQ}lTFhqfV9m`RKM z89h!>Q}&RsK{t6aw3O*4>qMKCSxd?TWg5p?vpRy-#!X@WJ@yC(@3UvN7;fFV?du2L z@%AT4c&MCv{NKL)1AA@Ua8E(HOMm&x@QurU8ojFMkrj$6(jf1Ne*{$0C)sbbiWd-F zv=!Esp&W`y63)dl*st5|R%Hg-S%PczWU2?7dW?%F=FTrGz)PuASBPH0Cp8Czy`$d_CO z)KoYxyR8MIgf-%+m)$eLC7>F$jPq*jtdfw7ekWRVVOEzYrz;2UnwPq$$yYWXS1v-* z21$_|lsup_Ay2jZVZahy$rb`6vf#QZI*F>Z3ef``M%h8X5P)0bpr?f_b>x`{Y~L_> zwGHS30i9^wZ?}!%sQvbf|3UH^Zfg4MIua+w+f zu=uu?4w9g8`{hgS8~131GNo6|zM z$7hf;ob$|ejH0M~hcZe#3Dr|c24E(}*fxxP^iB2)N5%i4RN~)jSFb)6b`O!3C%xmH z-`luu{h{#tmwx!e@V(2g@VC8-VTA=2Eg`Yq6fcYoiFlE>I}Kqi?%`<|BBIl{+Q`xa z!z7-(VY!uRNk~al_Q-O5NllIZ@xsuU34bgrYF;vyJyUH-TP7J0Pb$xkTYKRU$R&p4 z&IwL~Km{M+SzCUgWLQgRc?c|5*_=qhu<(HtpVkf49eG0Wg^O2!Q}41w8Rs z?_9f(>Gl>ZpoqP#L{J*TdY_iRItw4SD94D#&Qso+lt=TsrRSN+z|N8f;d!tnDJzAq zUfq?qgO~8c=cYt27U*ebx&S~CZ&kBMw7cHF$kWQis3nYX$Xri)UUX%G1wxVKFjg0F)axU_OkC@N6=UQUVoPX=a`R}fx{ zbaJb0hiSP$NHKF*(4JVKtOB>DjG~sDmSe#5oGtom>INAM8=R$963(hqDBy;5uIV`}^vjqei=8>c~SiVRhmkkvbV0e&pEnBQS z)qw`CIY6xU~E{{fhczw!F(e{b$_Z+rVk zc3ZpdxBu~L-v~Esz0r!Cs9*!Cw7FJq93*qAW#FQ1T6v7ZHU{t*Ezg5@Q=_)o8Ojty ztU;#O0Am@@?Cxp3kG}}vD74GeT0;^cvZL25Faq*4*JS%MXn|8;Ey{*u&ZuJf3MJG~ zwK`GXqP&|I!*M!uM$qEXN@L)^6*jFZ7>sKr(~$$%H|0vKXoMmFWmaJ(f-MbY)j3hX z)5^D2RO{NgO94Drmz^;sMIhkr0*3W+c%nQ(!c`w>$Q!I>LmwA>5jkS`gDlN|M2#3c zv55iy0{@8TnL8@|`W~e`Z*$QSiNL>W*RBqCI`T-UQ+w0g6W;druYc#~S04NApIs(? zKt`<0g`C>{PX;b*#wPjgW%$WZPH`)jcGyR@kPHpYmmOH zV+cI97D&s4bcc-W#4{CWDo2XIi__GG)#1{_L~bb3#Jz-LBty$ov31wV0+TR2lfjFH>ulJaD&VMnu;k z16rOT33yZqejT_3T>c^sfZIAIzf`Fk^Ot5;m|g|s@-(%>+9>6ljEP^Ucc&Z!`{3@q z(-Z)gN%)%MnV>_-b+H#wmiC-8+EEP~;BWL(G#6V6a%PXwxLIEY zwC;A?F+ZPs#GBr-?Q`GzK9~4+wy`qBD&f3PCFO^qrdjg9jPC1uHh_jzYK1&2!(U-J zip2Cyl*50aPKy-@UPwSl*|}SC85~16PoKB*e~-`JJVqx9S$xSdMf^~iqH%tZXn!8jiXWcxmz84P?-CjSG{K2_pi9z zcr623FQjF2rDG z9J5fgy;?Rdp|j-`vjOGdA)+!0GxM+u=H|ISLlmn%k)G(s7SJ{MTFLrLp^??8DyRin z&~lzhA~8B?G*mX8logg__P{IsLBnTHEA#qjjJBr(1ET;l@`=xlrAGOc2$Pvm=xXTT z4XkutP|*p;EEfemxzN%lenyxMfPPTiF`T=@OJ2V1XIE1-yZ$u2A#Tx9Tu;!z4ypKq z@14{AX}L!T_6lX4a_}wY}>j=s!;49(BkoWpe0O(!>n~tYnqXQk1S^$ zzWa*EIW1P6mQ%;rj03kyFt7_YOi~4hkIdFAgctrJ-`S|EScvDG@h<1PDrWpxlEP$4 zDfFZ#{DismmT0T{CpKvYCarCp6dEkN%?#MA7ICO{x`4dx9I}WX@L9%7=+Z8X1Gziu z6c|Q~5)&>D6ALGB;pXPKU3GYhz){!}KkRv?Zm}rR2jG@+yhsxh<9W`OW|`Q&8#aWw zTRi`NZ@Y1e@v1~9RoOs3MPiUex#D~KC+KnsTAl>OPUILyw||k66NmGm<|^&r90)Dl zYvg0YwMLa-zy+HmWou1@l!%i2@C#dCgH7sP+9a&BW$D>)H0e;D3`WIhiGus-l|115 zmH$k$v3=IPO!fkHf}4nSjr+8O4P@Z<)Lc$9i9k(Id}(8xZJv%w#4{n8Sb%PF2Wksd zqZ6{2DW-!a=aC=Wr4f!is`7xL0Z>f2O)FIa+)0Ze9YeDaou^Hb`sspIDx}(TK6jdS zl7m%A__Wk*#ZM>HMAl|C64V=s+SghbpNfU&@XX=d@y~lc%@bEha*Od%q|}Auq*=&d zoN50jNrs7w&2c5MEVf-xC^=$yBD0*V(w?IPP98+dx-KIU`DynBdB9u#+0^(Ci-*~+ zPsIfqAHqYo5L4QVLd%NUVhhmEZIYl^c4llBAcYnD@8Dv?o`?WcZVe>5Y=xD6jmoru zQPlyfz$LKHC!Hy=!G=s`o|1Fhe$@i$K(>k){xtKC%#3>dNe{!brG633>|Nw4sWeo; zWUlHWK2{Hs3J4E)_d63ad}r~8e@~!QDJ*$Hb|{c10*z;`)M26l2;zr9KRlNoJ~#Da zYI`^roRLFR(zUtc&u75WrUrme`1K`cWshx-q-Y7m+Jez;=$Tx|j{PQq8|BsZWzGzM zDt_4t)5;w<7qnzb)+y~MlqedOJZQ}C$|O_3awAC03;u-_$0ns|zGA8LYg;C)fzXcy zKSPqLj4C{8U%kG>&uqC1HA($v2IWmz^}(81YFL7nI373-E|wDF<(*4!I6pTL&yy*< zQG8C>$XsYF#uCPm!3uAPsFc)558G~h=rQt;35tD`c2n>&g`>$*6ygd{m$4O0GUGzU zsUs%=tp+bDIom!P9zT+W$ezpI11)L+d|n+rtOBs`OX^L*AYJ`fYn^3)zp~^CnEGcU zgAZB~6k0rEsYsy-WmY8`Xc#EJwo55EFT^x?hOgu@?Yf)Sq7y&o;E@HG>50yTde+s} zZN#fu0b0~WQTgpE3sW!iYprM^7F}RKYfGk+2Dj3F>6lGJ)0qZ2##6CRFvFAx2np(a z6RNqhfVL8PdX!UeP~?|{J@H?6v&M9kALB99bHBA-vwU?DB~_A35HYD6SrV6xcK_%rE zXMM4C$TAVANuhdcaWjkOa(_CZ8N{>fIF3B5rbOhT`>H>cXPk{`w0o3qa zOa5Ts@$Rb(kW0{N%p>Iud2zwvMIWRX!7_-C-KgH=^fni3Z(95rs;*rGRAmrV^B>TU zDql?xc!_xY+6{zknaW;4m-)(N zBzBk{{(vkH>-x|$NSXcbIz6$VudR(T1h*(kgflQfnj#m%!z`8;h!qYyqefRM@5 z*Lo%Wt8LlPCFCG)6GYpKZk*i&^D7X=PkDVR)SXoNZbd~?ua57*9A zCT1RIQ77*ihpziaRtgAsJ2`C8wlW&iT7CTCj3s3@Nd<^me!>js%R7$ykLP1Te{~EK58RXeGdsj!Jxp zkDm~8SR$DE0X9@!#@O5&-VnAH;g9sRN+tFWG$)wJcBWdAW;*ix1QS+*B4dqv=1w%7 zK~89`;9VqQKqZAZwE@W|R9q@d^Cv@JQ{2(=65%^aFr&So49G4G;5H41>++fQZr(LAv3js)&vB! zfKH@?h$fv8l+1NrZCmf2xNh?p$YGgBfm{M@E! zcE(cy!AfQVJVvkuOY?=BcFI|yO{|kZq8R3W3A*=s3Yv!eAkI1TAv9sb*hrhLL6SvR< zOCFZ6kUIBdC}+9kdnBY$RZeusKu~Vr_L`BKn8+sVzPea65(J+TmzCm_A9yQD0~FoD zOJG=nEE0j^C8${%2C8v4><&4@?sAou;CWQa34K|_wR+_pZ<1pW%09N50wj?{HikAI zcxW+{j^R@iCX~@a-trWz>QF{!Z- z<8uly#LQAmZd!xz!2L-;QoK0_cMoX-O^;Oo!wS|+yVOk_OYj(5ws@qDv%e zy;5Jv*)-XJnk5gyQ#ldfffj&O?8hzC6(z@U{Ihms?(JGCm0Fo!DfrUf2^K?dNrnS| zF<=>xo3}v1f)<&Ecgq8Vtx>%OUS-OOW2&LVui%b&u|E!*JdjDDY&yoHCRAmqIW~pQfW5&S_+JD_XXd4CF;fw+2vzfkj6OLKWZDZP7a^8?NCB7?)9tW$ zAiMsxxqCs#B!hnoQz7C<6?TdQr3&WWe#;w^!a0%+ANP z^FVThmts66QvoOdOjN^?=4Dm#pyb6IHAn@WijR4_sMs4nC!pzX!Z1h z@^jAFL*}QHEf8I4-pRIYF4HgtJX>BR+*&l2o3#hQ<{{l9Imv$zxs+!F2PCmo*fdq86&b6r+g03cPISxG*TzXibkFt_vT_Mxdv@=G%>`|=ptn{n>P)U%ZbAg$0+1~8a1=wtXRkhS z>E=1srh$a0(F}UB#xPsFSJ~U?GwsBd7F-b-^mZn+^?ZpzG86x8&e(dCz5XfOVq!8N z6&4hlD{bI0Yt(k~vT!IE6oSGbv4iXfr-L$P88~dw@Q@WStGjdfn6QL8qx42Hasu8W z5yd!=z3rSSWtO++?unAs$-Rz;?a+OUb;|%2uehQVhJ$uyZbU z-~@D?wjyjb@c4mXO3U8eV>Au(G2xKv9@3thF8t-zX<@g9e&k)>;DnN1ofc^m++@?g zPfJ-Ub7mthu>g0)@V3v=k2JK4(Uq*S_JRh)d7Yx2!kUKteu1&_(mXH5$_Z@wZM3F>_T2001BWNkl5u%K5@{re<@mhCXDfVuMhtfyu)&xt9$V$8yTy=2wumM6a^cc`m;LN2;^Q>t{ zL!(51^TR^tl~SwITTM9_8`?btKEg2`-s^ILR$gR)W-(Il!dO<|Y{xEB;Y6tCMP*8l ztZ(sw`stS=lx@-+Y;9Zx3__wYMwF8(2siKyh=EQRDEdmo=0XxcwL8Mv0;bL5XHOY0 z?7apI*`{DxSW;`jP!f3(j{ez-c$}l0yZ|D3$GDm$hhW5j;<{UqG3P(ia5Q-Zrc$?+ z{v;5^3{PFe{z_fqhX}B8;o?^=V97&l_?f~2KRiQ3MK#-<1E3|!(!L4Q7!i+|8B-Iq z0Dw4=S}Jpg?)u0PD2ROkq)2;a@FQR05Ik3H%>u866M9U)w%F5akDw)Se1m@ZI2D(w zs?=SwV%Zj@;k$s8bxB8`T<{j%F@{;{4-w^f6*>m}(hfmc#=Se@Q6G!N2*K9M&aot` z`0vkA(XVODl&xW9ut4j`*cm=x0^}`t-}+fOQ*keP z)}D-bJV|h>1#|93!$zCqYxUzPr!^v*N2%jcHg1|5Qfq)b5#PvoW-_)95&;5Cwq!yu zMZd#_IjE_DaroA&wol1)8WU&1r1TGy)3|BrOB4YwnvsVXbSV$ekJv?ePISxa7~7a( z6?TUoOVLBxg|+;%C@<=#=Y_4jS*`2jIWwoY{i%Q%^xlL#%{ykfxYz=3YXGZq3_%sX zlfZLvOWF%Zdc~zZWcE5oChJ4-QiXw zohEBA)!623V`IW&0WDFc27A(Nsjc^kMEc7E+SDg0fz}PV$rCkAN`m)53&Byes4k!s z`rW*k?)VTzKKha{XtSjTEtQK!C{}g+E-82u+^Oo?K9smlk8>whDExc-5&&u-x`Dql zD#$F_i=qMJLIL2}vfk0|A!w4`Sr}3X2B%kKUV@PMqLF~kfPK}cv(O-cE@GhWS({RJ z@?gSiyDd~gc_fZ&V+|?+VbfMi)c!+`~&82xr@v3oOGNbTTDkOtidH#*4 z3GHG*)0W(lVWI=YWdpv-8pD?cb}i}vBTkpg60A31$4NOcqF(Wh8Rpf}Sw6LLS_;|) zAH`0Z;UQ81o{{yma?bc6agj$-(6td@e!p41dk6zF&=i75xmN~g(|0Q`o5HyqJn&Im z@XF;i{=EwNFFz+@(gH|rs1?}|evclOJnPk=m`Ro10*&dSTpBz%=V(1uh~zR2KI}sx zGQ@^_y|?iQ@BDQ%QNVn#D>}`NprQ&{umBJ8b}Sd^3IB%w$k8D}BO`xoDJzxjx?A$V z!_Xb^xYWsV^ZW6EC7(299=x zmEW5=EhbkyZ_gBx0ns#YcuNB{@MRRgNxQl^8xn2uIrB*d;N-S%p7abk>%6OG3|!?? zD3}^?Q=eHciLEqW^|s?2o3{wjbJBmM*P5uOPDM)uWL&;im%(!@XZKM0t8|;^fV5S2 zy7SW0f{P5a1gJaMLQ148AcbwTFB#Cq7g5p@v>g0QHUknY6ty-(Rw84d(t#5iz*qIL zBN{|@8&#F#O_Ht2+lXd3Jmm1o3DxuIq?O4l2T{J**D%Fp9iuo4~6awJn6T8mEdt&Gs6o}mGg4iMQKRTxEcJqb|R_)NQ;LYNlEw^+}A zAa8UcEx;2l)yClhwdgvyTF1#|rmxk4=(_-4&c?&Z+B$rkUSu&;T zTo4H+hQWaP$LXlMZpSQ)K@GeJi%9ku24z~oyeR3z5mY~@FOQ{(5(`=mN*j_jcM;PQ zm2L|U&3LMu=sgYaBe5PE_Hr+zyKTc{rpu;Ql7r9y2Ehj4@{f6_*X}kLs5wv(4EC!C z&;)0+^HxQ?@Y~fH`m&wVVjhb}j3E#+)&hc65u-1ptG*Xapv8GfXlQGrTe=9~%nMYu zzzU1lyH^>Yo*UU)-IHlliz`13|5T0uO6F%zO8_^xsYGX>;|NfUiEn8?XCX?iu#p7!;9p`fbgdY}za7jWPjT}nBX zp|kMZVSdw2SR@3p1(VbmTE4UK&Lt``(<&4AyzV8SE7>FVkD>_y8M$`txSR{cv$^ip zdHH~K$%l3iTPvYMgj=2<+kC7p$b4Q>y7);1|+B+O^`Gi`95xEZ`yAe_cXh38c;? z5%2NOex^A^*cG;luJUFNLJW^Tw_E#2qRTrXSfDVH9+8xWxOT>RphjEZX44C^^D2==zC*7gm!1MThjMD4tZ~Hd zJcpWjpH3)S%h4>-JiI#(u>lbkcce7pA@tJ8lsWkt4CCM&j?>#3C}zx=&)tF)UF~Mm zWA3O-cx-EjuF;5UkI8Ax2re)l($fMh!|(>ME6Msj@+Y+>`*93EHmN*A5gLStGB%9R z3r_{#fS*pdmUv1XO3`79?<6hqEb!mh&?;EnJOs~ug_{b zArit~0j;7F8o~rTgm1O;>=;`%7oDWT8T^jNFzTL>;`PsMn}Dce`T<*(^Kpd$d;tNm znOj+jeii&mpe2&F4G2Bp6=KLi851uJmK+y-n32PZf{}yjfJlaIjJ@gfoaiDeOGYO@ zTiCN|8q34Fmhv6y0s&Uir|b?Tp&nL1#;i&)Wr9)%V-Vg8WklfwnMH->u(%X7*BOBy z8nu-#Q&A;j!jtV)0}QQp^gv-g)&>jaBwH*rk@b%t&VT{A8%YTETkZ`08Qd@s!yf_e zI%kL9svurJwh|F_on>aGO3#bJaOmLipFc~wAX%l_60O+_*b2)?$2+^2@HK|uo)U9_ zD|5A=Wd`c$NYYDBc?A$;PRt!{+T5hz@2P4=W}o^;eLznu=aq|n1z_MClqtZjdC~#@ zB%g((y8n(&P&VlI!SU4$~KzS}nL8ejikQNhDTCc#7*`XPQ*EL#R zQ)p1Zi4k;cTb||fnwOx6a08%QfC7HbvmOJ6q8#~}$)Yd3#Fo(uuYAy|4K*EPrEz-U zAohMfU(S_@dlN?xI@_2|%Ic))#cJ<33rCt!= zm&*dTVuhhFJs{E?bH2J{=(=5KFNKqFZ(8}_0j*-~%1l0$z zhsJ_u7fsSe{z348srZWq?T(g|r@3d}B*UqJ1BOr!tC&<-p{wivU|xt<1O@Mg)i6+q z7E74n?i?k);BP})=Q0(Pql8>wERl8(v;?&V6$d*eF@bStOl7UJFe2J#1f^0A5_$Mc zjuhq2i#AKc(#kY&MiNGniuhvGN_A~ctJZ5;OtMo2IC-Eyc|$lASIL<%@gJ+~y zXI+v}k(%iO29kb#Ry{ZTn`*pna!LnaEiBJ15mF(d7 ztpZ^7w7TUK25d#{Y#;3fGv%1bhA9uKJrz7jqytm%%hCxm?1o!6hlYo{LX$1^8mn7V{mk&#l+s(LnOy~HVTNordG<%hTqg{ zvnqM3U17V_#4@p!TgR_xjw@!3&r#RlsuhKbsDMXWlh_uUcCy9BG}S{yp8x0a3Ie*LVdj z9xdq%+fCCSBJ%CxD~p#<-IKGg0{&dG$hNhB;=)0_dPvZ+O|AI1(qQ7m`?9nq{e@jZ z04)3CA`ow}=Heuwp<()BM^7u$io{;md{~l7ENQrpaK@&3C*oxsrBIj;A7HH$8$_gd2SY#QbuA})dmF^ zKIj3CMFaMXS&X6Sy^CzEWjAwLMzs|mBLaI;mQ(x@W$n9ZUu{v=7#6{D&9v zf(&RydRYT*;Thdt9n=2-VP5L_rG^StGOL!ou1L18!IOB|W42GU0(884l6vnyF%h;t3*Hv)AI98Ny z4hd+6n1+&I^$!nT-EfKX76H>^#Jd75Eb-8$b#tlU!oV>YGPJ@#4a@1PQn);=*iH^L zW1y{;%JeJsX7`Yv!EWJq1n0UCQl_cEQmaAQBoMITJ~ixZB%gFP&S7#L*9i|b@+m|j zl7oI#_R?m=dk;|t$uCvrC1CdXpMgE|M@0ZYXXw;u<5wV#b${82EP`pCEIWh??2 z!%P8#(!p-5Zb&hpk1@O+X+p^;KEXVAt*H9s@yZelT2YA}yZE@g6{6YTeF2aR>?MHS z{Lo+wC}rxYhLA5Nc|QYX38Y=hXY5}UlW7#s)O3vV7Tw4Bmedoa>VvRdv&vv&nwMvA ztgkw5)j9QBVg(abjGPgsi?j@@nHlP@f*)o`DlScth42bxXs5xQU_MiglF5LkIGuX1 zIwWK}#cmo5_Cf+2s&gk5_Lr@61K}YyH^Q<~bhK80)=5`8tyx^^vd6z$hrNY|$h#U~ zb4imXXXmBFT8phx`M3%x5<+Kk#CE>kCgV{Bccv^Kf!#$~G>=eep9r2^bNHT*33;g% z&jYpIN-cLw8zUc1t#L_{01kWUp4Q|GK%xJtH#8p)JcZ>N!j))-&x!aJE^@W3f}TifR+F?g>MOn8`!3z)k^>y6BZonz4uU{AyWlj9r3-2 ztSjgxH9QccnpLF-qB-zEhon0dwm3N@+pfuLW+cspI?lSs@kr;h@@O+htKS)2%wr0p zeY;5WD+FSB
  • 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 AF#rGn literal 0 HcmV?d00001 diff --git a/fastapi/static/description/index.html b/fastapi/static/description/index.html new file mode 100644 index 000000000..d5efae3b5 --- /dev/null +++ b/fastapi/static/description/index.html @@ -0,0 +1,431 @@ + + + + + + +Odoo FastAPI + + + +
    +

    Odoo FastAPI

    + + +

    Beta License: LGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runbot

    +

    This addon provides the basis smoothly integrate the FastAPI +framework into Odoo.

    +

    This integration allows you to use all the goodies from FastAPI to build custom +APIs for your Odoo server based on standard Python type hints.

    +

    Table of contents

    + +
    +

    Known issues / Roadmap

    +

    The roadmap +and known issues can +be found on GitHub.

    +
    +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • ACSONE SA/NV
    • +
    +
    +
    +

    Contributors

    + +
    +
    +

    Maintainers

    +

    This module is maintained by the OCA.

    +Odoo Community Association +

    OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

    +

    Current maintainer:

    +

    lmignon

    +

    This module is part of the OCA/rest-framework project on GitHub.

    +

    You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

    +
    +
    +
    + + diff --git a/fastapi/tests/__init__.py b/fastapi/tests/__init__.py new file mode 100644 index 000000000..a8f207919 --- /dev/null +++ b/fastapi/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fastapi diff --git a/fastapi/tests/test_fastapi.py b/fastapi/tests/test_fastapi.py new file mode 100644 index 000000000..dde2ce2bb --- /dev/null +++ b/fastapi/tests/test_fastapi.py @@ -0,0 +1,44 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# Copyright 2022 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + +import os +import unittest + +from odoo.tests.common import HttpCase + + +@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "EndpointHttpCase skipped") +class FastAPIHttpCase(HttpCase): + def setUp(self): + super().setUp() + self.fastapi_demo_app = self.env.ref("fastapi.fastapi_app_demo") + + def test_call(self): + route = "/fastapi_demo/" + response = self.url_open(route) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'{"Hello":"World"}') + + def test_update_route(self): + route = "/fastapi_demo/" + response = self.url_open(route) + self.assertEqual(response.status_code, 200) + response = self.url_open("/new_root/") + self.assertEqual(response.status_code, 404) + self.fastapi_demo_app.root_path = "/new_root" + self.env.flush_all() + response = self.url_open(route) + self.assertEqual(response.status_code, 404) + response = self.url_open("/new_root/") + self.assertEqual(response.status_code, 200) + + def test_odoo_env_depends(self): + route = "/fastapi_demo/contacts" + response = self.url_open(route) + self.assertEqual(response.status_code, 200) + count = self.env["res.partner"].sudo().search_count([]) + expected = b'{"count":%d}' % count + self.assertEqual(response.content, expected) diff --git a/fastapi/views/fastapi_app.xml b/fastapi/views/fastapi_app.xml new file mode 100644 index 000000000..e27e7e887 --- /dev/null +++ b/fastapi/views/fastapi_app.xml @@ -0,0 +1,93 @@ + + + + + + fastapi.app.form (in fastapi) + fastapi.app + +
    +
    +
    + + +
    +
    + + + + + + + + + + + +
    +
    +
    +
    + + + fastapi.app.search (in fastapi) + fastapi.app + + + + + + + + + + + + + + fastapi.app.tree (in fastapi) + fastapi.app + + + + + + + + + + + + + + Fastapi App + fastapi.app + tree,form + [] + {} + + + + Fastapi Endpoint + + + + + +
    diff --git a/fastapi/views/fastapi_menu.xml b/fastapi/views/fastapi_menu.xml new file mode 100644 index 000000000..90fa87cf1 --- /dev/null +++ b/fastapi/views/fastapi_menu.xml @@ -0,0 +1,11 @@ + + + + + FastAPI + + fastapi,static/description/icon.png + + + From 04d86c45e1033ae906b555e785ad536ed2d3fb3a Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Tue, 27 Sep 2022 13:51:28 +0200 Subject: [PATCH 002/118] [IMP] fastapi: Uses contextvars to hold and provide odoo env on fastapi service excution --- fastapi/context.py | 10 ++++++++++ fastapi/depends.py | 7 +++---- fastapi/fastapi_dispatcher.py | 22 ++++++++++++++++------ 3 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 fastapi/context.py diff --git a/fastapi/context.py b/fastapi/context.py new file mode 100644 index 000000000..a8de1ef61 --- /dev/null +++ b/fastapi/context.py @@ -0,0 +1,10 @@ +# Copyright 2022 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + +# define context vars to hold the odoo env + +from contextvars import ContextVar + +from odoo.api import Environment + +odoo_env_ctx: ContextVar[Environment] = ContextVar("odoo_env_ctx") diff --git a/fastapi/depends.py b/fastapi/depends.py index 41999b339..9362bfa7a 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -1,8 +1,7 @@ from odoo.api import Environment -from odoo.http import request + +from .context import odoo_env_ctx def odoo_env() -> Environment: - # TODO the env must be retreaved from contextvar to ensure that - # it's properly shared with the thread runner in aswgi - yield request.env + yield odoo_env_ctx.get() diff --git a/fastapi/fastapi_dispatcher.py b/fastapi/fastapi_dispatcher.py index 6bbbcb3ac..ae2089b05 100644 --- a/fastapi/fastapi_dispatcher.py +++ b/fastapi/fastapi_dispatcher.py @@ -1,8 +1,9 @@ +from contextlib import contextmanager from io import BytesIO from odoo.http import Dispatcher, request -middelware = None +from .context import odoo_env_ctx class FastApiDispatcher(Dispatcher): @@ -21,11 +22,12 @@ def dispatch(self, endpoint, args): # depends method app = request.env["fastapi.app"].sudo().get_app(root_path) data = BytesIO() - for r in app(environ, self._make_response): - data.write(r) - return self.request.make_response( - data.getvalue(), headers=self.headers, status=self.status - ) + with self._manage_odoo_env(): + for r in app(environ, self._make_response): + data.write(r) + return self.request.make_response( + data.getvalue(), headers=self.headers, status=self.status + ) def handle_error(self, exc): pass @@ -38,3 +40,11 @@ def _get_environ(self): environ = self.request.httprequest.environ environ["wsgi.input"] = self.request.httprequest._get_stream_for_parsing() return environ + + @contextmanager + def _manage_odoo_env(self): + token = odoo_env_ctx.set(request.env) + try: + yield + finally: + odoo_env_ctx.reset(token) From 429da0201557ce5b04cad0bb5c29186b9f903649 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 28 Sep 2022 08:54:28 +0200 Subject: [PATCH 003/118] [IMP] fastapi: renames fastapi.app into fastapi.endpoint --- fastapi/__manifest__.py | 8 ++--- ...app_demo.xml => fastapi_endpoint_demo.xml} | 10 +++---- fastapi/fastapi_dispatcher.py | 2 +- fastapi/models/__init__.py | 3 +- .../{fastapi_app.py => fastapi_endpoint.py} | 8 ++--- ...i_app_demo.py => fastapi_endpoint_demo.py} | 4 +-- .../{fastapi_app.xml => fastapi_endpoint.xml} | 12 ++++---- fastapi/security/res_groups.xml | 2 +- fastapi/tests/test_fastapi.py | 2 +- .../{fastapi_app.xml => fastapi_endpoint.xml} | 30 +++++++++---------- 10 files changed, 41 insertions(+), 40 deletions(-) rename fastapi/demo/{fastapi_app_demo.xml => fastapi_endpoint_demo.xml} (74%) rename fastapi/models/{fastapi_app.py => fastapi_endpoint.py} (96%) rename fastapi/models/{fastapi_app_demo.py => fastapi_endpoint_demo.py} (88%) rename fastapi/security/{fastapi_app.xml => fastapi_endpoint.xml} (63%) rename fastapi/views/{fastapi_app.xml => fastapi_endpoint.xml} (73%) diff --git a/fastapi/__manifest__.py b/fastapi/__manifest__.py index 52e3b62c8..c15a6c847 100644 --- a/fastapi/__manifest__.py +++ b/fastapi/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Odoo FastAPI", "summary": """ - Odoo FastApi instegration""", + Odoo FastAPI endpoint""", "version": "16.0.0.0.1", "license": "LGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", @@ -13,11 +13,11 @@ "depends": ["endpoint_route_handler"], "data": [ "security/res_groups.xml", - "security/fastapi_app.xml", + "security/fastapi_endpoint.xml", "views/fastapi_menu.xml", - "views/fastapi_app.xml", + "views/fastapi_endpoint.xml", ], - "demo": ["demo/fastapi_app_demo.xml"], + "demo": ["demo/fastapi_endpoint_demo.xml"], "external_dependencies": { "python": ["fastapi", "python-multipart", "ujson", "a2wsgi"] }, diff --git a/fastapi/demo/fastapi_app_demo.xml b/fastapi/demo/fastapi_endpoint_demo.xml similarity index 74% rename from fastapi/demo/fastapi_app_demo.xml rename to fastapi/demo/fastapi_endpoint_demo.xml index b7bd8aaeb..d5b65d755 100644 --- a/fastapi/demo/fastapi_app_demo.xml +++ b/fastapi/demo/fastapi_endpoint_demo.xml @@ -2,8 +2,8 @@ - - Fastapi Demo App + + Fastapi Demo Endpoint List[APIRouter]: diff --git a/fastapi/fastapi_dispatcher.py b/fastapi/fastapi_dispatcher.py index ae2089b05..2963fb6e6 100644 --- a/fastapi/fastapi_dispatcher.py +++ b/fastapi/fastapi_dispatcher.py @@ -20,7 +20,7 @@ def dispatch(self, endpoint, args): root_path = "/" + environ["PATH_INFO"].split("/")[1] # TODO store the env into contextvar to be used by the odoo_env # depends method - app = request.env["fastapi.app"].sudo().get_app(root_path) + app = request.env["fastapi.endpoint"].sudo().get_app(root_path) data = BytesIO() with self._manage_odoo_env(): for r in app(environ, self._make_response): diff --git a/fastapi/models/__init__.py b/fastapi/models/__init__.py index ced2219a6..85a376639 100644 --- a/fastapi/models/__init__.py +++ b/fastapi/models/__init__.py @@ -1 +1,2 @@ -from . import fastapi_app, fastapi_app_demo +from . import fastapi_endpoint +from . import fastapi_endpoint_demo diff --git a/fastapi/models/fastapi_app.py b/fastapi/models/fastapi_endpoint.py similarity index 96% rename from fastapi/models/fastapi_app.py rename to fastapi/models/fastapi_endpoint.py index ce4fd50ff..d31f59a1f 100644 --- a/fastapi/models/fastapi_app.py +++ b/fastapi/models/fastapi_endpoint.py @@ -17,8 +17,8 @@ class FastapiEndpoint(models.Model): - _name = "fastapi.app" - _description = "Fastapi Application" + _name = "fastapi.endpoint" + _description = "FastAPI Endpoint" name: str = fields.Char(required=True, help="The title of the API.") description: str = fields.Text( @@ -170,12 +170,12 @@ def get_app(self, root_path): return ASGIMiddleware(record._get_app()) def _get_app(self) -> ASGIMiddleware: - app = FastAPI(**self._prepare_fastapi_app_params()) + app = FastAPI(**self._prepare_fastapi_endpoint_params()) for router in self._get_fastapi_routers(): app.include_router(prefix=self.root_path, router=router) return app - def _prepare_fastapi_app_params(self) -> Dict[str, Any]: + def _prepare_fastapi_endpoint_params(self) -> Dict[str, Any]: return { "title": self.name, "description": self.description, diff --git a/fastapi/models/fastapi_app_demo.py b/fastapi/models/fastapi_endpoint_demo.py similarity index 88% rename from fastapi/models/fastapi_app_demo.py rename to fastapi/models/fastapi_endpoint_demo.py index 9baef4984..1f85d4a7b 100644 --- a/fastapi/models/fastapi_app_demo.py +++ b/fastapi/models/fastapi_endpoint_demo.py @@ -13,10 +13,10 @@ class FastapiEndpoint(models.Model): - _inherit = "fastapi.app" + _inherit = "fastapi.endpoint" app: str = fields.Selection( - selection_add=[("demo", "Demo App")], ondelete={"demo": "cascade"} + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} ) def _get_fastapi_routers(self) -> List[APIRouter]: diff --git a/fastapi/security/fastapi_app.xml b/fastapi/security/fastapi_endpoint.xml similarity index 63% rename from fastapi/security/fastapi_app.xml rename to fastapi/security/fastapi_endpoint.xml index 333dba9f1..ea94557be 100644 --- a/fastapi/security/fastapi_app.xml +++ b/fastapi/security/fastapi_endpoint.xml @@ -3,9 +3,9 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). --> - - fastapi.app view - + + fastapi.endpoint view + @@ -13,9 +13,9 @@ - - fastapi.app manage - + + fastapi.endpoint manage + diff --git a/fastapi/security/res_groups.xml b/fastapi/security/res_groups.xml index f707dbadf..e117c5f1a 100644 --- a/fastapi/security/res_groups.xml +++ b/fastapi/security/res_groups.xml @@ -4,7 +4,7 @@ FastAPI - Helps you manage your Fastapi Applications + Helps you manage your Fastapi Endpoints 99 diff --git a/fastapi/tests/test_fastapi.py b/fastapi/tests/test_fastapi.py index dde2ce2bb..d6abc8e8e 100644 --- a/fastapi/tests/test_fastapi.py +++ b/fastapi/tests/test_fastapi.py @@ -14,7 +14,7 @@ class FastAPIHttpCase(HttpCase): def setUp(self): super().setUp() - self.fastapi_demo_app = self.env.ref("fastapi.fastapi_app_demo") + self.fastapi_demo_app = self.env.ref("fastapi.fastapi_endpoint_demo") def test_call(self): route = "/fastapi_demo/" diff --git a/fastapi/views/fastapi_app.xml b/fastapi/views/fastapi_endpoint.xml similarity index 73% rename from fastapi/views/fastapi_app.xml rename to fastapi/views/fastapi_endpoint.xml index e27e7e887..62c5db603 100644 --- a/fastapi/views/fastapi_app.xml +++ b/fastapi/views/fastapi_endpoint.xml @@ -3,9 +3,9 @@ License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). --> - - fastapi.app.form (in fastapi) - fastapi.app + + fastapi.endpoint.form (in fastapi) + fastapi.endpoint
    @@ -37,9 +37,9 @@ - - fastapi.app.search (in fastapi) - fastapi.app + + fastapi.endpoint.search (in fastapi) + fastapi.endpoint @@ -60,9 +60,9 @@ - - fastapi.app.tree (in fastapi) - fastapi.app + + fastapi.endpoint.tree (in fastapi) + fastapi.endpoint @@ -75,18 +75,18 @@ - - Fastapi App - fastapi.app + + FastAPI Endpoint + fastapi.endpoint tree,form [] {} - - Fastapi Endpoint + + FastAPI Endpoint - + From f8d8c43bac6febcd52ea1679c85a3152b7540199 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Sat, 8 Oct 2022 18:11:14 +0200 Subject: [PATCH 004/118] [IMP] fastapi: code cleanup --- fastapi/fastapi_dispatcher.py | 3 +++ fastapi/tests/test_fastapi.py | 11 ----------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/fastapi/fastapi_dispatcher.py b/fastapi/fastapi_dispatcher.py index 2963fb6e6..83ecf45e7 100644 --- a/fastapi/fastapi_dispatcher.py +++ b/fastapi/fastapi_dispatcher.py @@ -1,3 +1,6 @@ +# Copyright 2022 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + from contextlib import contextmanager from io import BytesIO diff --git a/fastapi/tests/test_fastapi.py b/fastapi/tests/test_fastapi.py index d6abc8e8e..9117cb8bd 100644 --- a/fastapi/tests/test_fastapi.py +++ b/fastapi/tests/test_fastapi.py @@ -1,6 +1,3 @@ -# Copyright 2021 Camptocamp SA -# @author: Simone Orsi -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). # Copyright 2022 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). @@ -34,11 +31,3 @@ def test_update_route(self): self.assertEqual(response.status_code, 404) response = self.url_open("/new_root/") self.assertEqual(response.status_code, 200) - - def test_odoo_env_depends(self): - route = "/fastapi_demo/contacts" - response = self.url_open(route) - self.assertEqual(response.status_code, 200) - count = self.env["res.partner"].sudo().search_count([]) - expected = b'{"count":%d}' % count - self.assertEqual(response.content, expected) From 634dfd04d0efeea05da40ad5ca221a17aff42f7a Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Sat, 8 Oct 2022 20:10:41 +0200 Subject: [PATCH 005/118] [IMP] fastapi: Improves modularity and demo app * add default empty method to use as dependency to get the authenticated partner * improves the demo app to illustrate the way the dependency overrides mechanism can be used to provide the right implementation to use to retrieve the authenticated partner according to the security method configured on the app * add tests for the demo app to show how the TestClient class and the dependey overrides functianality should be used to easily write tests --- fastapi/__manifest__.py | 1 + fastapi/demo/fastapi_endpoint_demo.xml | 1 + fastapi/depends.py | 57 +++++++++++++++ fastapi/models/fastapi_endpoint.py | 13 +++- fastapi/models/fastapi_endpoint_demo.py | 92 ++++++++++++++++++++++--- fastapi/tests/__init__.py | 1 + fastapi/tests/test_fastapi_demo.py | 56 +++++++++++++++ fastapi/views/fastapi_endpoint.xml | 1 + fastapi/views/fastapi_endpoint_demo.xml | 23 +++++++ 9 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 fastapi/tests/test_fastapi_demo.py create mode 100644 fastapi/views/fastapi_endpoint_demo.xml diff --git a/fastapi/__manifest__.py b/fastapi/__manifest__.py index c15a6c847..69ee62b86 100644 --- a/fastapi/__manifest__.py +++ b/fastapi/__manifest__.py @@ -16,6 +16,7 @@ "security/fastapi_endpoint.xml", "views/fastapi_menu.xml", "views/fastapi_endpoint.xml", + "views/fastapi_endpoint_demo.xml", ], "demo": ["demo/fastapi_endpoint_demo.xml"], "external_dependencies": { diff --git a/fastapi/demo/fastapi_endpoint_demo.xml b/fastapi/demo/fastapi_endpoint_demo.xml index d5b65d755..502083377 100644 --- a/fastapi/demo/fastapi_endpoint_demo.xml +++ b/fastapi/demo/fastapi_endpoint_demo.xml @@ -49,5 +49,6 @@ async def read_root(): ]]> demo /fastapi_demo + http_basic diff --git a/fastapi/depends.py b/fastapi/depends.py index 9362bfa7a..cd3408810 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -1,7 +1,64 @@ +# Copyright 2022 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + from odoo.api import Environment +from odoo.exceptions import AccessDenied + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.base.models.res_users import Users + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials from .context import odoo_env_ctx def odoo_env() -> Environment: yield odoo_env_ctx.get() + + +def authenticated_partner_impl() -> Partner: + """This method has to be overriden when you create your fastapi app + to declare the way your partner will be provided. In some case, this + partner will come from the authentication mechanism (ex jwt token) in other cases + it could comme from a lookup on an email received into an HTTP header ... + See the fastapi_endpoint_demo for an exemple""" + + +def authenticated_partner( + partner: Partner = Depends(authenticated_partner_impl), # noqa: B008 +) -> Partner: + """If you need to get access to the authenticated partner into your + enpoint, you can add a dependency into the endpoint definition on this + method. + This method is a safe way to declare a dependency without requiring a + specific implementation. It depends on `authenticated_partner_id`. The + concrete implementation of authenticated_partner_id has to be provide + when the FastAPI app is created. + """ + return partner + + +def basic_auth_user( + credential: HTTPBasicCredentials = Depends(HTTPBasic()), # noqa: B008 + env: Environment = Depends(odoo_env), # noqa: B008 +) -> Users: + username = credential.username + password = credential.password + try: + uid = env["res.users"].authenticate( + db=env.cr.dbname, login=username, password=password, user_agent_env=None + ) + return env["res.users"].browse(uid) + except AccessDenied as ad: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Basic"}, + ) from ad + + +def authenticated_partner_from_basic_auth_user( + user: Users = Depends(basic_auth_user), # noqa: B008 +) -> Partner: + return user.partner_id diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index d31f59a1f..01f451463 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -97,14 +97,16 @@ def _handle_route_updates(self, vals): return True if any([x in vals for x in self._routing_fields()]): self._register_endpoints() - return True + self._reset_app() return False def unlink(self): self._unregister_endpoints() return super().unlink() + @api.model def _routing_fields(self): + """The list of fields requiring to rebuild the app if modified""" return ["root_path"] @property @@ -159,17 +161,22 @@ def _unregister_endpoints(self): key = rec._endpoint_registry_route_unique_key(route) rec._endpoint_registry.drop_rule(key) + def _reset_app(self): + self.get_app.clear_cache(self) + @api.model @tools.ormcache("root_path") # TODO cache on thread local by db to enable to get 1 middelware by - # thread when odoo runs in multi threads mode + # thread when odoo runs in multi threads mode and to allows invalidate + # specific entries in place og the overall cache as we have to do into + # the _rest_app method def get_app(self, root_path): record = self.search([("root_path", "=", root_path)]) if not record: return None return ASGIMiddleware(record._get_app()) - def _get_app(self) -> ASGIMiddleware: + def _get_app(self) -> FastAPI: app = FastAPI(**self._prepare_fastapi_endpoint_params()) for router in self._get_fastapi_routers(): app.include_router(prefix=self.root_path, router=router) diff --git a/fastapi/models/fastapi_endpoint_demo.py b/fastapi/models/fastapi_endpoint_demo.py index 1f85d4a7b..b1f6f8475 100644 --- a/fastapi/models/fastapi_endpoint_demo.py +++ b/fastapi/models/fastapi_endpoint_demo.py @@ -3,12 +3,22 @@ from typing import List -from odoo import fields, models +from odoo import _, api, fields, models from odoo.api import Environment +from odoo.exceptions import ValidationError -from fastapi import APIRouter, Depends +from odoo.addons.base.models.res_partner import Partner -from ..depends import odoo_env +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import APIKeyHeader +from pydantic import BaseModel + +from ..depends import ( + authenticated_partner, + authenticated_partner_from_basic_auth_user, + authenticated_partner_impl, + odoo_env, +) class FastapiEndpoint(models.Model): @@ -18,12 +28,56 @@ class FastapiEndpoint(models.Model): app: str = fields.Selection( selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} ) + demo_auth_method = fields.Selection( + selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")], + string="Authenciation method", + ) def _get_fastapi_routers(self) -> List[APIRouter]: if self.app == "demo": return [demo_api_router] return super()._get_fastapi_routers() + @api.constrains("app", "demo_auth_method") + def _valdiate_demo_auth_method(self): + for rec in self: + if rec.app == "demo" and not rec.demo_auth_method: + raise ValidationError( + _( + "The authentication method is required for app %(app)s", + app=rec.app, + ) + ) + + @api.model + def _routing_fields(self): + fields = super()._routing_fields() + fields.append("demo_auth_method") + return fields + + def _get_app(self): + app = super()._get_app() + if self.app == "demo": + # Here we add the overrides to the authenticated_partner_impl method + # according to the authentication method configured on the demo app + if self.demo_auth_method == "http_basic": + authenticated_partner_impl_override = ( + authenticated_partner_from_basic_auth_user + ) + else: + authenticated_partner_impl_override = ( + api_key_based_authenticated_partner_impl + ) + app.dependency_overrides[ + authenticated_partner_impl + ] = authenticated_partner_impl_override + return app + + +class UserInfo(BaseModel): + name: str + display_name: str + demo_api_router = APIRouter() @@ -34,8 +88,30 @@ async def hello_word(): return {"Hello": "World"} -@demo_api_router.get("/contacts") -async def count_partners(env: Environment = Depends(odoo_env)): # noqa: B008 - """Returns the number of contacts into the database""" - count = env["res.partner"].sudo().search_count([]) - return {"count": count} +@demo_api_router.get("/who_ami", response_model=UserInfo) +async def who_ami(partner=Depends(authenticated_partner)) -> UserInfo: # noqa: B008 + """Who am I? + + Returns the authenticated partner + """ + return UserInfo(name=partner.name, display_name=partner.display_name) + + +def api_key_based_authenticated_partner_impl( + api_key: str = Depends( # noqa: B008 + APIKeyHeader( + name="api-key", + description="In this demo, you can use a user's login as api key.", + ) + ), + env: Environment = Depends(odoo_env), # noqa: B008 +) -> Partner: + """A dummy implementation that look for a user with the same login + as the provided api key + """ + partner = env["res.users"].search([("login", "=", api_key)], limit=1).partner_id + if not partner: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect API Key" + ) + return partner diff --git a/fastapi/tests/__init__.py b/fastapi/tests/__init__.py index a8f207919..ea40c354c 100644 --- a/fastapi/tests/__init__.py +++ b/fastapi/tests/__init__.py @@ -1 +1,2 @@ from . import test_fastapi +from . import test_fastapi_demo diff --git a/fastapi/tests/test_fastapi_demo.py b/fastapi/tests/test_fastapi_demo.py new file mode 100644 index 000000000..090b1ff07 --- /dev/null +++ b/fastapi/tests/test_fastapi_demo.py @@ -0,0 +1,56 @@ +# Copyright 2022 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + +from functools import partial + +from requests import Response + +from odoo.tests.common import TransactionCase + +from odoo.addons.fastapi import depends + +from fastapi import status +from fastapi.testclient import TestClient + + +class FastAPIDemoCase(TransactionCase): + """The fastapi lib comes with a usefull testclient that let's you + easily test your endpoinds. Moreover, the dependey overrides functionnality + allows you to provide specific implementation for part of the code to avoid + to rely on some tricky http stuff for example: authentication + + This test class is an example on how you can test your own code + """ + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.test_partner = cls.env["res.partner"].create({"name": "FastAPI Demo"}) + cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo") + cls.app = cls.fastapi_demo_app._get_app() + cls.app.dependency_overrides[depends.authenticated_partner_impl] = partial( + lambda a: a, cls.test_partner + ) + cls.app.dependency_overrides[depends.odoo_env] = partial(lambda a: a, cls.env) + cls.client = TestClient(cls.app) + + @classmethod + def tearDownClass(cls) -> None: + cls.fastapi_demo_app._reset_app() + + super().tearDownClass() + + def _get_path(self, path) -> str: + return self.fastapi_demo_app.root_path + path + + def test_hello_world(self) -> None: + response: Response = self.client.get(self._get_path("/")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), {"Hello": "World"}) + + def test_who_ami(self) -> None: + response: Response = self.client.get(self._get_path("/who_ami")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual( + response.json(), {"name": "FastAPI Demo", "display_name": "FastAPI Demo"} + ) diff --git a/fastapi/views/fastapi_endpoint.xml b/fastapi/views/fastapi_endpoint.xml index 62c5db603..f0c623a3c 100644 --- a/fastapi/views/fastapi_endpoint.xml +++ b/fastapi/views/fastapi_endpoint.xml @@ -32,6 +32,7 @@ + diff --git a/fastapi/views/fastapi_endpoint_demo.xml b/fastapi/views/fastapi_endpoint_demo.xml new file mode 100644 index 000000000..db76aa537 --- /dev/null +++ b/fastapi/views/fastapi_endpoint_demo.xml @@ -0,0 +1,23 @@ + + + + + + fastapi.endpoint.demo.form (in fastapi) + fastapi.endpoint + + + + + + + + + + + From 50cfd6c55ee7128be7a03603d065f1191c7708e1 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Sun, 9 Oct 2022 08:42:09 +0200 Subject: [PATCH 006/118] [IMP] fastapi: Add dependency method This method can be used to get access to the fastapi.endpoint record into your router's methods --- fastapi/depends.py | 26 +++++++++++++++++++++ fastapi/models/fastapi_endpoint.py | 6 +++++ fastapi/models/fastapi_endpoint_demo.py | 31 ++++++++++++++++++++++++- fastapi/tests/test_fastapi_demo.py | 19 ++++++++++++--- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/fastapi/depends.py b/fastapi/depends.py index cd3408810..9be6d09b4 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -1,6 +1,8 @@ # Copyright 2022 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). +from typing import TYPE_CHECKING + from odoo.api import Environment from odoo.exceptions import AccessDenied @@ -12,6 +14,9 @@ from .context import odoo_env_ctx +if TYPE_CHECKING: + from .models.fastapi_endpoint import FastapiEndpoint + def odoo_env() -> Environment: yield odoo_env_ctx.get() @@ -62,3 +67,24 @@ def authenticated_partner_from_basic_auth_user( user: Users = Depends(basic_auth_user), # noqa: B008 ) -> Partner: return user.partner_id + + +def fastapi_endpoint_id() -> int: + """This method is overriden by default to make the fastapi.endpoint record + available for your endpoint method. To get the fastapi.enpoint record + in your method, you just need to add a dependecy on the fastapi_endpoint method + defined below + """ + + +def fastapi_endpoint( + _id: int = Depends(fastapi_endpoint_id), # noqa: B008 + env: Environment = Depends(odoo_env), # noqa: B008 +) -> "FastapiEndpoint": + """Return the fastapi.endpoint record + + Be carefull, the information are returned as sudo + """ + # TODO we should declare a technical user with read access only on the + # fastapi.endpoint model + return env["fastapi.endpoint"].sudo().browse(_id) diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index 01f451463..7c61ec0a9 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -2,6 +2,7 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). import logging +from functools import partial from typing import Any, Dict, List from a2wsgi import ASGIMiddleware @@ -12,6 +13,8 @@ from fastapi import APIRouter, FastAPI +from .. import depends + _logger = logging.getLogger(__name__) @@ -180,6 +183,9 @@ def _get_app(self) -> FastAPI: app = FastAPI(**self._prepare_fastapi_endpoint_params()) for router in self._get_fastapi_routers(): app.include_router(prefix=self.root_path, router=router) + app.dependency_overrides[depends.fastapi_endpoint_id] = partial( + lambda a: a, self.id + ) return app def _prepare_fastapi_endpoint_params(self) -> Dict[str, Any]: diff --git a/fastapi/models/fastapi_endpoint_demo.py b/fastapi/models/fastapi_endpoint_demo.py index b1f6f8475..16c43a8f1 100644 --- a/fastapi/models/fastapi_endpoint_demo.py +++ b/fastapi/models/fastapi_endpoint_demo.py @@ -11,12 +11,13 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import APIKeyHeader -from pydantic import BaseModel +from pydantic import BaseModel, Field from ..depends import ( authenticated_partner, authenticated_partner_from_basic_auth_user, authenticated_partner_impl, + fastapi_endpoint, odoo_env, ) @@ -79,6 +80,17 @@ class UserInfo(BaseModel): display_name: str +class EndpointAppInfo(BaseModel): + id: str + name: str + app: str + auth_method: str = Field(alias="demo_auth_method") + root_path: str + + class Config: + orm_mode = True + + demo_api_router = APIRouter() @@ -94,9 +106,26 @@ async def who_ami(partner=Depends(authenticated_partner)) -> UserInfo: # noqa: Returns the authenticated partner """ + # This method show you how you can rget the authenticated partner without + # depending on a specific implementation. return UserInfo(name=partner.name, display_name=partner.display_name) +@demo_api_router.get( + "/endpoint_app_info", + response_model=EndpointAppInfo, + dependencies=[Depends(authenticated_partner)], +) +async def endpoint_app_info( + endpoint: FastapiEndpoint = Depends(fastapi_endpoint), # noqa: B008 +) -> EndpointAppInfo: + """Returns the current enpoint configuration""" + # This method show you how to get access to current endpoint configuration + # It also show you how you can specify a dependecy to force the security + # even if the method doesn't require the authenticated partner as parameter + return EndpointAppInfo.from_orm(endpoint) + + def api_key_based_authenticated_partner_impl( api_key: str = Depends( # noqa: B008 APIKeyHeader( diff --git a/fastapi/tests/test_fastapi_demo.py b/fastapi/tests/test_fastapi_demo.py index 090b1ff07..7581a95bd 100644 --- a/fastapi/tests/test_fastapi_demo.py +++ b/fastapi/tests/test_fastapi_demo.py @@ -7,11 +7,12 @@ from odoo.tests.common import TransactionCase -from odoo.addons.fastapi import depends - from fastapi import status from fastapi.testclient import TestClient +from .. import depends +from ..models.fastapi_endpoint_demo import EndpointAppInfo + class FastAPIDemoCase(TransactionCase): """The fastapi lib comes with a usefull testclient that let's you @@ -52,5 +53,17 @@ def test_who_ami(self) -> None: response: Response = self.client.get(self._get_path("/who_ami")) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual( - response.json(), {"name": "FastAPI Demo", "display_name": "FastAPI Demo"} + response.json(), + { + "name": self.test_partner.name, + "display_name": self.test_partner.display_name, + }, + ) + + def test_endpoint_info(self) -> None: + response: Response = self.client.get(self._get_path("/endpoint_app_info")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual( + response.json(), + EndpointAppInfo.from_orm(self.fastapi_demo_app).dict(by_alias=True), ) From ac0abb4bebb6e29410b0c2491219894ff0508a8d Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 7 Dec 2022 09:45:21 +0100 Subject: [PATCH 007/118] [IMP] fastapi: Implement error handling ensure transation is rolled back in case of error and allows override / extension of the error handling globally or by app --- fastapi/error_handlers.py | 80 ++++++++++++++++++++++ fastapi/models/fastapi_endpoint.py | 42 +++++++++++- fastapi/models/fastapi_endpoint_demo.py | 35 +++++++++- fastapi/tests/test_fastapi_demo.py | 91 +++++++++++++++++++++++-- 4 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 fastapi/error_handlers.py diff --git a/fastapi/error_handlers.py b/fastapi/error_handlers.py new file mode 100644 index 000000000..a7b4d9b2d --- /dev/null +++ b/fastapi/error_handlers.py @@ -0,0 +1,80 @@ +# Copyright 2022 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from starlette.responses import JSONResponse +from starlette.status import ( + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_500_INTERNAL_SERVER_ERROR, +) + +import odoo + +from fastapi import Request +from fastapi.exception_handlers import http_exception_handler +from fastapi.exceptions import HTTPException + +from .context import odoo_env_ctx + +_logger = logging.getLogger(__name__) + + +def _rollback(request: Request, reason: str) -> None: + cr = odoo_env_ctx.get().cr + if cr is not None: + _logger.debug("rollback on %s", reason) + cr.rollback() + + +async def _odoo_user_error_handler( + request: Request, exc: odoo.exceptions.UserError +) -> JSONResponse: + _rollback(request, "UserError") + return await http_exception_handler( + request, HTTPException(HTTP_400_BAD_REQUEST, exc.args[0]) + ) + + +async def _odoo_access_error_handler( + request: Request, _exc: odoo.exceptions.AccessError +) -> JSONResponse: + _rollback(request, "AccessError") + return await http_exception_handler( + request, HTTPException(HTTP_403_FORBIDDEN, "AccessError") + ) + + +async def _odoo_missing_error_handler( + request: Request, _exc: odoo.exceptions.MissingError +) -> JSONResponse: + _rollback(request, "MissingError") + return await http_exception_handler( + request, HTTPException(HTTP_404_NOT_FOUND, "MissingError") + ) + + +async def _odoo_validation_error_handler( + request: Request, exc: odoo.exceptions.ValidationError +) -> JSONResponse: + _rollback(request, "ValidationError") + return await http_exception_handler( + request, HTTPException(HTTP_400_BAD_REQUEST, exc.args[0]) + ) + + +async def _odoo_http_exception_handler( + request: Request, exc: HTTPException +) -> JSONResponse: + _rollback(request, "HTTPException") + return await http_exception_handler(request, exc) + + +async def _odoo_exception_handler(request: Request, exc: Exception) -> JSONResponse: + _rollback(request, "Exception") + _logger.exception("Unhandled exception", exc_info=exc) + return await http_exception_handler( + request, HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, str(exc)) + ) diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index 7c61ec0a9..2e1195239 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -3,17 +3,18 @@ import logging from functools import partial -from typing import Any, Dict, List +from typing import Any, Awaitable, Callable, Dict, List, Type, Union from a2wsgi import ASGIMiddleware +import odoo from odoo import _, api, exceptions, fields, models, tools from odoo.addons.endpoint_route_handler.registry import EndpointRegistry -from fastapi import APIRouter, FastAPI +from fastapi import APIRouter, FastAPI, HTTPException, Request, Response -from .. import depends +from .. import depends, error_handlers _logger = logging.getLogger(__name__) @@ -186,9 +187,44 @@ def _get_app(self) -> FastAPI: app.dependency_overrides[depends.fastapi_endpoint_id] = partial( lambda a: a, self.id ) + for exception, handler in self._get_app_exception_handlers().items(): + app.add_exception_handler(exception, handler) return app + def _get_app_exception_handlers( + self, + ) -> Dict[ + int | Type[Exception], + Callable[[Request, Exception], Union[Response, Awaitable[Response]]], + ]: + """Return a dict of exception handlers to register on the app + + The key is the exception class or status code to handle. + The value is the handler function. + + If you need to register your own handler, you can do it by overriding + this method and calling super(). Changes done in this way will be applied + to all the endpoints. If you need to register a handler only for a specific + endpoint, you can do it by overriding the _get_app_exception_handlers method + and conditionally returning your specific handlers only for the endpoint + you want according to the self.app value. + + Be careful to not forget to roll back the transaction when you implement + your own error handler. If you don't, the transaction will be committed + and the changes will be applied to the database. + """ + self.ensure_one() + return { + Exception: error_handlers._odoo_exception_handler, + HTTPException: error_handlers._odoo_http_exception_handler, + odoo.exceptions.UserError: error_handlers._odoo_user_error_handler, + odoo.exceptions.AccessError: error_handlers._odoo_access_error_handler, + odoo.exceptions.MissingError: error_handlers._odoo_missing_error_handler, + odoo.exceptions.ValidationError: error_handlers._odoo_validation_error_handler, + } + def _prepare_fastapi_endpoint_params(self) -> Dict[str, Any]: + """Return the params to pass to the Fast API app constructor""" return { "title": self.name, "description": self.description, diff --git a/fastapi/models/fastapi_endpoint_demo.py b/fastapi/models/fastapi_endpoint_demo.py index 16c43a8f1..4036af43d 100644 --- a/fastapi/models/fastapi_endpoint_demo.py +++ b/fastapi/models/fastapi_endpoint_demo.py @@ -1,11 +1,11 @@ # Copyright 2022 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). - +from enum import Enum from typing import List from odoo import _, api, fields, models from odoo.api import Environment -from odoo.exceptions import ValidationError +from odoo.exceptions import AccessError, MissingError, UserError, ValidationError from odoo.addons.base.models.res_partner import Partner @@ -100,6 +100,37 @@ async def hello_word(): return {"Hello": "World"} +class ExceptionType(str, Enum): + user_error = "UserError" + validation_error = "ValidationError" + access_error = "AccessError" + missing_error = "MissingError" + http_exception = "HTTPException" + bare_exception = "BareException" + + +@demo_api_router.get("/exception") +async def exception(exception_type: ExceptionType, error_message: str): + """Raise an exception + + This method is called into the test suite to check that any exception + is correctly handled by the fastapi endpoint and that the transaction + is roll backed. + """ + exception_classes = { + ExceptionType.user_error: UserError, + ExceptionType.validation_error: ValidationError, + ExceptionType.access_error: AccessError, + ExceptionType.missing_error: MissingError, + ExceptionType.http_exception: HTTPException, + ExceptionType.bare_exception: NotImplementedError, # any exception child of Exception + } + exception_cls = exception_classes[exception_type] + if exception_cls is HTTPException: + raise exception_cls(status_code=status.HTTP_409_CONFLICT, detail=error_message) + raise exception_classes[exception_type](error_message) + + @demo_api_router.get("/who_ami", response_model=UserInfo) async def who_ami(partner=Depends(authenticated_partner)) -> UserInfo: # noqa: B008 """Who am I? diff --git a/fastapi/tests/test_fastapi_demo.py b/fastapi/tests/test_fastapi_demo.py index 7581a95bd..b40874412 100644 --- a/fastapi/tests/test_fastapi_demo.py +++ b/fastapi/tests/test_fastapi_demo.py @@ -2,21 +2,24 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). from functools import partial +from unittest import mock from requests import Response from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger from fastapi import status from fastapi.testclient import TestClient from .. import depends -from ..models.fastapi_endpoint_demo import EndpointAppInfo +from ..context import odoo_env_ctx +from ..models.fastapi_endpoint_demo import EndpointAppInfo, ExceptionType class FastAPIDemoCase(TransactionCase): - """The fastapi lib comes with a usefull testclient that let's you - easily test your endpoinds. Moreover, the dependey overrides functionnality + """The fastapi lib comes with a useful testclient that let's you + easily test your endpoints. Moreover, the dependency overrides functionality allows you to provide specific implementation for part of the code to avoid to rely on some tricky http stuff for example: authentication @@ -32,11 +35,16 @@ def setUpClass(cls) -> None: cls.app.dependency_overrides[depends.authenticated_partner_impl] = partial( lambda a: a, cls.test_partner ) - cls.app.dependency_overrides[depends.odoo_env] = partial(lambda a: a, cls.env) - cls.client = TestClient(cls.app) + # we need to disable the raise of unexpected exception into the called + # service to test the error handling of the endpoint. By default, the + # TestClient will let unexpected exception bubble up to the test method + # to allows you to process the error accordingly + cls.client = TestClient(cls.app, raise_server_exceptions=False) + cls._ctx_token = odoo_env_ctx.set(cls.env) @classmethod def tearDownClass(cls) -> None: + odoo_env_ctx.reset(cls._ctx_token) cls.fastapi_demo_app._reset_app() super().tearDownClass() @@ -44,6 +52,31 @@ def tearDownClass(cls) -> None: def _get_path(self, path) -> str: return self.fastapi_demo_app.root_path + path + @mute_logger("odoo.addons.fastapi.error_handlers") + def assert_exception_processed( + self, + exception_type: ExceptionType, + error_message: str, + expected_message: str, + expected_status_code: int, + ) -> None: + with mock.patch.object(self.env.cr.__class__, "rollback") as mock_rollback: + response: Response = self.client.get( + self._get_path("/exception"), + params={ + "exception_type": exception_type.value, + "error_message": error_message, + }, + ) + mock_rollback.assert_called_once() + self.assertEqual(response.status_code, expected_status_code) + self.assertDictEqual( + response.json(), + { + "detail": expected_message, + }, + ) + def test_hello_world(self) -> None: response: Response = self.client.get(self._get_path("/")) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -67,3 +100,51 @@ def test_endpoint_info(self) -> None: response.json(), EndpointAppInfo.from_orm(self.fastapi_demo_app).dict(by_alias=True), ) + + def test_user_error(self) -> None: + self.assert_exception_processed( + exception_type=ExceptionType.user_error, + error_message="test", + expected_message="test", + expected_status_code=status.HTTP_400_BAD_REQUEST, + ) + + def test_validation_error(self) -> None: + self.assert_exception_processed( + exception_type=ExceptionType.validation_error, + error_message="test", + expected_message="test", + expected_status_code=status.HTTP_400_BAD_REQUEST, + ) + + def test_bare_exception(self) -> None: + self.assert_exception_processed( + exception_type=ExceptionType.bare_exception, + error_message="test", + expected_message="test", + expected_status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def test_access_error(self) -> None: + self.assert_exception_processed( + exception_type=ExceptionType.access_error, + error_message="test", + expected_message="AccessError", + expected_status_code=status.HTTP_403_FORBIDDEN, + ) + + def test_missing_error(self) -> None: + self.assert_exception_processed( + exception_type=ExceptionType.missing_error, + error_message="test", + expected_message="MissingError", + expected_status_code=status.HTTP_404_NOT_FOUND, + ) + + def test_http_exception(self) -> None: + self.assert_exception_processed( + exception_type=ExceptionType.http_exception, + error_message="test", + expected_message="test", + expected_status_code=status.HTTP_409_CONFLICT, + ) From d8e1037b151da34164c2d119b2b763bcdf3f7ade Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Sat, 10 Dec 2022 16:30:04 +0100 Subject: [PATCH 008/118] [IMP] fastapi: Allows to define the user executing the api calls --- fastapi/depends.py | 2 +- fastapi/fastapi_dispatcher.py | 13 +++++++--- fastapi/models/fastapi_endpoint.py | 33 ++++++++++++++++++++++--- fastapi/models/fastapi_endpoint_demo.py | 8 +++--- fastapi/views/fastapi_endpoint.xml | 1 + 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/fastapi/depends.py b/fastapi/depends.py index 9be6d09b4..75b6adcec 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -54,7 +54,7 @@ def basic_auth_user( uid = env["res.users"].authenticate( db=env.cr.dbname, login=username, password=password, user_agent_env=None ) - return env["res.users"].browse(uid) + return env["res.users"].sudo().browse(uid) except AccessDenied as ad: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/fastapi/fastapi_dispatcher.py b/fastapi/fastapi_dispatcher.py index 83ecf45e7..cee15e325 100644 --- a/fastapi/fastapi_dispatcher.py +++ b/fastapi/fastapi_dispatcher.py @@ -23,9 +23,11 @@ def dispatch(self, endpoint, args): root_path = "/" + environ["PATH_INFO"].split("/")[1] # TODO store the env into contextvar to be used by the odoo_env # depends method - app = request.env["fastapi.endpoint"].sudo().get_app(root_path) + fastapi_endpoint = self.request.env["fastapi.endpoint"].sudo() + app = fastapi_endpoint.get_app(root_path) + uid = fastapi_endpoint.get_uid(root_path) data = BytesIO() - with self._manage_odoo_env(): + with self._manage_odoo_env(uid): for r in app(environ, self._make_response): data.write(r) return self.request.make_response( @@ -45,8 +47,11 @@ def _get_environ(self): return environ @contextmanager - def _manage_odoo_env(self): - token = odoo_env_ctx.set(request.env) + def _manage_odoo_env(self, uid=None): + env = request.env + if uid: + env = env(user=uid) + token = odoo_env_ctx.set(env) try: yield finally: diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index 2e1195239..5b1408461 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -38,6 +38,12 @@ class FastapiEndpoint(models.Model): copy=False, ) app: str = fields.Selection(selection=[], required=True) + user_id = fields.Many2one( + comodel_name="res.users", + string="User", + help="The user to use to execute the API calls.", + default=lambda self: self.env.ref("base.public_user"), + ) docs_url: str = fields.Char(compute="_compute_urls") redoc_url: str = fields.Char(compute="_compute_urls") openapi_url: str = fields.Char(compute="_compute_urls") @@ -99,9 +105,16 @@ def _handle_route_updates(self, vals): else: self._unregister_endpoints() return True - if any([x in vals for x in self._routing_fields()]): + refresh_endpoints = any([x in vals for x in self._routing_fields()]) + refresh_fastapi_app = ( + any([x in vals for x in self._fastapi_app_fields()]) or refresh_endpoints + ) + if refresh_endpoints: self._register_endpoints() + if refresh_fastapi_app: self._reset_app() + if "user_id" in vals: + self.get_uid.clear_cache(self) return False def unlink(self): @@ -109,10 +122,16 @@ def unlink(self): return super().unlink() @api.model - def _routing_fields(self): - """The list of fields requiring to rebuild the app if modified""" + def _routing_fields(self) -> List[str]: + """The list of fields requiring to refresh the mount point of the pp + into odoo if modified""" return ["root_path"] + @api.model + def _fastapi_app_fields(self) -> List[str]: + """The list of fields requiring to refresh the fastapi app if modified""" + return [] + @property def _endpoint_registry(self) -> EndpointRegistry: return EndpointRegistry.registry_for(self.env.cr.dbname) @@ -180,6 +199,14 @@ def get_app(self, root_path): return None return ASGIMiddleware(record._get_app()) + @api.model + @tools.ormcache("root_path") + def get_uid(self, root_path): + record = self.search([("root_path", "=", root_path)]) + if not record: + return None + return record.user_id.id + def _get_app(self) -> FastAPI: app = FastAPI(**self._prepare_fastapi_endpoint_params()) for router in self._get_fastapi_routers(): diff --git a/fastapi/models/fastapi_endpoint_demo.py b/fastapi/models/fastapi_endpoint_demo.py index 4036af43d..2215623e5 100644 --- a/fastapi/models/fastapi_endpoint_demo.py +++ b/fastapi/models/fastapi_endpoint_demo.py @@ -51,8 +51,8 @@ def _valdiate_demo_auth_method(self): ) @api.model - def _routing_fields(self): - fields = super()._routing_fields() + def _fastapi_app_fields(self) -> List[str]: + fields = super()._fastapi_app_fields() fields.append("demo_auth_method") return fields @@ -169,7 +169,9 @@ def api_key_based_authenticated_partner_impl( """A dummy implementation that look for a user with the same login as the provided api key """ - partner = env["res.users"].search([("login", "=", api_key)], limit=1).partner_id + partner = ( + env["res.users"].sudo().search([("login", "=", api_key)], limit=1).partner_id + ) if not partner: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect API Key" diff --git a/fastapi/views/fastapi_endpoint.xml b/fastapi/views/fastapi_endpoint.xml index f0c623a3c..27592a45c 100644 --- a/fastapi/views/fastapi_endpoint.xml +++ b/fastapi/views/fastapi_endpoint.xml @@ -25,6 +25,7 @@ + From 79669649e7ffae079e6f7d75ada2f730710615c9 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Sat, 10 Dec 2022 16:36:47 +0100 Subject: [PATCH 009/118] [IMP] fastapi: Add authenticated_partner_id into ir.rule eval context --- fastapi/depends.py | 17 ++++++++++++++--- fastapi/models/__init__.py | 1 + fastapi/models/ir_rule.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 fastapi/models/ir_rule.py diff --git a/fastapi/depends.py b/fastapi/depends.py index 75b6adcec..028bdeead 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -37,11 +37,22 @@ def authenticated_partner( enpoint, you can add a dependency into the endpoint definition on this method. This method is a safe way to declare a dependency without requiring a - specific implementation. It depends on `authenticated_partner_id`. The - concrete implementation of authenticated_partner_id has to be provide + specific implementation. It depends on `authenticated_partner_impl`. The + concrete implementation of authenticated_partner_impl has to be provided when the FastAPI app is created. + This method is also responsible to put the authenticated partner id + into the context of the current environment. """ - return partner + return partner.with_context(authenticated_partner_id=partner.id) + + +def authenticated_partner_env( + partner: Partner = Depends(authenticated_partner), # noqa: B008 +) -> Environment: + """Return an environment the authenticated partner id into the context""" + return partner.env + + def basic_auth_user( diff --git a/fastapi/models/__init__.py b/fastapi/models/__init__.py index 85a376639..6768870cb 100644 --- a/fastapi/models/__init__.py +++ b/fastapi/models/__init__.py @@ -1,2 +1,3 @@ from . import fastapi_endpoint from . import fastapi_endpoint_demo +from . import ir_rule diff --git a/fastapi/models/ir_rule.py b/fastapi/models/ir_rule.py new file mode 100644 index 000000000..20eefe816 --- /dev/null +++ b/fastapi/models/ir_rule.py @@ -0,0 +1,28 @@ +# Copyright 2022 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class IrRule(models.Model): + """Add authenticated_partner_id in record rule evaluation context. + + This come from the env current odoo environment which is + populated by dependency method authenticated_partner_env or authenticated_partner + when a route handler depends one of them and is called by the FastAPI service layer. + """ + + _inherit = "ir.rule" + + @api.model + def _eval_context(self): + ctx = super(IrRule, self)._eval_context() + if "authenticated_partner_id" in self.env.context: + ctx["authenticated_partner_id"] = self.env.context[ + "authenticated_partner_id" + ] + return ctx + + def _compute_domain_keys(self): + """Return the list of context keys to use for caching ``_compute_domain``.""" + return super(IrRule, self)._compute_domain_keys() + ["authenticated_partner_id"] From 38f7bd696332b2cc09a12cd19dfefdafb3de191f Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Sat, 10 Dec 2022 16:40:28 +0100 Subject: [PATCH 010/118] [IMP] fastapi: Add helper code to ease development of search route handlers --- fastapi/depends.py | 8 +++++++- fastapi/schemas.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 fastapi/schemas.py diff --git a/fastapi/depends.py b/fastapi/depends.py index 028bdeead..20b2975b5 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -9,10 +9,11 @@ from odoo.addons.base.models.res_partner import Partner from odoo.addons.base.models.res_users import Users -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Query, status from fastapi.security import HTTPBasic, HTTPBasicCredentials from .context import odoo_env_ctx +from .schemas import Paging if TYPE_CHECKING: from .models.fastapi_endpoint import FastapiEndpoint @@ -53,6 +54,11 @@ def authenticated_partner_env( return partner.env +def paging( + page: int = Query(1, gte=1), page_size: int = Query(80, gte=1) # noqa: B008 +) -> Paging: + """Return a Paging object from the page and page_size parameters""" + return Paging(limit=page_size, offset=(page - 1) * page_size) def basic_auth_user( diff --git a/fastapi/schemas.py b/fastapi/schemas.py new file mode 100644 index 000000000..0e98f4181 --- /dev/null +++ b/fastapi/schemas.py @@ -0,0 +1,20 @@ +# Copyright 2022 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from typing import Generic, List, Optional, TypeVar + +from pydantic import BaseModel +from pydantic.generics import GenericModel + +T = TypeVar("T") + + +class PagedCollection(GenericModel, Generic[T]): + + total: int + items: List[T] + + +class Paging(BaseModel): + limit: Optional[int] + offset: Optional[int] From a16a53d4423ea2e0bab3f560776e3f3fb7cc0b1c Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Sat, 10 Dec 2022 16:43:06 +0100 Subject: [PATCH 011/118] [IMP] fastapi: documentation --- fastapi/README.rst | 1203 ++++++++++++++++- fastapi/depends.py | 10 +- fastapi/models/fastapi_endpoint_demo.py | 4 +- fastapi/readme/DESCRIPTION.rst | 35 +- fastapi/readme/ROADMAP.rst | 7 + fastapi/readme/USAGE.rst | 1157 ++++++++++++++++ .../static/description/endpoint_create.png | Bin 0 -> 33245 bytes fastapi/static/description/index.html | 1094 ++++++++++++++- 8 files changed, 3485 insertions(+), 25 deletions(-) create mode 100644 fastapi/readme/USAGE.rst create mode 100644 fastapi/static/description/endpoint_create.png diff --git a/fastapi/README.rst b/fastapi/README.rst index 93745186d..aba0fa1f5 100644 --- a/fastapi/README.rst +++ b/fastapi/README.rst @@ -25,12 +25,43 @@ Odoo FastAPI |badge1| |badge2| |badge3| |badge4| |badge5| -This addon provides the basis smoothly integrate the `FastAPI`_ +This addon provides the basis to smoothly integrate the `FastAPI`_ framework into Odoo. -This integration allows you to use all the goodies from `FastAPI` to build custom +This integration allows you to use all the goodies from `FastAPI`_ to build custom APIs for your Odoo server based on standard Python type hints. +What is building an API? +************************ + +An API is a set of functions that can be called from the outside world. The +goal of an API is to provide a way to interact with your application from the +outside world without having to know how it works internally. A common mistake +when you are building an API is to expose all the internal functions of your +application and therefore create a tight coupling between the outside world and +your internal datamodel and business logic. This is not a good idea because it +makes it very hard to change your internal datamodel and business logic without +breaking the outside world. + +When you are building an API, you define a contract between the outside world +and your application. This contract is defined by the functions that you expose +and the parameters that you accept. This contract is the API. When you change +your internal datamodel and business logic, you can still keep the same API +contract and therefore you don't break the outside world. Even if you change +your implementation, as long as you keep the same API contract, the outside +world will still work. This is the beauty of an API and this is why it is so +important to design a good API. + +A good API is designed to be stable and to be easy to use. It's designed to +provide high-level functions related to a specific use case. It's designed to +be easy to use by hiding the complexity of the internal datamodel and business +logic. A common mistake when you are building an API is to expose all the internal +functions of your application and let the oustide world deal with the complexity +of your internal datamodel and business logic. Don't forget that on a transactional +point of view, each call to an API function is a transaction. This means that +if a specific use case requires multiple calls to your API, you should provide +a single function that does all the work in a single transaction. This why APIs +methods are called high-level and atomic functions. .. _FastAPI: https://fastapi.tiangolo.com/ @@ -39,6 +70,1167 @@ APIs for your Odoo server based on standard Python type hints. .. contents:: :local: +Usage +===== + +What's building an API with fastapi? +************************************ + +FastAPI is a modern, fast (high-performance), web framework for building APIs +with Python 3.6+ based on standard Python type hints. This addons let's you +keep advantage of the fastapi framework and use it with Odoo. + +Before you start, we must define some terms: + +* **App**: A FastAPI app is a collection of routes, dependencies, and other + components that can be used to build a web application. +* **Router**: A router is a collection of routes that can be mounted in an + app. +* **Route**: A route is a mapping between an HTTP method and a path, and + defines what should happen when the user requests that path. +* **Dependency**: A dependency is a callable that can be used to get some + information from the user request, or to perform some actions before the + request handler is called. +* **Request**: A request is an object that contains all the information + sent by the user's browser as part of an HTTP request. +* **Response**: A response is an object that contains all the information + that the user's browser needs to build the result page. +* **Handler**: A handler is a function that takes a request and returns a + response. +* **Middleware**: A middleware is a function that takes a request and a + handler, and returns a response. + +The FastAPI framework is based on the following principles: + +* **Fast**: Very high performance, on par with NodeJS and Go (thanks to Starlette + and Pydantic). [One of the fastest Python frameworks available] +* **Fast to code**: Increase the speed to develop features by about 200% to 300%. +* **Fewer bugs**: Reduce about 40% of human (developer) induced errors. +* **Intuitive**: Great editor support. Completion everywhere. Less time + debugging. +* **Easy**: Designed to be easy to use and learn. Less time reading docs. +* **Short**: Minimize code duplication. Multiple features from each parameter + declaration. Fewer bugs. +* **Robust**: Get production-ready code. With automatic interactive documentation. +* **Standards-based**: Based on (and fully compatible with) the open standards + for APIs: OpenAPI (previously known as Swagger) and JSON Schema. +* **Open Source**: FastAPI is fully open-source, under the MIT license. + +The first step is to install the fastapi addon. You can do it with the +following command: + + $ pip install odoo-fastapi + +Once the addon is installed, you can start building your API. The first thing +you need to do is to create a new addon that depends on 'fastapi'. For example, +let's create an addon called *my_demo_api*. + +Then, you need to declare your app by defining a model that inherits from +'fastapi.endpoint' and add your app name into the app field. For example: + +.. code-block:: python + + from odoo import fields, models + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + +The **'fastapi.endpoint'** model is the base model for all the endpoints. An endpoint +instance is the mount point for a fastapi app into Odoo. When you create a new +endpoint, you can define the app that you want to mount in the **'app'** field +and the path where you want to mount it in the **'path'** field. + +figure:: static/description/endpoint.png + + FastAPI Endpoint + +Thanks to the **'fastapi.endpoint'** model, you can create as many endpoints as +you wand and mount as many apps as you want in each endpoint. The endpoint is +also the place where you can define configuration parameters for your app. A +typical example is the authentication method that you want to use for your app +when accessed at the endpoint path. + +Now, you can create your first router. For that, you need to define a global +variable into your fastapi_endpoint module called for example 'demo_api_router' + +.. code-block:: python + + from fastapi import APIRouter + from odoo import fields, models + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + + # create a router + demo_api_router = APIRouter() + + +To make your router available to your app, you need to add it to the list of routers +returned by the **get_fastapi_routers** method of your fastapi_endpoint model. + +.. code-block:: python + + from fastapi import APIRouter + from odoo import api, fields, models + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + + @api.model + def get_fastapi_routers(self): + if self.app == "demo": + return [demo_api_router] + return super().get_fastapi_routers() + + # create a router + demo_api_router = APIRouter() + +Now, you can start adding routes to your router. For example, let's add a route +that returns a list of partners. + +.. code-block:: python + + from fastapi import APIRouter + from pydantic import BaseModel + from odoo import api, fields, models + from ..depends import odoo_env + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + + @api.model + def get_fastapi_routers(self): + if self.app == "demo": + return [demo_api_router] + return super().get_fastapi_routers() + + # create a router + demo_api_router = APIRouter() + + class PartnerInfo(BaseModel): + name: str + email: str + + @demo_api_router.get("/partners", response_model=list[PartnerInfo]) + def get_partners(env=Depends(odoo_env)) -> list[PartnerInfo]: + return [ + PartnerInfo(name=partner.name, email=partner.email) + for partner in env["res.partner"].search([]) + ] + +Now, you can start your Odoo server, install your addon and create a new endpoint +instance for your app. Once it's done click on the docs url to access the +interactive documentation of your app. + +Dealing with the odoo environment +********************************* + +The **'odoo.addons.fastapi.depends'** module provides a set of functions that you can use +to inject reusable dependencies into your routes. For example, the **'odoo_env'** +function returns the current odoo environment. You can use it to access the +odoo models and the database from your route handlers. + +.. code-block:: python + + from ..depends import odoo_env + + @demo_api_router.get("/partners", response_model=list[PartnerInfo]) + def get_partners(env=Depends(odoo_env)) -> list[PartnerInfo]: + return [ + PartnerInfo(name=partner.name, email=partner.email) + for partner in env["res.partner"].search([]) + ] + +As you can see, you can use the **'Depends'** function to inject the dependency +into your route handler. The **'Depends'** function is provided by the +**'fastapi'** module. You can use it to inject any dependency into your route +handler. As your handler is a python function, the only way to get access to +the odoo environment is to inject it as a dependency. The fastapi addon provides +a set of function that can be used as dependencies: + +* **'odoo_env'**: Returns the current odoo environment. +* **'fastapi_endpoint'**: Returns the current fastapi endpoint model instance. +* **'authenticated_partner'**: Returns the authenticated partner. + +By default, the **'odoo_env'** and **'fastapi_endpoint'** dependencies are +available without extra work. + +The dependency injection mechanism +********************************** + +The **'odoo_env'** dependency relies on a simple implementation that retrieves +the current odoo environment from ContextVar variable initialized at the start +of the request processing by the specific request dispatcher processing the +fastapi requests. + +The **'fastapi_endpoint'** dependency relies on the 'dependency_overrides' mechanism +provided by the **'fastapi'** module. (see the fastapi documentation for more +details about the dependency_overrides mechanism). If you take a look at the +current implementation of the **'fastapi_endpoint'** dependency, you will see +that the method depends of two parameters: **'endpoint_id'** and **'env'**. Each +of these parameters are dependencies themselves. + +.. code-block:: python + + def fastapi_endpoint_id() -> int: + """This method is overriden by default to make the fastapi.endpoint record + available for your endpoint method. To get the fastapi.endpoint record + in your method, you just need to add a dependency on the fastapi_endpoint method + defined below + """ + + + def fastapi_endpoint( + _id: int = Depends(fastapi_endpoint_id), # noqa: B008 + env: Environment = Depends(odoo_env), # noqa: B008 + ) -> "FastapiEndpoint": + """Return the fastapi.endpoint record + + Be careful, the information are returned as sudo + """ + # TODO we should declare a technical user with read access only on the + # fastapi.endpoint model + return env["fastapi.endpoint"].sudo().browse(_id) + + +As you can see, one of these dependencies is the **'fastapi_endpoint_id'** +dependency and has no concrete implementation. This method is used as a contract +that must be implemented/provided at the time the fastapi app is created. +Here comes the power of the dependency_overrides mechanism. +If you take a look at the **'_get_app'** method of the **'FastapiEndpoint'** model, +you will see that the **'fastapi_endpoint_id'** dependency is overriden by +registering a specific method that returns the id of the current fastapi endpoint +model instance for the original method. + +.. code-block:: python + + def _get_app(self) -> FastAPI: + app = FastAPI(**self._prepare_fastapi_endpoint_params()) + for router in self._get_fastapi_routers(): + app.include_router(prefix=self.root_path, router=router) + app.dependency_overrides[depends.fastapi_endpoint_id] = partial( + lambda a: a, self.id + ) + +This kind of mechanism is very powerful and allows you to inject any dependency +into your route handlers and moreover, define an abstract dependency that can be +used by any other addon and for which the implementation could depend on the +endpoint configuration. + +The authentication mechanism +**************************** + +To make our app not tightly coupled with a specific authentication mechanism, +we will use the **'authenticated_partner'** dependency. As for the +**'fastapi_endpoint'** this dependency depends on an abstract dependency. + +When you define a route handler, you can inject the **'authenticated_partner'** +dependency as a parameter of your route handler. + +.. code-block:: python + + @demo_api_router.get("/partners", response_model=list[PartnerInfo]) + def get_partners( + env=Depends(odoo_env), partner=Depends(authenticated_partner) + ) -> list[PartnerInfo]: + return [ + PartnerInfo(name=partner.name, email=partner.email) + for partner in env["res.partner"].search([]) + ] + + +At this stage, your handler is not tied to a specific authentication mechanism +but only expects to get a partner as a dependency. Depending on your needs, you +can implement different authentication mechanism available for your app. +The fastapi addon provides a default authentication mechanism using the +'BasiAuth' method. This authentication mechanism is implemented in the +**'odoo.addons.fastapi.depends'** module and relies on functionalities provided +by the **'fastapi.security'** module. + +.. code-block:: python + + def authenticated_partner( + env: Environment = Depends(odoo_env), + security: HTTPBasicCredentials = Depends(HTTPBasic()), + ) -> "res.partner": + """Return the authenticated partner""" + partner = env["res.partner"].search( + [("email", "=", security.username)], limit=1 + ) + if not partner: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + if not partner.check_password(security.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + return partner + +As you can see, the **'authenticated_partner'** dependency relies on the +**'HTTPBasic'** dependency provided by the **'fastapi.security'** module. +In this dummy implementation, we just check that the provided credentials +can be used to authenticate a user in odoo. If the authentication is successful, +we return the partner record linked to the authenticated user. + +In some cas you could want to implement a more complex authentication mechanism +that could rely on a token or a session. In this case, you can override the +**'authenticated_partner'** dependency by registering a specific method that +returns the authenticated partner. Moreover, you can make it configurable on +the fastapi endpoint model instance. + +To do it, you just need to implement a specific method for each of your +authentication mechanism and allows the user to select one of these methods +when he creates a new fastapi endpoint. Let's say that we want to allow the +authentication by using an api key or via basic auth. Since basic auth is already +implemented, we will only implement the api key authentication mechanism. + +.. code-block:: python + + from fastapi.security import APIKeyHeader + + def api_key_based_authenticated_partner_impl( + api_key: str = Depends( # noqa: B008 + APIKeyHeader( + name="api-key", + description="In this demo, you can use a user's login as api key.", + ) + ), + env: Environment = Depends(odoo_env), # noqa: B008 + ) -> Partner: + """A dummy implementation that look for a user with the same login + as the provided api key + """ + partner = env["res.users"].search([("login", "=", api_key)], limit=1).partner_id + if not partner: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect API Key" + ) + return partner + +As for the 'BaseAuth' authentication mechanism, we also rely one of the native +security dependency provided by the **'fastapi.security'** module. + +Now that we have an implementation for our two authentication mechanism, we +can allows the user to select one of these authentication mechanism by adding +a selection field on the fastapi endpoint model. + +.. code-block:: python + + from odoo import fields, models + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + demo_auth_method = fields.Selection( + selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")], + string="Authenciation method", + ) + +.. note:: + A good practice is to prefix specific configuration fields of your app with + the name of your app. This will avoid conflicts with other app when the + 'fastapi.endpoint' model is extended for other 'app'. + +Now that we have a selection field that allows the user to select the +authentication method, we can use the dependency override mechanism to +provide the right implementation of the **'authenticated_partner'** dependency +when the app is instantiated. + +.. code-block:: python + + from odoo.addons.fastapi import depends + from odoo.addons.fastapi.depends import authenticated_partner + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + demo_auth_method = fields.Selection( + selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")], + string="Authenciation method", + ) + + def _get_app(self) -> FastAPI: + app = super()._get_app() + if self.app == "demo": + # Here we add the overrides to the authenticated_partner_impl method + # according to the authentication method configured on the demo app + if self.demo_auth_method == "http_basic": + authenticated_partner_impl_override = ( + authenticated_partner_from_basic_auth_user + ) + else: + authenticated_partner_impl_override = ( + api_key_based_authenticated_partner_impl + ) + app.dependency_overrides[ + authenticated_partner_impl + ] = authenticated_partner_impl_override + return app + + +To see how the dependency override mechanism works, you can take a look at the +demo app provided by the fastapi addon. If you choose the app 'demo' in the +fastapi endpoint form view, you will see that the authentication method +is configurable. You can also see that depending on the authentication method +configured on your fastapi endpoint, the documentation will change. + +.. note:: + A time of writing, the dependency override mechanism is not supported by + the fastapi documentation generator. A fix has been proposed and is waiting + to be merged. You can follow the progress of the fix on `github + `_ + +Managing configuration parameters for your app +*********************************************** + +As we have seen in the previous section, you can add configuration fields +on the fastapi endpoint model to allow the user to configure your app (as for +any odoo model you extend). When you need to access these configuration fields +in your route handlers, you can use the **'odoo.addons.fastapi.depends.fastapi_endpoint'** +dependency method to retrieve the 'fastapi.endpoint' record associated to the +current request. + +.. code-block:: python + + from pydantic import BaseModel, Field + from odoo.addons.fastapi.depends import fastapi_endpoint + + class EndpointAppInfo(BaseModel): + id: str + name: str + app: str + auth_method: str = Field(alias="demo_auth_method") + root_path: str + + class Config: + orm_mode = True + + @demo_api_router.get( + "/endpoint_app_info", + response_model=EndpointAppInfo, + dependencies=[Depends(authenticated_partner)], + ) + async def endpoint_app_info( + endpoint: FastapiEndpoint = Depends(fastapi_endpoint), # noqa: B008 + ) -> EndpointAppInfo: + """Returns the current endpoint configuration""" + # This method show you how to get access to current endpoint configuration + # It also show you how you can specify a dependency to force the security + # even if the method doesn't require the authenticated partner as parameter + return EndpointAppInfo.from_orm(endpoint) + +Some of the configuration fields of the fastapi endpoint could impact the way +the app is instantiated. For example, in the previous section, we have seen +that the authentication method configured on the 'fastapi.endpoint' record is +used in order to provide the right implementation of the **'authenticated_partner'** +when the app is instantiated. To ensure that the app is re-instantiated when +an element of the configuration used in the instantiation of the app is +modified, you must override the **'_fastapi_app_fields'** method to add the +name of the fields that impact the instantiation of the app into the returned +list. + +.. code-block:: python + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + demo_auth_method = fields.Selection( + selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")], + string="Authenciation method", + ) + + @api.model + def _fastapi_app_fields(self) -> List[str]: + fields = super()._fastapi_app_fields() + fields.append("demo_auth_method") + return fields + + +How to extend an existing app +****************************** + +When you develop a fastapi app, in a native python app it's not possible +to extend and existing one. This limitation doesn't apply to the fastapi addon +because the fastapi endpoint model is designed to be extended. However, the +way to extend an existing app is not the same as the way to extend an odoo model. + +First of all, it's important to keep in mind that when you define a route, you +are actually defining a contract between the client and the server. This +contract is defined by the route path, the method (GET, POST, PUT, DELETE, +etc.), the parameters and the response. If you want to extend an existing app, +you must ensure that the contract is not broken. Any change to the contract +will respect the `Liskov substitution principle +`_. This means +that the client should not be impacted by the change. + +What does it mean in practice? It means that you can't change the route path +or the method of an existing route. You can't change the name of a parameter +or the type of a response. You can't add a new parameter or a new response. +You can't remove a parameter or a response. If you want to change the contract, +you must create a new route. + +What can you change? + +* You can change the implementation of the route handler. +* You can override the dependencies of the route handler. +* You can add a new route handler. +* You can extend the model used as parameter or as response of the route handler. + +Let's see how to do that. + +Changing the implementation of the route handler +================================================ + +Let's say that you want to change the implementation of the route handler +**'/demo/echo'**. Since a route handler is just a python method, it could seems +a tedious task since we are not into a model method and therefore we can't +take advantage of the Odoo inheritance mechanism. + +However, the fastapi addon provides a way to do that. Thanks to the **'odoo_env'** +dependency method, you can access the current odoo environment. With this +environment, you can access the registry and therefore the model you want to +delegate the implementation to. If you want to change the implementation of +the route handler **'/demo/echo'**, the only thing you have to do is to +inherit from the model where the implementation is defined and override the +method **'echo'**. + +.. code-block:: python + + from pydantic import BaseModel + from fastapi import Depends, APIRouter + from odoo import models + from odoo.addons.fastapi.depends import odoo_env + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self) -> List[APIRouter]: + routers = super()._get_fastapi_routers() + routers.append(demo_api_router) + return routers + + demo_api_router = APIRouter() + + @demo_api_router.get( + "/echo", + response_model=EchoResponse, + dependencies=[Depends(odoo_env)], + ) + async def echo( + message: str, + odoo_env: OdooEnv = Depends(odoo_env), + ) -> EchoResponse: + """Echo the message""" + return EchoResponse(message=odoo_env["demo.fastapi.endpoint"].echo(message)) + + class EchoResponse(BaseModel): + message: str + + class DemoEndpoint(models.AbstractModel): + + _name = "demo.fastapi.endpoint" + _description = "Demo Endpoint" + + def echo(self, message: str) -> str: + return message + + class DemoEndpointInherit(models.AbstractModel): + + _inherit = "demo.fastapi.endpoint" + + def echo(self, message: str) -> str: + return f"Hello {message}" + + +..note:: + + It's a good programming practice to implement the business logic outside + the route handler. This way, you can easily test your business logic without + having to test the route handler. In the example above, the business logic + is implemented in the method **'echo'** of the model **'demo.fastapi.endpoint'**. + The route handler just delegate the implementation to this method. + + +Overriding the dependencies of the route handler +================================================ + +As you've previously seen, the dependency injection mechanism of fastapi is +very powerful. By designing your route handler to rely on dependencies with +a specific functional scope, you can easily change the implementation of the +dependency without having to change the route handler. With such a design, you +can even define abstract dependencies that must be implemented by the concrete +application. This is the case of the **'authenticated_partner'** dependency in our +previous example. (you can find the implementation of this dependency in the +file **'odoo/addons/fastapi/depends.py'** and it's usage in the file +**'odoo/addons/fastapi/models/fastapi_endpoint_demo.py'**) + +Adding a new route handler +========================== + +Let's say that you want to add a new route handler **'/demo/echo2'**. +You could be tempted to add this new route handler in your new addons by +importing the router of the existing app and adding the new route handler to +it. + +.. code-block:: python + + from odoo.addons.fastapi.models.fastapi_endpoint_demo import demo_api_router + + @demo_api_router.get( + "/echo2", + response_model=EchoResponse, + dependencies=[Depends(odoo_env)], + ) + async def echo2( + message: str, + odoo_env: OdooEnv = Depends(odoo_env), + ) -> EchoResponse: + """Echo the message""" + echo = odoo_env["demo.fastapi.endpoint"].echo2(message) + return EchoResponse(message=f"Echo2: {echo}") + +The problem with this approach is that you unconditionally add the new route +handler to the existing app even if the app is called for a different database +where your new addon is not installed. + +The solution is to define a new router and to add it to the list of routers +returned by the method **'_get_fastapi_routers'** of the model +**'fastapi.endpoint'** you are inheriting from into your new addon. + +.. code-block:: python + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self) -> List[APIRouter]: + routers = super()._get_fastapi_routers() + if self.app == "demo": + routers.append(additional_demo_api_router) + return routers + + additional_demo_api_router = APIRouter() + + @additional_demo_api_router.get( + "/echo2", + response_model=EchoResponse, + dependencies=[Depends(odoo_env)], + ) + async def echo2( + message: str, + odoo_env: OdooEnv = Depends(odoo_env), + ) -> EchoResponse: + """Echo the message""" + echo = odoo_env["demo.fastapi.endpoint"].echo2(message) + return EchoResponse(message=f"Echo2: {echo}") + + +In this way, the new router is added to the list of routers of your app only if +the app is called for a database where your new addon is installed. + +Extending the model used as parameter or as response of the route handler +========================================================================= + +The fastapi python library uses the pydantic library to define the models. By +default, once a model is defined, it's not possible to extend it. However, a +companion python library called +`extendable_pydantic `_ provides +a way to use inheritance with pydantic models to extend an existing model. If +used alone, it's your responsibility to instruct this library the list of +extensions to apply to a model and the order to apply them. This is not very +convenient. Fortunately, an dedicated odoo addon exists to make this process +complete transparent. This addon is called +`odoo-addon-extendable `_. + +When you want to allow other addons to extend a pydantic model, you must +first define the model as an extendable model by using a dedicated metaclass + +.. code-block:: python + + from pydantic import BaseModel + from extendable_pydantic import ExtendableModelMeta + + class Partner(BaseModel, metaclass=ExtendableModelMeta): + name = 0.1 + +As any other pydantic model, you can now use this model as parameter or as response +of a route handler. You can also use all the features of models defined with +pydantic. + +.. code-block:: python + + @demo_api_router.get( + "/partner", + response_model=Location, + dependencies=[Depends(authenticated_partner)], + ) + async def partner( + partner: ResPartner = Depends(authenticated_partner), + ) -> Partner: + """Return the location""" + return Partner.from_orm(partner) + + +If you need to add a new field into the model **'Partner'**, you can extend it +in your new addon by defining a new model that inherits from the model **'Partner'**. + +.. code-block:: python + + from typing import Optional + from odoo.addons.fastapi.models.fastapi_endpoint_demo import Partner + + class PartnerExtended(Partner, extends=Partner): + email: Optional[str] + +If your new addon is installed in a database, a call to the route handler +**'/demo/partner'** will return a response with the new field **'email'** if a +value is provided by the odoo record. + +.. code-block:: python + + { + "name": "John Doe", + "email": "jhon.doe@acsone.eu" + } + +If your new addon is not installed in a database, a call to the route handler +**'/demo/partner'** will only return the name of the partner. + +.. code-block:: python + + { + "name": "John Doe" + } + +..note:: + + The liskov substitution principle has also to be respected. That means that + if you extend a model, you must add new required fields or you must provide + default values for the new optional fields. + + + +Managing security into the route handlers +***************************************** + +By default the route handlers are processed the user configured on the +**'fastapi.endpoint'** model instance. (default is the Public user). +You have seen previously how to define a dependency that will be used to enforce +the authentication of a partner. When a method depends on this dependency, the +'authenticated_partner_id' key is added to the context of the partner environment. +(If you don't need the partner as dependency but need to get an environment +with the authenticated user, you can use the dependency 'authenticated_partner_env' instead of +'authenticated_partner'.) + +The fastapi addon extends the 'ir.rule' model to add into the evaluation context +of the security rules the key 'authenticated_partner_id' that contains the id +of the authenticated partner. + +A goog practice when you develop a fastapi app and you want to protect your data +in an efficient and traceable way is to: + +* create a new user specific to the app but with any access rights. +* create a security group specific to the app and add the user to this group. +* for each model you want to protect: + + * add a 'ir.model.access' record for the model to allow read access to your model + and add the group to the record. + * create a new 'ir.rule' record for the model that restricts the access to the + records of the model to the authenticated partner by using the key + 'authenticated_partner_id' in domain of the rule. + +* add a dependency on the 'authenticated_partner' to your handlers when you need + to access the authenticated partner or ensure that the service is called by an + authenticated partner. + +.. code-block:: xml + + + My Demo App User + demo_app_user + + + + My Demo App + + + + + + My Demo App: access to sale.order + + + + + + + + + + + Sale Order Rule + + [('partner_id', '=', authenticated_partner_id)] + + + +How to test your fastapi app +**************************** + +Thanks to the starlette test client, it's possible to test your fastapi app +in a very simple way. With the test client, you can call your route handlers +as if they were real http endpoints. The test client is available in the +**'fastapi.testclient'** module. + +Once again the dependency injection mechanism comes to the rescue by allowing +you to inject into the test client specific implementations of the dependencies +normally provided by the normal processing of the request by the fastapi app. +(for example, you can inject a mock of the dependency 'authenticated_partner' +to test the behavior of your route handlers when the partner is not authenticated, +you can also inject a mock for the odoo_env etc...) + +With all these features, writing a test for the 'Hello world' route handler +defined into the demo app is as simple as + +.. code-block:: python + + from functools import partial + + from requests import Response + + from odoo.tests.common import TransactionCase + + from fastapi.testclient import TestClient + + from .. import depends + from ..context import odoo_env_ctx + + + class FastAPIDemoCase(TransactionCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.test_partner = cls.env["res.partner"].create({"name": "FastAPI Demo"}) + cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo") + cls.app = cls.fastapi_demo_app._get_app() + cls.app.dependency_overrides[depends.authenticated_partner_impl] = partial( + lambda a: a, cls.test_partner + ) + cls.client = TestClient(cls.app) + cls._ctx_token = odoo_env_ctx.set(cls.env) + + @classmethod + def tearDownClass(cls) -> None: + odoo_env_ctx.reset(cls._ctx_token) + cls.fastapi_demo_app._reset_app() + + super().tearDownClass() + + def _get_path(self, path) -> str: + return self.fastapi_demo_app.root_path + path + + def test_hello_world(self) -> None: + response: Response = self.client.get(self._get_path("/")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), {"Hello": "World"}) + + +Overall considerations when you develop an fastapi app +******************************************************* + +Developing a fastapi app requires to follow some good practices to ensure that +the app is robust and easy to maintain. Here are some of them: + +* A route handler must be as simple as possible. It must not contain any + business logic. The business logic must be implemented into the service + layer. The route handler must only call the service layer and return the + result of the service layer. To ease extension on your business logic, your + service layer can be implemented as an odoo abstract model that can be + inherited by other addons. + +* A route handler should not expose the internal data structure and api of Odoo. + It should provide the api that is needed by the client. More widely, an app + provides a set of services that address a set of use cases specific to + a well defined functional domain. You must always keep in mind that your api + will remain the same for a long time even if you upgrade your odoo version + of modify your business logic. + +* A route handler is a transactional unit of work. When you design your api + you must ensure that the completeness of a use case is guaranteed by a single + transaction. If you need to perform several transactions to complete a use + case, you introduce a risk of inconsistency in your data or extra complexity + in your client code. + +* Properly handle the errors. The route handler must return a proper error + response when an error occurs. The error response must be consistent with + the rest of the api. The error response must be documented in the api + documentation. By default, the **'odoo-addon-fastapi'** module handles + the common exception types defined in the **'odoo.exceptions'** module + and returns a proper error response with the corresponding http status code. + An error in the route handler must always return an error response with a + http status code different from 200. The error response must contain a + human readable message that can be displayed to the user. The error response + can also contain a machine readable code that can be used by the client to + handle the error in a specific way. + +* When you design your json document through the pydantic models, you must + use the appropriate data types. For example, you must use the data type + **'datetime.date'** to represent a date and not a string. You must also + properly define the constraints on the fields. For example, if a field + is optional, you must use the data type **'typing.Optional'**. + `pydantic`_ provides everything you need to + properly define your json document. + +* Always use an appropriate pydantic model as request and/or response for + your route handler. Constraints on the fields of the pydantic model must + apply to the specific use case. For example, il you route handler is used + to create a sale order, the pydantic model must not contain the field + 'id' because the id of the sale order will be generated by the route handler. + But if the id is required afterwords, the pydantic model for the response + must contain the field 'id' as required. + +* Uses descriptive property names in your json documents. For example, avoid the + use of documents providing a flat list of key value pairs. + +* Be consistent in the naming of your fields into your json documents. For example, + if you use 'id' to represent the id of a sale order, you must use 'id' to represent + the id of all the other objects. + +* Be consistent in the naming style of your fields. Always prefer underscore + to camel case. + +* Always use plural for the name of the fields that contain a list of items. + For example, if you have a field 'lines' that contains a list of sale order + lines, you must use 'lines' and not 'line'. + +* You can't expect that a client will provide you the identifier of a specific + record in odoo (for example the id of a carrier) if you don't provide a + specific route handler to retrieve the list of available records. Sometimes, + the client must share with odoo the identity of a specific record to be + able to perform an appropriate action specific to this record (for example, + the processing of a payment is different for each payment acquirer). In this + case, you must provide a specific attribute that allows both the client and + odoo to identify the record. The field 'provider' on a payment acquirer allows + you to identify a specific record in odoo. This kind of approach + allows both the client and odoo to identify the record without having to rely + on the id of the record. (This will ensure that the client will not break + if the id of the record is changed in odoo for example when tests are run + on an other database). + +* Always use the same name for the same kind of object. For example, if you + have a field 'lines' that contains a list of sale order lines, you must + use the same name for the same kind of object in all the other json documents. + +* Manage relations between objects in your json documents the same way. + By default, you should return the id of the related object in the json document. + But this is not always possible or convenient, so you can also return the + related object in the json document. The main advantage of returning the id + of the related object is that it allows you to avoid the `n+1 problem + `_ . The + main advantage of returning the related object in the json document is that + it allows you to avoid an extra call to retrieve the related object. + By keeping in mind the pros and cons of each approach, you can choose the + best one for your use case. Once it's done, you must be consistent in the + way you manage the relations of the same object. + +* It's not always a good idea to name your fields into your json documents + with the same name as the fields of the corresponding odoo model. For example, + in your document representing a sale order, you must not use the name 'order_line' + for the field that contains the list of sale order lines. The name 'order_line' + in addition to being confusing and not consistent with the best practices, is + not auto-descriptive. The name 'lines' is much better. + +* Keep a defensive programming approach. If you provide a route handler that + returns a list of records, you must ensure that the computation of the list + is not too long or will not drain your server resources. For example, + for search route handlers, you must ensure that the search is limited to + a reasonable number of records by default. + +* As a corollary of the previous point, a search handler must always use the + pagination mechanism with a reasonable default page size. The result list + must be enclosed in a json document that contains the total number of records + and the list of records. + +* Use plural for the name of a service. For example, if you provide a service + that allows you to manage the sale orders, you must use the name 'sale_orders' + and not 'sale_order'. + + + +* ... and many more. + +We could write a book about the best practices to follow when you design your api +but we will stop here. This list is the result of our experience at `ACSONE SA/NV +`_ and it evolve over time. It's a kind of rescue kit that we +would provide to a new developer that starts to design an api. This kit must +be accompanied with the reading of some useful resources link like the `REST Guidelines +`_. On a technical level, +the `fastapi documentation `_ provides a lot of +useful information as well, with a lot of examples. Last but not least, the +`pydantic`_ documentation is also very useful. + +Miscellaneous +************* + +Development of a search route handler +===================================== + +The **'odoo-addon-fastapi'** module provides 2 useful piece of code to help +you be consistent when writing a route handler for a search route. + +1. A dependency method to use to specify the pagination parameters in the same + way for all the search route handlers: **'odoo.addons.fastapi.paging'**. +2. A PagedCollection pydantic model to use to return the result of a search route + handler enclosed in a json document that contains the total number of records. + +.. code-block:: python + + from pydantic import BaseModel + + from odoo.api import Environment + from odoo.addons.fastapi.depends import paging, authenticated_partner_env + from odoo.addons.fastapi.schemas import PagedCollection, Paging + + class SaleOrder(BaseModel): + id: int + name: str + + + @router.get( + "/sale_orders", + response_model=PagedCollection[SaleOrder], + response_model_exclude_unset=True, + ) + def get_sale_orders( + paging: Paging = Depends(paging), + env: Environment = Depends(authenticated_partner_env), + ) -> PagedCollection[SaleOrder]: + """Get the list of sale orders.""" + count = env["sale.order"].search_count([]) + orders = env["sale.order"].search([], limit=paging.limit, offset=paging.offset) + return PagedCollection[SaleOrder]( + total=count, + items=[SaleOrder.from_orm(order) for order in orders], + ) + +.. note:: + + The **'odoo.addons.fastapi.schemas.Paging'** and **'odoo.addons.fastapi.schemas.PagedCollection'** + pydantic models are not designed to be extended to not introduce a + dependency between the **'odoo-addon-fastapi'** module and the **'odoo-addon-extendable'** + Moreover, at the time of writing, the **'extendable-pydantic'** library does not + support Generic models. Nevertheless, a pull request has been submitted to + add this feature to the library. (see `PR 1 `_) + + +Customization of the error handling +=================================== + +The error handling a very important topic in the design of the fastapi integration +with odoo. It must ensure that the error messages are properly return to the client +and that the transaction is properly roll backed. The **'fastapi'** module provides +a way to register custom error handlers. The **'odoo.addons.fastapi.error_handlers'** +module provides the default error handlers that are registered by default when +a new instance of the **'FastAPI'** class is created. When an app is initialized in +'fastapi.endpoint' model, the method `_get_app_exception_handlers` is called to +get a dictionary of error handlers. This method is designed to be overridden +in a custom module to provide custom error handlers. You can override the handler +for a specific exception class or you can add a new handler for a new exception +or even replace all the handlers by your own handlers. Whatever you do, you must +ensure that the transaction is properly roll backed. + +Some could argue that the error handling can't be extended since the error handlers +are global method not defined in an odoo model. Since the method providing the +the error handlers definitions is defined on the 'fastapi.endpoint' model, it's +not a problem at all, you just need to think another way to do it that by inheritance. + +A solution could be to develop you own error handler to be able to process the +error and chain the call to the default error handler. + +.. code-block:: python + + class MyCustomErrorHandler(): + def __init__(self, next_handler): + self.next_handler = next_handler + + def __call__(self, request: Request, exc: Exception) -> JSONResponse: + # do something with the error + response = self.next_handler(request, exc) + # do something with the response + return response + + +With this solution, you can now register your custom error handler by overriding +the method `_get_app_exception_handlers` in your custom module. + +.. code-block:: python + + class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + def _get_app_exception_handlers( + self, + ) -> Dict[ + int | Type[Exception], + Callable[[Request, Exception], Union[Response, Awaitable[Response]]], + ]: + handlers = super()._get_app_exception_handlers() + access_error_handler = handlers.get(odoo.exceptions.AccessError) + handlers[odoo.exceptions.AccessError] = MyCustomErrorHandler(access_error_handler) + return handlers + +In the previous example, we extend the error handler for the 'AccessError' exception +for all the endpoints. You can do the same for a specific app by checking the +'app' field of the 'fastapi.endpoint' record before registering your custom error +handler. + +What's next? +************ + +The **'odoo-addon-fastapi'** module is still in its early stage of development. +It will evolve over time to integrate your feedback and to provide the missing +features. It's now up to you to try it and to provide your feedback. + +.. _pydantic: https://docs.pydantic.dev/ + Known issues / Roadmap ====================== @@ -46,6 +1238,13 @@ The `roadmap `_ can be found on GitHub. +The **FastAPI** module provides an easy way to use WebSockets. Unfortunately, this +support is not 'yet' available in the **Odoo** framework. The challenge is high +because the integration of the fastapi is based on the use of a specific middleware +that convert the WSGI request consumed by odoo to a ASGI request. The question +is to know if it is also possible to develop the same kind of bridge for the +WebSockets. + Bug Tracker =========== diff --git a/fastapi/depends.py b/fastapi/depends.py index 20b2975b5..5da6b0636 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -28,14 +28,14 @@ def authenticated_partner_impl() -> Partner: to declare the way your partner will be provided. In some case, this partner will come from the authentication mechanism (ex jwt token) in other cases it could comme from a lookup on an email received into an HTTP header ... - See the fastapi_endpoint_demo for an exemple""" + See the fastapi_endpoint_demo for an example""" def authenticated_partner( partner: Partner = Depends(authenticated_partner_impl), # noqa: B008 ) -> Partner: """If you need to get access to the authenticated partner into your - enpoint, you can add a dependency into the endpoint definition on this + endpoint, you can add a dependency into the endpoint definition on this method. This method is a safe way to declare a dependency without requiring a specific implementation. It depends on `authenticated_partner_impl`. The @@ -88,8 +88,8 @@ def authenticated_partner_from_basic_auth_user( def fastapi_endpoint_id() -> int: """This method is overriden by default to make the fastapi.endpoint record - available for your endpoint method. To get the fastapi.enpoint record - in your method, you just need to add a dependecy on the fastapi_endpoint method + available for your endpoint method. To get the fastapi.endpoint record + in your method, you just need to add a dependency on the fastapi_endpoint method defined below """ @@ -100,7 +100,7 @@ def fastapi_endpoint( ) -> "FastapiEndpoint": """Return the fastapi.endpoint record - Be carefull, the information are returned as sudo + Be careful, the information are returned as sudo """ # TODO we should declare a technical user with read access only on the # fastapi.endpoint model diff --git a/fastapi/models/fastapi_endpoint_demo.py b/fastapi/models/fastapi_endpoint_demo.py index 2215623e5..78b19ffd9 100644 --- a/fastapi/models/fastapi_endpoint_demo.py +++ b/fastapi/models/fastapi_endpoint_demo.py @@ -150,9 +150,9 @@ async def who_ami(partner=Depends(authenticated_partner)) -> UserInfo: # noqa: async def endpoint_app_info( endpoint: FastapiEndpoint = Depends(fastapi_endpoint), # noqa: B008 ) -> EndpointAppInfo: - """Returns the current enpoint configuration""" + """Returns the current endpoint configuration""" # This method show you how to get access to current endpoint configuration - # It also show you how you can specify a dependecy to force the security + # It also show you how you can specify a dependency to force the security # even if the method doesn't require the authenticated partner as parameter return EndpointAppInfo.from_orm(endpoint) diff --git a/fastapi/readme/DESCRIPTION.rst b/fastapi/readme/DESCRIPTION.rst index 0bd29b5cf..0ac802379 100644 --- a/fastapi/readme/DESCRIPTION.rst +++ b/fastapi/readme/DESCRIPTION.rst @@ -1,8 +1,39 @@ -This addon provides the basis smoothly integrate the `FastAPI`_ +This addon provides the basis to smoothly integrate the `FastAPI`_ framework into Odoo. -This integration allows you to use all the goodies from `FastAPI` to build custom +This integration allows you to use all the goodies from `FastAPI`_ to build custom APIs for your Odoo server based on standard Python type hints. +What is building an API? +************************ + +An API is a set of functions that can be called from the outside world. The +goal of an API is to provide a way to interact with your application from the +outside world without having to know how it works internally. A common mistake +when you are building an API is to expose all the internal functions of your +application and therefore create a tight coupling between the outside world and +your internal datamodel and business logic. This is not a good idea because it +makes it very hard to change your internal datamodel and business logic without +breaking the outside world. + +When you are building an API, you define a contract between the outside world +and your application. This contract is defined by the functions that you expose +and the parameters that you accept. This contract is the API. When you change +your internal datamodel and business logic, you can still keep the same API +contract and therefore you don't break the outside world. Even if you change +your implementation, as long as you keep the same API contract, the outside +world will still work. This is the beauty of an API and this is why it is so +important to design a good API. + +A good API is designed to be stable and to be easy to use. It's designed to +provide high-level functions related to a specific use case. It's designed to +be easy to use by hiding the complexity of the internal datamodel and business +logic. A common mistake when you are building an API is to expose all the internal +functions of your application and let the oustide world deal with the complexity +of your internal datamodel and business logic. Don't forget that on a transactional +point of view, each call to an API function is a transaction. This means that +if a specific use case requires multiple calls to your API, you should provide +a single function that does all the work in a single transaction. This why APIs +methods are called high-level and atomic functions. .. _FastAPI: https://fastapi.tiangolo.com/ diff --git a/fastapi/readme/ROADMAP.rst b/fastapi/readme/ROADMAP.rst index 97adf4165..1975e0974 100644 --- a/fastapi/readme/ROADMAP.rst +++ b/fastapi/readme/ROADMAP.rst @@ -1,3 +1,10 @@ The `roadmap `_ and `known issues `_ can be found on GitHub. + +The **FastAPI** module provides an easy way to use WebSockets. Unfortunately, this +support is not 'yet' available in the **Odoo** framework. The challenge is high +because the integration of the fastapi is based on the use of a specific middleware +that convert the WSGI request consumed by odoo to a ASGI request. The question +is to know if it is also possible to develop the same kind of bridge for the +WebSockets. diff --git a/fastapi/readme/USAGE.rst b/fastapi/readme/USAGE.rst new file mode 100644 index 000000000..58a47598b --- /dev/null +++ b/fastapi/readme/USAGE.rst @@ -0,0 +1,1157 @@ +What's building an API with fastapi? +************************************ + +FastAPI is a modern, fast (high-performance), web framework for building APIs +with Python 3.6+ based on standard Python type hints. This addons let's you +keep advantage of the fastapi framework and use it with Odoo. + +Before you start, we must define some terms: + +* **App**: A FastAPI app is a collection of routes, dependencies, and other + components that can be used to build a web application. +* **Router**: A router is a collection of routes that can be mounted in an + app. +* **Route**: A route is a mapping between an HTTP method and a path, and + defines what should happen when the user requests that path. +* **Dependency**: A dependency is a callable that can be used to get some + information from the user request, or to perform some actions before the + request handler is called. +* **Request**: A request is an object that contains all the information + sent by the user's browser as part of an HTTP request. +* **Response**: A response is an object that contains all the information + that the user's browser needs to build the result page. +* **Handler**: A handler is a function that takes a request and returns a + response. +* **Middleware**: A middleware is a function that takes a request and a + handler, and returns a response. + +The FastAPI framework is based on the following principles: + +* **Fast**: Very high performance, on par with NodeJS and Go (thanks to Starlette + and Pydantic). [One of the fastest Python frameworks available] +* **Fast to code**: Increase the speed to develop features by about 200% to 300%. +* **Fewer bugs**: Reduce about 40% of human (developer) induced errors. +* **Intuitive**: Great editor support. Completion everywhere. Less time + debugging. +* **Easy**: Designed to be easy to use and learn. Less time reading docs. +* **Short**: Minimize code duplication. Multiple features from each parameter + declaration. Fewer bugs. +* **Robust**: Get production-ready code. With automatic interactive documentation. +* **Standards-based**: Based on (and fully compatible with) the open standards + for APIs: OpenAPI (previously known as Swagger) and JSON Schema. +* **Open Source**: FastAPI is fully open-source, under the MIT license. + +The first step is to install the fastapi addon. You can do it with the +following command: + + $ pip install odoo-fastapi + +Once the addon is installed, you can start building your API. The first thing +you need to do is to create a new addon that depends on 'fastapi'. For example, +let's create an addon called *my_demo_api*. + +Then, you need to declare your app by defining a model that inherits from +'fastapi.endpoint' and add your app name into the app field. For example: + +.. code-block:: python + + from odoo import fields, models + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + +The **'fastapi.endpoint'** model is the base model for all the endpoints. An endpoint +instance is the mount point for a fastapi app into Odoo. When you create a new +endpoint, you can define the app that you want to mount in the **'app'** field +and the path where you want to mount it in the **'path'** field. + +figure:: static/description/endpoint.png + + FastAPI Endpoint + +Thanks to the **'fastapi.endpoint'** model, you can create as many endpoints as +you wand and mount as many apps as you want in each endpoint. The endpoint is +also the place where you can define configuration parameters for your app. A +typical example is the authentication method that you want to use for your app +when accessed at the endpoint path. + +Now, you can create your first router. For that, you need to define a global +variable into your fastapi_endpoint module called for example 'demo_api_router' + +.. code-block:: python + + from fastapi import APIRouter + from odoo import fields, models + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + + # create a router + demo_api_router = APIRouter() + + +To make your router available to your app, you need to add it to the list of routers +returned by the **get_fastapi_routers** method of your fastapi_endpoint model. + +.. code-block:: python + + from fastapi import APIRouter + from odoo import api, fields, models + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + + @api.model + def get_fastapi_routers(self): + if self.app == "demo": + return [demo_api_router] + return super().get_fastapi_routers() + + # create a router + demo_api_router = APIRouter() + +Now, you can start adding routes to your router. For example, let's add a route +that returns a list of partners. + +.. code-block:: python + + from fastapi import APIRouter + from pydantic import BaseModel + from odoo import api, fields, models + from ..depends import odoo_env + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + + @api.model + def get_fastapi_routers(self): + if self.app == "demo": + return [demo_api_router] + return super().get_fastapi_routers() + + # create a router + demo_api_router = APIRouter() + + class PartnerInfo(BaseModel): + name: str + email: str + + @demo_api_router.get("/partners", response_model=list[PartnerInfo]) + def get_partners(env=Depends(odoo_env)) -> list[PartnerInfo]: + return [ + PartnerInfo(name=partner.name, email=partner.email) + for partner in env["res.partner"].search([]) + ] + +Now, you can start your Odoo server, install your addon and create a new endpoint +instance for your app. Once it's done click on the docs url to access the +interactive documentation of your app. + +Dealing with the odoo environment +********************************* + +The **'odoo.addons.fastapi.depends'** module provides a set of functions that you can use +to inject reusable dependencies into your routes. For example, the **'odoo_env'** +function returns the current odoo environment. You can use it to access the +odoo models and the database from your route handlers. + +.. code-block:: python + + from ..depends import odoo_env + + @demo_api_router.get("/partners", response_model=list[PartnerInfo]) + def get_partners(env=Depends(odoo_env)) -> list[PartnerInfo]: + return [ + PartnerInfo(name=partner.name, email=partner.email) + for partner in env["res.partner"].search([]) + ] + +As you can see, you can use the **'Depends'** function to inject the dependency +into your route handler. The **'Depends'** function is provided by the +**'fastapi'** module. You can use it to inject any dependency into your route +handler. As your handler is a python function, the only way to get access to +the odoo environment is to inject it as a dependency. The fastapi addon provides +a set of function that can be used as dependencies: + +* **'odoo_env'**: Returns the current odoo environment. +* **'fastapi_endpoint'**: Returns the current fastapi endpoint model instance. +* **'authenticated_partner'**: Returns the authenticated partner. + +By default, the **'odoo_env'** and **'fastapi_endpoint'** dependencies are +available without extra work. + +The dependency injection mechanism +********************************** + +The **'odoo_env'** dependency relies on a simple implementation that retrieves +the current odoo environment from ContextVar variable initialized at the start +of the request processing by the specific request dispatcher processing the +fastapi requests. + +The **'fastapi_endpoint'** dependency relies on the 'dependency_overrides' mechanism +provided by the **'fastapi'** module. (see the fastapi documentation for more +details about the dependency_overrides mechanism). If you take a look at the +current implementation of the **'fastapi_endpoint'** dependency, you will see +that the method depends of two parameters: **'endpoint_id'** and **'env'**. Each +of these parameters are dependencies themselves. + +.. code-block:: python + + def fastapi_endpoint_id() -> int: + """This method is overriden by default to make the fastapi.endpoint record + available for your endpoint method. To get the fastapi.endpoint record + in your method, you just need to add a dependency on the fastapi_endpoint method + defined below + """ + + + def fastapi_endpoint( + _id: int = Depends(fastapi_endpoint_id), # noqa: B008 + env: Environment = Depends(odoo_env), # noqa: B008 + ) -> "FastapiEndpoint": + """Return the fastapi.endpoint record + + Be careful, the information are returned as sudo + """ + # TODO we should declare a technical user with read access only on the + # fastapi.endpoint model + return env["fastapi.endpoint"].sudo().browse(_id) + + +As you can see, one of these dependencies is the **'fastapi_endpoint_id'** +dependency and has no concrete implementation. This method is used as a contract +that must be implemented/provided at the time the fastapi app is created. +Here comes the power of the dependency_overrides mechanism. +If you take a look at the **'_get_app'** method of the **'FastapiEndpoint'** model, +you will see that the **'fastapi_endpoint_id'** dependency is overriden by +registering a specific method that returns the id of the current fastapi endpoint +model instance for the original method. + +.. code-block:: python + + def _get_app(self) -> FastAPI: + app = FastAPI(**self._prepare_fastapi_endpoint_params()) + for router in self._get_fastapi_routers(): + app.include_router(prefix=self.root_path, router=router) + app.dependency_overrides[depends.fastapi_endpoint_id] = partial( + lambda a: a, self.id + ) + +This kind of mechanism is very powerful and allows you to inject any dependency +into your route handlers and moreover, define an abstract dependency that can be +used by any other addon and for which the implementation could depend on the +endpoint configuration. + +The authentication mechanism +**************************** + +To make our app not tightly coupled with a specific authentication mechanism, +we will use the **'authenticated_partner'** dependency. As for the +**'fastapi_endpoint'** this dependency depends on an abstract dependency. + +When you define a route handler, you can inject the **'authenticated_partner'** +dependency as a parameter of your route handler. + +.. code-block:: python + + @demo_api_router.get("/partners", response_model=list[PartnerInfo]) + def get_partners( + env=Depends(odoo_env), partner=Depends(authenticated_partner) + ) -> list[PartnerInfo]: + return [ + PartnerInfo(name=partner.name, email=partner.email) + for partner in env["res.partner"].search([]) + ] + + +At this stage, your handler is not tied to a specific authentication mechanism +but only expects to get a partner as a dependency. Depending on your needs, you +can implement different authentication mechanism available for your app. +The fastapi addon provides a default authentication mechanism using the +'BasiAuth' method. This authentication mechanism is implemented in the +**'odoo.addons.fastapi.depends'** module and relies on functionalities provided +by the **'fastapi.security'** module. + +.. code-block:: python + + def authenticated_partner( + env: Environment = Depends(odoo_env), + security: HTTPBasicCredentials = Depends(HTTPBasic()), + ) -> "res.partner": + """Return the authenticated partner""" + partner = env["res.partner"].search( + [("email", "=", security.username)], limit=1 + ) + if not partner: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + if not partner.check_password(security.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + return partner + +As you can see, the **'authenticated_partner'** dependency relies on the +**'HTTPBasic'** dependency provided by the **'fastapi.security'** module. +In this dummy implementation, we just check that the provided credentials +can be used to authenticate a user in odoo. If the authentication is successful, +we return the partner record linked to the authenticated user. + +In some cas you could want to implement a more complex authentication mechanism +that could rely on a token or a session. In this case, you can override the +**'authenticated_partner'** dependency by registering a specific method that +returns the authenticated partner. Moreover, you can make it configurable on +the fastapi endpoint model instance. + +To do it, you just need to implement a specific method for each of your +authentication mechanism and allows the user to select one of these methods +when he creates a new fastapi endpoint. Let's say that we want to allow the +authentication by using an api key or via basic auth. Since basic auth is already +implemented, we will only implement the api key authentication mechanism. + +.. code-block:: python + + from fastapi.security import APIKeyHeader + + def api_key_based_authenticated_partner_impl( + api_key: str = Depends( # noqa: B008 + APIKeyHeader( + name="api-key", + description="In this demo, you can use a user's login as api key.", + ) + ), + env: Environment = Depends(odoo_env), # noqa: B008 + ) -> Partner: + """A dummy implementation that look for a user with the same login + as the provided api key + """ + partner = env["res.users"].search([("login", "=", api_key)], limit=1).partner_id + if not partner: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect API Key" + ) + return partner + +As for the 'BaseAuth' authentication mechanism, we also rely one of the native +security dependency provided by the **'fastapi.security'** module. + +Now that we have an implementation for our two authentication mechanism, we +can allows the user to select one of these authentication mechanism by adding +a selection field on the fastapi endpoint model. + +.. code-block:: python + + from odoo import fields, models + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + demo_auth_method = fields.Selection( + selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")], + string="Authenciation method", + ) + +.. note:: + A good practice is to prefix specific configuration fields of your app with + the name of your app. This will avoid conflicts with other app when the + 'fastapi.endpoint' model is extended for other 'app'. + +Now that we have a selection field that allows the user to select the +authentication method, we can use the dependency override mechanism to +provide the right implementation of the **'authenticated_partner'** dependency +when the app is instantiated. + +.. code-block:: python + + from odoo.addons.fastapi import depends + from odoo.addons.fastapi.depends import authenticated_partner + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + demo_auth_method = fields.Selection( + selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")], + string="Authenciation method", + ) + + def _get_app(self) -> FastAPI: + app = super()._get_app() + if self.app == "demo": + # Here we add the overrides to the authenticated_partner_impl method + # according to the authentication method configured on the demo app + if self.demo_auth_method == "http_basic": + authenticated_partner_impl_override = ( + authenticated_partner_from_basic_auth_user + ) + else: + authenticated_partner_impl_override = ( + api_key_based_authenticated_partner_impl + ) + app.dependency_overrides[ + authenticated_partner_impl + ] = authenticated_partner_impl_override + return app + + +To see how the dependency override mechanism works, you can take a look at the +demo app provided by the fastapi addon. If you choose the app 'demo' in the +fastapi endpoint form view, you will see that the authentication method +is configurable. You can also see that depending on the authentication method +configured on your fastapi endpoint, the documentation will change. + +.. note:: + A time of writing, the dependency override mechanism is not supported by + the fastapi documentation generator. A fix has been proposed and is waiting + to be merged. You can follow the progress of the fix on `github + `_ + +Managing configuration parameters for your app +*********************************************** + +As we have seen in the previous section, you can add configuration fields +on the fastapi endpoint model to allow the user to configure your app (as for +any odoo model you extend). When you need to access these configuration fields +in your route handlers, you can use the **'odoo.addons.fastapi.depends.fastapi_endpoint'** +dependency method to retrieve the 'fastapi.endpoint' record associated to the +current request. + +.. code-block:: python + + from pydantic import BaseModel, Field + from odoo.addons.fastapi.depends import fastapi_endpoint + + class EndpointAppInfo(BaseModel): + id: str + name: str + app: str + auth_method: str = Field(alias="demo_auth_method") + root_path: str + + class Config: + orm_mode = True + + @demo_api_router.get( + "/endpoint_app_info", + response_model=EndpointAppInfo, + dependencies=[Depends(authenticated_partner)], + ) + async def endpoint_app_info( + endpoint: FastapiEndpoint = Depends(fastapi_endpoint), # noqa: B008 + ) -> EndpointAppInfo: + """Returns the current endpoint configuration""" + # This method show you how to get access to current endpoint configuration + # It also show you how you can specify a dependency to force the security + # even if the method doesn't require the authenticated partner as parameter + return EndpointAppInfo.from_orm(endpoint) + +Some of the configuration fields of the fastapi endpoint could impact the way +the app is instantiated. For example, in the previous section, we have seen +that the authentication method configured on the 'fastapi.endpoint' record is +used in order to provide the right implementation of the **'authenticated_partner'** +when the app is instantiated. To ensure that the app is re-instantiated when +an element of the configuration used in the instantiation of the app is +modified, you must override the **'_fastapi_app_fields'** method to add the +name of the fields that impact the instantiation of the app into the returned +list. + +.. code-block:: python + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + demo_auth_method = fields.Selection( + selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")], + string="Authenciation method", + ) + + @api.model + def _fastapi_app_fields(self) -> List[str]: + fields = super()._fastapi_app_fields() + fields.append("demo_auth_method") + return fields + + +How to extend an existing app +****************************** + +When you develop a fastapi app, in a native python app it's not possible +to extend and existing one. This limitation doesn't apply to the fastapi addon +because the fastapi endpoint model is designed to be extended. However, the +way to extend an existing app is not the same as the way to extend an odoo model. + +First of all, it's important to keep in mind that when you define a route, you +are actually defining a contract between the client and the server. This +contract is defined by the route path, the method (GET, POST, PUT, DELETE, +etc.), the parameters and the response. If you want to extend an existing app, +you must ensure that the contract is not broken. Any change to the contract +will respect the `Liskov substitution principle +`_. This means +that the client should not be impacted by the change. + +What does it mean in practice? It means that you can't change the route path +or the method of an existing route. You can't change the name of a parameter +or the type of a response. You can't add a new parameter or a new response. +You can't remove a parameter or a response. If you want to change the contract, +you must create a new route. + +What can you change? + +* You can change the implementation of the route handler. +* You can override the dependencies of the route handler. +* You can add a new route handler. +* You can extend the model used as parameter or as response of the route handler. + +Let's see how to do that. + +Changing the implementation of the route handler +================================================ + +Let's say that you want to change the implementation of the route handler +**'/demo/echo'**. Since a route handler is just a python method, it could seems +a tedious task since we are not into a model method and therefore we can't +take advantage of the Odoo inheritance mechanism. + +However, the fastapi addon provides a way to do that. Thanks to the **'odoo_env'** +dependency method, you can access the current odoo environment. With this +environment, you can access the registry and therefore the model you want to +delegate the implementation to. If you want to change the implementation of +the route handler **'/demo/echo'**, the only thing you have to do is to +inherit from the model where the implementation is defined and override the +method **'echo'**. + +.. code-block:: python + + from pydantic import BaseModel + from fastapi import Depends, APIRouter + from odoo import models + from odoo.addons.fastapi.depends import odoo_env + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self) -> List[APIRouter]: + routers = super()._get_fastapi_routers() + routers.append(demo_api_router) + return routers + + demo_api_router = APIRouter() + + @demo_api_router.get( + "/echo", + response_model=EchoResponse, + dependencies=[Depends(odoo_env)], + ) + async def echo( + message: str, + odoo_env: OdooEnv = Depends(odoo_env), + ) -> EchoResponse: + """Echo the message""" + return EchoResponse(message=odoo_env["demo.fastapi.endpoint"].echo(message)) + + class EchoResponse(BaseModel): + message: str + + class DemoEndpoint(models.AbstractModel): + + _name = "demo.fastapi.endpoint" + _description = "Demo Endpoint" + + def echo(self, message: str) -> str: + return message + + class DemoEndpointInherit(models.AbstractModel): + + _inherit = "demo.fastapi.endpoint" + + def echo(self, message: str) -> str: + return f"Hello {message}" + + +..note:: + + It's a good programming practice to implement the business logic outside + the route handler. This way, you can easily test your business logic without + having to test the route handler. In the example above, the business logic + is implemented in the method **'echo'** of the model **'demo.fastapi.endpoint'**. + The route handler just delegate the implementation to this method. + + +Overriding the dependencies of the route handler +================================================ + +As you've previously seen, the dependency injection mechanism of fastapi is +very powerful. By designing your route handler to rely on dependencies with +a specific functional scope, you can easily change the implementation of the +dependency without having to change the route handler. With such a design, you +can even define abstract dependencies that must be implemented by the concrete +application. This is the case of the **'authenticated_partner'** dependency in our +previous example. (you can find the implementation of this dependency in the +file **'odoo/addons/fastapi/depends.py'** and it's usage in the file +**'odoo/addons/fastapi/models/fastapi_endpoint_demo.py'**) + +Adding a new route handler +========================== + +Let's say that you want to add a new route handler **'/demo/echo2'**. +You could be tempted to add this new route handler in your new addons by +importing the router of the existing app and adding the new route handler to +it. + +.. code-block:: python + + from odoo.addons.fastapi.models.fastapi_endpoint_demo import demo_api_router + + @demo_api_router.get( + "/echo2", + response_model=EchoResponse, + dependencies=[Depends(odoo_env)], + ) + async def echo2( + message: str, + odoo_env: OdooEnv = Depends(odoo_env), + ) -> EchoResponse: + """Echo the message""" + echo = odoo_env["demo.fastapi.endpoint"].echo2(message) + return EchoResponse(message=f"Echo2: {echo}") + +The problem with this approach is that you unconditionally add the new route +handler to the existing app even if the app is called for a different database +where your new addon is not installed. + +The solution is to define a new router and to add it to the list of routers +returned by the method **'_get_fastapi_routers'** of the model +**'fastapi.endpoint'** you are inheriting from into your new addon. + +.. code-block:: python + + class FastapiEndpoint(models.Model): + + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self) -> List[APIRouter]: + routers = super()._get_fastapi_routers() + if self.app == "demo": + routers.append(additional_demo_api_router) + return routers + + additional_demo_api_router = APIRouter() + + @additional_demo_api_router.get( + "/echo2", + response_model=EchoResponse, + dependencies=[Depends(odoo_env)], + ) + async def echo2( + message: str, + odoo_env: OdooEnv = Depends(odoo_env), + ) -> EchoResponse: + """Echo the message""" + echo = odoo_env["demo.fastapi.endpoint"].echo2(message) + return EchoResponse(message=f"Echo2: {echo}") + + +In this way, the new router is added to the list of routers of your app only if +the app is called for a database where your new addon is installed. + +Extending the model used as parameter or as response of the route handler +========================================================================= + +The fastapi python library uses the pydantic library to define the models. By +default, once a model is defined, it's not possible to extend it. However, a +companion python library called +`extendable_pydantic `_ provides +a way to use inheritance with pydantic models to extend an existing model. If +used alone, it's your responsibility to instruct this library the list of +extensions to apply to a model and the order to apply them. This is not very +convenient. Fortunately, an dedicated odoo addon exists to make this process +complete transparent. This addon is called +`odoo-addon-extendable `_. + +When you want to allow other addons to extend a pydantic model, you must +first define the model as an extendable model by using a dedicated metaclass + +.. code-block:: python + + from pydantic import BaseModel + from extendable_pydantic import ExtendableModelMeta + + class Partner(BaseModel, metaclass=ExtendableModelMeta): + name = 0.1 + +As any other pydantic model, you can now use this model as parameter or as response +of a route handler. You can also use all the features of models defined with +pydantic. + +.. code-block:: python + + @demo_api_router.get( + "/partner", + response_model=Location, + dependencies=[Depends(authenticated_partner)], + ) + async def partner( + partner: ResPartner = Depends(authenticated_partner), + ) -> Partner: + """Return the location""" + return Partner.from_orm(partner) + + +If you need to add a new field into the model **'Partner'**, you can extend it +in your new addon by defining a new model that inherits from the model **'Partner'**. + +.. code-block:: python + + from typing import Optional + from odoo.addons.fastapi.models.fastapi_endpoint_demo import Partner + + class PartnerExtended(Partner, extends=Partner): + email: Optional[str] + +If your new addon is installed in a database, a call to the route handler +**'/demo/partner'** will return a response with the new field **'email'** if a +value is provided by the odoo record. + +.. code-block:: python + + { + "name": "John Doe", + "email": "jhon.doe@acsone.eu" + } + +If your new addon is not installed in a database, a call to the route handler +**'/demo/partner'** will only return the name of the partner. + +.. code-block:: python + + { + "name": "John Doe" + } + +..note:: + + The liskov substitution principle has also to be respected. That means that + if you extend a model, you must add new required fields or you must provide + default values for the new optional fields. + + + +Managing security into the route handlers +***************************************** + +By default the route handlers are processed the user configured on the +**'fastapi.endpoint'** model instance. (default is the Public user). +You have seen previously how to define a dependency that will be used to enforce +the authentication of a partner. When a method depends on this dependency, the +'authenticated_partner_id' key is added to the context of the partner environment. +(If you don't need the partner as dependency but need to get an environment +with the authenticated user, you can use the dependency 'authenticated_partner_env' instead of +'authenticated_partner'.) + +The fastapi addon extends the 'ir.rule' model to add into the evaluation context +of the security rules the key 'authenticated_partner_id' that contains the id +of the authenticated partner. + +A goog practice when you develop a fastapi app and you want to protect your data +in an efficient and traceable way is to: + +* create a new user specific to the app but with any access rights. +* create a security group specific to the app and add the user to this group. +* for each model you want to protect: + + * add a 'ir.model.access' record for the model to allow read access to your model + and add the group to the record. + * create a new 'ir.rule' record for the model that restricts the access to the + records of the model to the authenticated partner by using the key + 'authenticated_partner_id' in domain of the rule. + +* add a dependency on the 'authenticated_partner' to your handlers when you need + to access the authenticated partner or ensure that the service is called by an + authenticated partner. + +.. code-block:: xml + + + My Demo App User + demo_app_user + + + + My Demo App + + + + + + My Demo App: access to sale.order + + + + + + + + + + + Sale Order Rule + + [('partner_id', '=', authenticated_partner_id)] + + + +How to test your fastapi app +**************************** + +Thanks to the starlette test client, it's possible to test your fastapi app +in a very simple way. With the test client, you can call your route handlers +as if they were real http endpoints. The test client is available in the +**'fastapi.testclient'** module. + +Once again the dependency injection mechanism comes to the rescue by allowing +you to inject into the test client specific implementations of the dependencies +normally provided by the normal processing of the request by the fastapi app. +(for example, you can inject a mock of the dependency 'authenticated_partner' +to test the behavior of your route handlers when the partner is not authenticated, +you can also inject a mock for the odoo_env etc...) + +With all these features, writing a test for the 'Hello world' route handler +defined into the demo app is as simple as + +.. code-block:: python + + from functools import partial + + from requests import Response + + from odoo.tests.common import TransactionCase + + from fastapi.testclient import TestClient + + from .. import depends + from ..context import odoo_env_ctx + + + class FastAPIDemoCase(TransactionCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.test_partner = cls.env["res.partner"].create({"name": "FastAPI Demo"}) + cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo") + cls.app = cls.fastapi_demo_app._get_app() + cls.app.dependency_overrides[depends.authenticated_partner_impl] = partial( + lambda a: a, cls.test_partner + ) + cls.client = TestClient(cls.app) + cls._ctx_token = odoo_env_ctx.set(cls.env) + + @classmethod + def tearDownClass(cls) -> None: + odoo_env_ctx.reset(cls._ctx_token) + cls.fastapi_demo_app._reset_app() + + super().tearDownClass() + + def _get_path(self, path) -> str: + return self.fastapi_demo_app.root_path + path + + def test_hello_world(self) -> None: + response: Response = self.client.get(self._get_path("/")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), {"Hello": "World"}) + + +Overall considerations when you develop an fastapi app +******************************************************* + +Developing a fastapi app requires to follow some good practices to ensure that +the app is robust and easy to maintain. Here are some of them: + +* A route handler must be as simple as possible. It must not contain any + business logic. The business logic must be implemented into the service + layer. The route handler must only call the service layer and return the + result of the service layer. To ease extension on your business logic, your + service layer can be implemented as an odoo abstract model that can be + inherited by other addons. + +* A route handler should not expose the internal data structure and api of Odoo. + It should provide the api that is needed by the client. More widely, an app + provides a set of services that address a set of use cases specific to + a well defined functional domain. You must always keep in mind that your api + will remain the same for a long time even if you upgrade your odoo version + of modify your business logic. + +* A route handler is a transactional unit of work. When you design your api + you must ensure that the completeness of a use case is guaranteed by a single + transaction. If you need to perform several transactions to complete a use + case, you introduce a risk of inconsistency in your data or extra complexity + in your client code. + +* Properly handle the errors. The route handler must return a proper error + response when an error occurs. The error response must be consistent with + the rest of the api. The error response must be documented in the api + documentation. By default, the **'odoo-addon-fastapi'** module handles + the common exception types defined in the **'odoo.exceptions'** module + and returns a proper error response with the corresponding http status code. + An error in the route handler must always return an error response with a + http status code different from 200. The error response must contain a + human readable message that can be displayed to the user. The error response + can also contain a machine readable code that can be used by the client to + handle the error in a specific way. + +* When you design your json document through the pydantic models, you must + use the appropriate data types. For example, you must use the data type + **'datetime.date'** to represent a date and not a string. You must also + properly define the constraints on the fields. For example, if a field + is optional, you must use the data type **'typing.Optional'**. + `pydantic`_ provides everything you need to + properly define your json document. + +* Always use an appropriate pydantic model as request and/or response for + your route handler. Constraints on the fields of the pydantic model must + apply to the specific use case. For example, il you route handler is used + to create a sale order, the pydantic model must not contain the field + 'id' because the id of the sale order will be generated by the route handler. + But if the id is required afterwords, the pydantic model for the response + must contain the field 'id' as required. + +* Uses descriptive property names in your json documents. For example, avoid the + use of documents providing a flat list of key value pairs. + +* Be consistent in the naming of your fields into your json documents. For example, + if you use 'id' to represent the id of a sale order, you must use 'id' to represent + the id of all the other objects. + +* Be consistent in the naming style of your fields. Always prefer underscore + to camel case. + +* Always use plural for the name of the fields that contain a list of items. + For example, if you have a field 'lines' that contains a list of sale order + lines, you must use 'lines' and not 'line'. + +* You can't expect that a client will provide you the identifier of a specific + record in odoo (for example the id of a carrier) if you don't provide a + specific route handler to retrieve the list of available records. Sometimes, + the client must share with odoo the identity of a specific record to be + able to perform an appropriate action specific to this record (for example, + the processing of a payment is different for each payment acquirer). In this + case, you must provide a specific attribute that allows both the client and + odoo to identify the record. The field 'provider' on a payment acquirer allows + you to identify a specific record in odoo. This kind of approach + allows both the client and odoo to identify the record without having to rely + on the id of the record. (This will ensure that the client will not break + if the id of the record is changed in odoo for example when tests are run + on an other database). + +* Always use the same name for the same kind of object. For example, if you + have a field 'lines' that contains a list of sale order lines, you must + use the same name for the same kind of object in all the other json documents. + +* Manage relations between objects in your json documents the same way. + By default, you should return the id of the related object in the json document. + But this is not always possible or convenient, so you can also return the + related object in the json document. The main advantage of returning the id + of the related object is that it allows you to avoid the `n+1 problem + `_ . The + main advantage of returning the related object in the json document is that + it allows you to avoid an extra call to retrieve the related object. + By keeping in mind the pros and cons of each approach, you can choose the + best one for your use case. Once it's done, you must be consistent in the + way you manage the relations of the same object. + +* It's not always a good idea to name your fields into your json documents + with the same name as the fields of the corresponding odoo model. For example, + in your document representing a sale order, you must not use the name 'order_line' + for the field that contains the list of sale order lines. The name 'order_line' + in addition to being confusing and not consistent with the best practices, is + not auto-descriptive. The name 'lines' is much better. + +* Keep a defensive programming approach. If you provide a route handler that + returns a list of records, you must ensure that the computation of the list + is not too long or will not drain your server resources. For example, + for search route handlers, you must ensure that the search is limited to + a reasonable number of records by default. + +* As a corollary of the previous point, a search handler must always use the + pagination mechanism with a reasonable default page size. The result list + must be enclosed in a json document that contains the total number of records + and the list of records. + +* Use plural for the name of a service. For example, if you provide a service + that allows you to manage the sale orders, you must use the name 'sale_orders' + and not 'sale_order'. + + + +* ... and many more. + +We could write a book about the best practices to follow when you design your api +but we will stop here. This list is the result of our experience at `ACSONE SA/NV +`_ and it evolve over time. It's a kind of rescue kit that we +would provide to a new developer that starts to design an api. This kit must +be accompanied with the reading of some useful resources link like the `REST Guidelines +`_. On a technical level, +the `fastapi documentation `_ provides a lot of +useful information as well, with a lot of examples. Last but not least, the +`pydantic`_ documentation is also very useful. + +Miscellaneous +************* + +Development of a search route handler +===================================== + +The **'odoo-addon-fastapi'** module provides 2 useful piece of code to help +you be consistent when writing a route handler for a search route. + +1. A dependency method to use to specify the pagination parameters in the same + way for all the search route handlers: **'odoo.addons.fastapi.paging'**. +2. A PagedCollection pydantic model to use to return the result of a search route + handler enclosed in a json document that contains the total number of records. + +.. code-block:: python + + from pydantic import BaseModel + + from odoo.api import Environment + from odoo.addons.fastapi.depends import paging, authenticated_partner_env + from odoo.addons.fastapi.schemas import PagedCollection, Paging + + class SaleOrder(BaseModel): + id: int + name: str + + + @router.get( + "/sale_orders", + response_model=PagedCollection[SaleOrder], + response_model_exclude_unset=True, + ) + def get_sale_orders( + paging: Paging = Depends(paging), + env: Environment = Depends(authenticated_partner_env), + ) -> PagedCollection[SaleOrder]: + """Get the list of sale orders.""" + count = env["sale.order"].search_count([]) + orders = env["sale.order"].search([], limit=paging.limit, offset=paging.offset) + return PagedCollection[SaleOrder]( + total=count, + items=[SaleOrder.from_orm(order) for order in orders], + ) + +.. note:: + + The **'odoo.addons.fastapi.schemas.Paging'** and **'odoo.addons.fastapi.schemas.PagedCollection'** + pydantic models are not designed to be extended to not introduce a + dependency between the **'odoo-addon-fastapi'** module and the **'odoo-addon-extendable'** + Moreover, at the time of writing, the **'extendable-pydantic'** library does not + support Generic models. Nevertheless, a pull request has been submitted to + add this feature to the library. (see `PR 1 `_) + + +Customization of the error handling +=================================== + +The error handling a very important topic in the design of the fastapi integration +with odoo. It must ensure that the error messages are properly return to the client +and that the transaction is properly roll backed. The **'fastapi'** module provides +a way to register custom error handlers. The **'odoo.addons.fastapi.error_handlers'** +module provides the default error handlers that are registered by default when +a new instance of the **'FastAPI'** class is created. When an app is initialized in +'fastapi.endpoint' model, the method `_get_app_exception_handlers` is called to +get a dictionary of error handlers. This method is designed to be overridden +in a custom module to provide custom error handlers. You can override the handler +for a specific exception class or you can add a new handler for a new exception +or even replace all the handlers by your own handlers. Whatever you do, you must +ensure that the transaction is properly roll backed. + +Some could argue that the error handling can't be extended since the error handlers +are global method not defined in an odoo model. Since the method providing the +the error handlers definitions is defined on the 'fastapi.endpoint' model, it's +not a problem at all, you just need to think another way to do it that by inheritance. + +A solution could be to develop you own error handler to be able to process the +error and chain the call to the default error handler. + +.. code-block:: python + + class MyCustomErrorHandler(): + def __init__(self, next_handler): + self.next_handler = next_handler + + def __call__(self, request: Request, exc: Exception) -> JSONResponse: + # do something with the error + response = self.next_handler(request, exc) + # do something with the response + return response + + +With this solution, you can now register your custom error handler by overriding +the method `_get_app_exception_handlers` in your custom module. + +.. code-block:: python + + class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + def _get_app_exception_handlers( + self, + ) -> Dict[ + int | Type[Exception], + Callable[[Request, Exception], Union[Response, Awaitable[Response]]], + ]: + handlers = super()._get_app_exception_handlers() + access_error_handler = handlers.get(odoo.exceptions.AccessError) + handlers[odoo.exceptions.AccessError] = MyCustomErrorHandler(access_error_handler) + return handlers + +In the previous example, we extend the error handler for the 'AccessError' exception +for all the endpoints. You can do the same for a specific app by checking the +'app' field of the 'fastapi.endpoint' record before registering your custom error +handler. + +What's next? +************ + +The **'odoo-addon-fastapi'** module is still in its early stage of development. +It will evolve over time to integrate your feedback and to provide the missing +features. It's now up to you to try it and to provide your feedback. + +.. _pydantic: https://docs.pydantic.dev/ diff --git a/fastapi/static/description/endpoint_create.png b/fastapi/static/description/endpoint_create.png new file mode 100644 index 0000000000000000000000000000000000000000..dc5806b14cc4b9a1e39efe343a76ffd55d39ad1f GIT binary patch literal 33245 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 toN*D6AAxVf0(B~$xm{kCBk|?{tw$Z^3MPO literal 0 HcmV?d00001 diff --git a/fastapi/static/description/index.html b/fastapi/static/description/index.html index d5efae3b5..29c1c02e1 100644 --- a/fastapi/static/description/index.html +++ b/fastapi/static/description/index.html @@ -368,31 +368,1096 @@

    Odoo FastAPI

    !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: LGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runbot

    -

    This addon provides the basis smoothly integrate the FastAPI +

    This addon provides the basis to smoothly integrate the FastAPI framework into Odoo.

    -

    This integration allows you to use all the goodies from FastAPI to build custom +

    This integration allows you to use all the goodies from FastAPI to build custom APIs for your Odoo server based on standard Python type hints.

    +
    +

    What is building an API?

    +

    An API is a set of functions that can be called from the outside world. The +goal of an API is to provide a way to interact with your application from the +outside world without having to know how it works internally. A common mistake +when you are building an API is to expose all the internal functions of your +application and therefore create a tight coupling between the outside world and +your internal datamodel and business logic. This is not a good idea because it +makes it very hard to change your internal datamodel and business logic without +breaking the outside world.

    +

    When you are building an API, you define a contract between the outside world +and your application. This contract is defined by the functions that you expose +and the parameters that you accept. This contract is the API. When you change +your internal datamodel and business logic, you can still keep the same API +contract and therefore you don’t break the outside world. Even if you change +your implementation, as long as you keep the same API contract, the outside +world will still work. This is the beauty of an API and this is why it is so +important to design a good API.

    +

    A good API is designed to be stable and to be easy to use. It’s designed to +provide high-level functions related to a specific use case. It’s designed to +be easy to use by hiding the complexity of the internal datamodel and business +logic. A common mistake when you are building an API is to expose all the internal +functions of your application and let the oustide world deal with the complexity +of your internal datamodel and business logic. Don’t forget that on a transactional +point of view, each call to an API function is a transaction. This means that +if a specific use case requires multiple calls to your API, you should provide +a single function that does all the work in a single transaction. This why APIs +methods are called high-level and atomic functions.

    Table of contents

    +
    +

    Usage

    +
    +
    +
    +

    What’s building an API with fastapi?

    +

    FastAPI is a modern, fast (high-performance), web framework for building APIs +with Python 3.6+ based on standard Python type hints. This addons let’s you +keep advantage of the fastapi framework and use it with Odoo.

    +

    Before you start, we must define some terms:

    +
      +
    • App: A FastAPI app is a collection of routes, dependencies, and other +components that can be used to build a web application.
    • +
    • Router: A router is a collection of routes that can be mounted in an +app.
    • +
    • Route: A route is a mapping between an HTTP method and a path, and +defines what should happen when the user requests that path.
    • +
    • Dependency: A dependency is a callable that can be used to get some +information from the user request, or to perform some actions before the +request handler is called.
    • +
    • Request: A request is an object that contains all the information +sent by the user’s browser as part of an HTTP request.
    • +
    • Response: A response is an object that contains all the information +that the user’s browser needs to build the result page.
    • +
    • Handler: A handler is a function that takes a request and returns a +response.
    • +
    • Middleware: A middleware is a function that takes a request and a +handler, and returns a response.
    • +
    +

    The FastAPI framework is based on the following principles:

    +
      +
    • Fast: Very high performance, on par with NodeJS and Go (thanks to Starlette +and Pydantic). [One of the fastest Python frameworks available]
    • +
    • Fast to code: Increase the speed to develop features by about 200% to 300%.
    • +
    • Fewer bugs: Reduce about 40% of human (developer) induced errors.
    • +
    • Intuitive: Great editor support. Completion everywhere. Less time +debugging.
    • +
    • Easy: Designed to be easy to use and learn. Less time reading docs.
    • +
    • Short: Minimize code duplication. Multiple features from each parameter +declaration. Fewer bugs.
    • +
    • Robust: Get production-ready code. With automatic interactive documentation.
    • +
    • Standards-based: Based on (and fully compatible with) the open standards +for APIs: OpenAPI (previously known as Swagger) and JSON Schema.
    • +
    • Open Source: FastAPI is fully open-source, under the MIT license.
    • +
    +

    The first step is to install the fastapi addon. You can do it with the +following command:

    +
    +$ pip install odoo-fastapi
    +

    Once the addon is installed, you can start building your API. The first thing +you need to do is to create a new addon that depends on ‘fastapi’. For example, +let’s create an addon called my_demo_api.

    +

    Then, you need to declare your app by defining a model that inherits from +‘fastapi.endpoint’ and add your app name into the app field. For example:

    +
    +from odoo import fields, models
    +
    +class FastapiEndpoint(models.Model):
    +
    +    _inherit = "fastapi.endpoint"
    +
    +    app: str = fields.Selection(
    +        selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"}
    +    )
    +
    +

    The ‘fastapi.endpoint’ model is the base model for all the endpoints. An endpoint +instance is the mount point for a fastapi app into Odoo. When you create a new +endpoint, you can define the app that you want to mount in the ‘app’ field +and the path where you want to mount it in the ‘path’ field.

    +

    figure:: static/description/endpoint.png

    +
    +FastAPI Endpoint
    +

    Thanks to the ‘fastapi.endpoint’ model, you can create as many endpoints as +you wand and mount as many apps as you want in each endpoint. The endpoint is +also the place where you can define configuration parameters for your app. A +typical example is the authentication method that you want to use for your app +when accessed at the endpoint path.

    +

    Now, you can create your first router. For that, you need to define a global +variable into your fastapi_endpoint module called for example ‘demo_api_router’

    +
    +from fastapi import APIRouter
    +from odoo import fields, models
    +
    +class FastapiEndpoint(models.Model):
    +
    +    _inherit = "fastapi.endpoint"
    +
    +    app: str = fields.Selection(
    +        selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"}
    +    )
    +
    +# create a router
    +demo_api_router = APIRouter()
    +
    +

    To make your router available to your app, you need to add it to the list of routers +returned by the get_fastapi_routers method of your fastapi_endpoint model.

    +
    +from fastapi import APIRouter
    +from odoo import api, fields, models
    +
    +class FastapiEndpoint(models.Model):
    +
    +    _inherit = "fastapi.endpoint"
    +
    +    app: str = fields.Selection(
    +        selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"}
    +    )
    +
    +    @api.model
    +    def get_fastapi_routers(self):
    +        if self.app == "demo":
    +            return [demo_api_router]
    +        return super().get_fastapi_routers()
    +
    +# create a router
    +demo_api_router = APIRouter()
    +
    +

    Now, you can start adding routes to your router. For example, let’s add a route +that returns a list of partners.

    +
    +from fastapi import APIRouter
    +from pydantic import BaseModel
    +from odoo import api, fields, models
    +from ..depends import odoo_env
    +
    +class FastapiEndpoint(models.Model):
    +
    +    _inherit = "fastapi.endpoint"
    +
    +    app: str = fields.Selection(
    +        selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"}
    +    )
    +
    +    @api.model
    +    def get_fastapi_routers(self):
    +        if self.app == "demo":
    +            return [demo_api_router]
    +        return super().get_fastapi_routers()
    +
    +# create a router
    +demo_api_router = APIRouter()
    +
    +class PartnerInfo(BaseModel):
    +    name: str
    +    email: str
    +
    +@demo_api_router.get("/partners", response_model=list[PartnerInfo])
    +def get_partners(env=Depends(odoo_env)) -> list[PartnerInfo]:
    +    return [
    +        PartnerInfo(name=partner.name, email=partner.email)
    +        for partner in env["res.partner"].search([])
    +    ]
    +
    +

    Now, you can start your Odoo server, install your addon and create a new endpoint +instance for your app. Once it’s done click on the docs url to access the +interactive documentation of your app.

    +
    +
    +

    Dealing with the odoo environment

    +

    The ‘odoo.addons.fastapi.depends’ module provides a set of functions that you can use +to inject reusable dependencies into your routes. For example, the ‘odoo_env’ +function returns the current odoo environment. You can use it to access the +odoo models and the database from your route handlers.

    +
    +from ..depends import odoo_env
    +
    +@demo_api_router.get("/partners", response_model=list[PartnerInfo])
    +def get_partners(env=Depends(odoo_env)) -> list[PartnerInfo]:
    +    return [
    +        PartnerInfo(name=partner.name, email=partner.email)
    +        for partner in env["res.partner"].search([])
    +    ]
    +
    +

    As you can see, you can use the ‘Depends’ function to inject the dependency +into your route handler. The ‘Depends’ function is provided by the +‘fastapi’ module. You can use it to inject any dependency into your route +handler. As your handler is a python function, the only way to get access to +the odoo environment is to inject it as a dependency. The fastapi addon provides +a set of function that can be used as dependencies:

    +
      +
    • ‘odoo_env’: Returns the current odoo environment.
    • +
    • ‘fastapi_endpoint’: Returns the current fastapi endpoint model instance.
    • +
    • ‘authenticated_partner’: Returns the authenticated partner.
    • +
    +

    By default, the ‘odoo_env’ and ‘fastapi_endpoint’ dependencies are +available without extra work.

    +
    +
    +

    The dependency injection mechanism

    +

    The ‘odoo_env’ dependency relies on a simple implementation that retrieves +the current odoo environment from ContextVar variable initialized at the start +of the request processing by the specific request dispatcher processing the +fastapi requests.

    +

    The ‘fastapi_endpoint’ dependency relies on the ‘dependency_overrides’ mechanism +provided by the ‘fastapi’ module. (see the fastapi documentation for more +details about the dependency_overrides mechanism). If you take a look at the +current implementation of the ‘fastapi_endpoint’ dependency, you will see +that the method depends of two parameters: ‘endpoint_id’ and ‘env’. Each +of these parameters are dependencies themselves.

    +
    +def fastapi_endpoint_id() -> int:
    +    """This method is overriden by default to make the fastapi.endpoint record
    +    available for your endpoint method. To get the fastapi.endpoint record
    +    in your method, you just need to add a dependency on the fastapi_endpoint method
    +    defined below
    +    """
    +
    +
    +def fastapi_endpoint(
    +    _id: int = Depends(fastapi_endpoint_id),  # noqa: B008
    +    env: Environment = Depends(odoo_env),  # noqa: B008
    +) -> "FastapiEndpoint":
    +    """Return the fastapi.endpoint record
    +
    +    Be careful, the information are returned as sudo
    +    """
    +    # TODO we should declare a technical user with read access only on the
    +    # fastapi.endpoint model
    +    return env["fastapi.endpoint"].sudo().browse(_id)
    +
    +

    As you can see, one of these dependencies is the ‘fastapi_endpoint_id’ +dependency and has no concrete implementation. This method is used as a contract +that must be implemented/provided at the time the fastapi app is created. +Here comes the power of the dependency_overrides mechanism. +If you take a look at the ‘_get_app’ method of the ‘FastapiEndpoint’ model, +you will see that the ‘fastapi_endpoint_id’ dependency is overriden by +registering a specific method that returns the id of the current fastapi endpoint +model instance for the original method.

    +
    +def _get_app(self) -> FastAPI:
    +    app = FastAPI(**self._prepare_fastapi_endpoint_params())
    +    for router in self._get_fastapi_routers():
    +        app.include_router(prefix=self.root_path, router=router)
    +    app.dependency_overrides[depends.fastapi_endpoint_id] = partial(
    +        lambda a: a, self.id
    +    )
    +
    +

    This kind of mechanism is very powerful and allows you to inject any dependency +into your route handlers and moreover, define an abstract dependency that can be +used by any other addon and for which the implementation could depend on the +endpoint configuration.

    +
    +
    +

    The authentication mechanism

    +

    To make our app not tightly coupled with a specific authentication mechanism, +we will use the ‘authenticated_partner’ dependency. As for the +‘fastapi_endpoint’ this dependency depends on an abstract dependency.

    +

    When you define a route handler, you can inject the ‘authenticated_partner’ +dependency as a parameter of your route handler.

    +
    +@demo_api_router.get("/partners", response_model=list[PartnerInfo])
    +def get_partners(
    +    env=Depends(odoo_env), partner=Depends(authenticated_partner)
    +) -> list[PartnerInfo]:
    +    return [
    +        PartnerInfo(name=partner.name, email=partner.email)
    +        for partner in env["res.partner"].search([])
    +    ]
    +
    +

    At this stage, your handler is not tied to a specific authentication mechanism +but only expects to get a partner as a dependency. Depending on your needs, you +can implement different authentication mechanism available for your app. +The fastapi addon provides a default authentication mechanism using the +‘BasiAuth’ method. This authentication mechanism is implemented in the +‘odoo.addons.fastapi.depends’ module and relies on functionalities provided +by the ‘fastapi.security’ module.

    +
    +def authenticated_partner(
    +    env: Environment = Depends(odoo_env),
    +    security: HTTPBasicCredentials = Depends(HTTPBasic()),
    +) -> "res.partner":
    +    """Return the authenticated partner"""
    +    partner = env["res.partner"].search(
    +        [("email", "=", security.username)], limit=1
    +    )
    +    if not partner:
    +        raise HTTPException(
    +            status_code=status.HTTP_401_UNAUTHORIZED,
    +            detail="Invalid authentication credentials",
    +            headers={"WWW-Authenticate": "Basic"},
    +        )
    +    if not partner.check_password(security.password):
    +        raise HTTPException(
    +            status_code=status.HTTP_401_UNAUTHORIZED,
    +            detail="Invalid authentication credentials",
    +            headers={"WWW-Authenticate": "Basic"},
    +        )
    +    return partner
    +
    +

    As you can see, the ‘authenticated_partner’ dependency relies on the +‘HTTPBasic’ dependency provided by the ‘fastapi.security’ module. +In this dummy implementation, we just check that the provided credentials +can be used to authenticate a user in odoo. If the authentication is successful, +we return the partner record linked to the authenticated user.

    +

    In some cas you could want to implement a more complex authentication mechanism +that could rely on a token or a session. In this case, you can override the +‘authenticated_partner’ dependency by registering a specific method that +returns the authenticated partner. Moreover, you can make it configurable on +the fastapi endpoint model instance.

    +

    To do it, you just need to implement a specific method for each of your +authentication mechanism and allows the user to select one of these methods +when he creates a new fastapi endpoint. Let’s say that we want to allow the +authentication by using an api key or via basic auth. Since basic auth is already +implemented, we will only implement the api key authentication mechanism.

    +
    +from fastapi.security import APIKeyHeader
    +
    +def api_key_based_authenticated_partner_impl(
    +    api_key: str = Depends(  # noqa: B008
    +        APIKeyHeader(
    +            name="api-key",
    +            description="In this demo, you can use a user's login as api key.",
    +        )
    +    ),
    +    env: Environment = Depends(odoo_env),  # noqa: B008
    +) -> Partner:
    +    """A dummy implementation that look for a user with the same login
    +    as the provided api key
    +    """
    +    partner = env["res.users"].search([("login", "=", api_key)], limit=1).partner_id
    +    if not partner:
    +        raise HTTPException(
    +            status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect API Key"
    +        )
    +    return partner
    +
    +

    As for the ‘BaseAuth’ authentication mechanism, we also rely one of the native +security dependency provided by the ‘fastapi.security’ module.

    +

    Now that we have an implementation for our two authentication mechanism, we +can allows the user to select one of these authentication mechanism by adding +a selection field on the fastapi endpoint model.

    +
    +from odoo import fields, models
    +
    +class FastapiEndpoint(models.Model):
    +
    +    _inherit = "fastapi.endpoint"
    +
    +    app: str = fields.Selection(
    +      selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"}
    +    )
    +    demo_auth_method = fields.Selection(
    +        selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")],
    +        string="Authenciation method",
    +    )
    +
    +
    +

    Note

    +

    A good practice is to prefix specific configuration fields of your app with +the name of your app. This will avoid conflicts with other app when the +‘fastapi.endpoint’ model is extended for other ‘app’.

    +
    +

    Now that we have a selection field that allows the user to select the +authentication method, we can use the dependency override mechanism to +provide the right implementation of the ‘authenticated_partner’ dependency +when the app is instantiated.

    +
    +from odoo.addons.fastapi import depends
    +from odoo.addons.fastapi.depends import authenticated_partner
    +class FastapiEndpoint(models.Model):
    +
    +    _inherit = "fastapi.endpoint"
    +
    +    app: str = fields.Selection(
    +      selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"}
    +    )
    +    demo_auth_method = fields.Selection(
    +        selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")],
    +        string="Authenciation method",
    +    )
    +
    +  def _get_app(self) -> FastAPI:
    +      app = super()._get_app()
    +      if self.app == "demo":
    +          # Here we add the overrides to the authenticated_partner_impl method
    +          # according to the authentication method configured on the demo app
    +          if self.demo_auth_method == "http_basic":
    +              authenticated_partner_impl_override = (
    +                  authenticated_partner_from_basic_auth_user
    +              )
    +          else:
    +              authenticated_partner_impl_override = (
    +                  api_key_based_authenticated_partner_impl
    +              )
    +      app.dependency_overrides[
    +          authenticated_partner_impl
    +      ] = authenticated_partner_impl_override
    +      return app
    +
    +

    To see how the dependency override mechanism works, you can take a look at the +demo app provided by the fastapi addon. If you choose the app ‘demo’ in the +fastapi endpoint form view, you will see that the authentication method +is configurable. You can also see that depending on the authentication method +configured on your fastapi endpoint, the documentation will change.

    +
    +

    Note

    +

    A time of writing, the dependency override mechanism is not supported by +the fastapi documentation generator. A fix has been proposed and is waiting +to be merged. You can follow the progress of the fix on github

    +
    +
    +
    +

    Managing configuration parameters for your app

    +

    As we have seen in the previous section, you can add configuration fields +on the fastapi endpoint model to allow the user to configure your app (as for +any odoo model you extend). When you need to access these configuration fields +in your route handlers, you can use the ‘odoo.addons.fastapi.depends.fastapi_endpoint’ +dependency method to retrieve the ‘fastapi.endpoint’ record associated to the +current request.

    +
    +from pydantic import BaseModel, Field
    +from odoo.addons.fastapi.depends import fastapi_endpoint
    +
    +class EndpointAppInfo(BaseModel):
    +  id: str
    +  name: str
    +  app: str
    +  auth_method: str = Field(alias="demo_auth_method")
    +  root_path: str
    +
    +  class Config:
    +      orm_mode = True
    +
    +  @demo_api_router.get(
    +      "/endpoint_app_info",
    +      response_model=EndpointAppInfo,
    +      dependencies=[Depends(authenticated_partner)],
    +  )
    +  async def endpoint_app_info(
    +      endpoint: FastapiEndpoint = Depends(fastapi_endpoint),  # noqa: B008
    +  ) -> EndpointAppInfo:
    +      """Returns the current endpoint configuration"""
    +      # This method show you how to get access to current endpoint configuration
    +      # It also show you how you can specify a dependency to force the security
    +      # even if the method doesn't require the authenticated partner as parameter
    +      return EndpointAppInfo.from_orm(endpoint)
    +
    +

    Some of the configuration fields of the fastapi endpoint could impact the way +the app is instantiated. For example, in the previous section, we have seen +that the authentication method configured on the ‘fastapi.endpoint’ record is +used in order to provide the right implementation of the ‘authenticated_partner’ +when the app is instantiated. To ensure that the app is re-instantiated when +an element of the configuration used in the instantiation of the app is +modified, you must override the ‘_fastapi_app_fields’ method to add the +name of the fields that impact the instantiation of the app into the returned +list.

    +
    +class FastapiEndpoint(models.Model):
    +
    +    _inherit = "fastapi.endpoint"
    +
    +    app: str = fields.Selection(
    +      selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"}
    +    )
    +    demo_auth_method = fields.Selection(
    +        selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")],
    +        string="Authenciation method",
    +    )
    +
    +    @api.model
    +    def _fastapi_app_fields(self) -> List[str]:
    +        fields = super()._fastapi_app_fields()
    +        fields.append("demo_auth_method")
    +        return fields
    +
    +
    +
    +

    How to extend an existing app

    +

    When you develop a fastapi app, in a native python app it’s not possible +to extend and existing one. This limitation doesn’t apply to the fastapi addon +because the fastapi endpoint model is designed to be extended. However, the +way to extend an existing app is not the same as the way to extend an odoo model.

    +

    First of all, it’s important to keep in mind that when you define a route, you +are actually defining a contract between the client and the server. This +contract is defined by the route path, the method (GET, POST, PUT, DELETE, +etc.), the parameters and the response. If you want to extend an existing app, +you must ensure that the contract is not broken. Any change to the contract +will respect the Liskov substitution principle. This means +that the client should not be impacted by the change.

    +

    What does it mean in practice? It means that you can’t change the route path +or the method of an existing route. You can’t change the name of a parameter +or the type of a response. You can’t add a new parameter or a new response. +You can’t remove a parameter or a response. If you want to change the contract, +you must create a new route.

    +

    What can you change?

    +
      +
    • You can change the implementation of the route handler.
    • +
    • You can override the dependencies of the route handler.
    • +
    • You can add a new route handler.
    • +
    • You can extend the model used as parameter or as response of the route handler.
    • +
    +

    Let’s see how to do that.

    +
    +

    Changing the implementation of the route handler

    +

    Let’s say that you want to change the implementation of the route handler +‘/demo/echo’. Since a route handler is just a python method, it could seems +a tedious task since we are not into a model method and therefore we can’t +take advantage of the Odoo inheritance mechanism.

    +

    However, the fastapi addon provides a way to do that. Thanks to the ‘odoo_env’ +dependency method, you can access the current odoo environment. With this +environment, you can access the registry and therefore the model you want to +delegate the implementation to. If you want to change the implementation of +the route handler ‘/demo/echo’, the only thing you have to do is to +inherit from the model where the implementation is defined and override the +method ‘echo’.

    +
    +from pydantic import BaseModel
    +from fastapi import Depends, APIRouter
    +from odoo import models
    +from odoo.addons.fastapi.depends import odoo_env
    +
    +class FastapiEndpoint(models.Model):
    +
    +    _inherit = "fastapi.endpoint"
    +
    +    def _get_fastapi_routers(self) -> List[APIRouter]:
    +        routers = super()._get_fastapi_routers()
    +        routers.append(demo_api_router)
    +        return routers
    +
    +demo_api_router = APIRouter()
    +
    +@demo_api_router.get(
    +    "/echo",
    +    response_model=EchoResponse,
    +    dependencies=[Depends(odoo_env)],
    +)
    +async def echo(
    +    message: str,
    +    odoo_env: OdooEnv = Depends(odoo_env),
    +) -> EchoResponse:
    +    """Echo the message"""
    +    return EchoResponse(message=odoo_env["demo.fastapi.endpoint"].echo(message))
    +
    +class EchoResponse(BaseModel):
    +    message: str
    +
    +class DemoEndpoint(models.AbstractModel):
    +
    +    _name = "demo.fastapi.endpoint"
    +    _description = "Demo Endpoint"
    +
    +    def echo(self, message: str) -> str:
    +        return message
    +
    +class DemoEndpointInherit(models.AbstractModel):
    +
    +    _inherit = "demo.fastapi.endpoint"
    +
    +    def echo(self, message: str) -> str:
    +        return f"Hello {message}"
    +
    +

    ..note:

    +
    +It's a good programming practice to implement the business logic outside
    +the route handler. This way, you can easily test your business logic without
    +having to test the route handler. In the example above, the business logic
    +is implemented in the method **'echo'** of the model **'demo.fastapi.endpoint'**.
    +The route handler just delegate the implementation to this method.
    +
    +
    +
    +

    Overriding the dependencies of the route handler

    +

    As you’ve previously seen, the dependency injection mechanism of fastapi is +very powerful. By designing your route handler to rely on dependencies with +a specific functional scope, you can easily change the implementation of the +dependency without having to change the route handler. With such a design, you +can even define abstract dependencies that must be implemented by the concrete +application. This is the case of the ‘authenticated_partner’ dependency in our +previous example. (you can find the implementation of this dependency in the +file ‘odoo/addons/fastapi/depends.py’ and it’s usage in the file +‘odoo/addons/fastapi/models/fastapi_endpoint_demo.py’)

    +
    +
    +

    Adding a new route handler

    +

    Let’s say that you want to add a new route handler ‘/demo/echo2’. +You could be tempted to add this new route handler in your new addons by +importing the router of the existing app and adding the new route handler to +it.

    +
    +from odoo.addons.fastapi.models.fastapi_endpoint_demo import demo_api_router
    +
    +@demo_api_router.get(
    +    "/echo2",
    +    response_model=EchoResponse,
    +    dependencies=[Depends(odoo_env)],
    +)
    +async def echo2(
    +    message: str,
    +    odoo_env: OdooEnv = Depends(odoo_env),
    +) -> EchoResponse:
    +    """Echo the message"""
    +    echo = odoo_env["demo.fastapi.endpoint"].echo2(message)
    +    return EchoResponse(message=f"Echo2: {echo}")
    +
    +

    The problem with this approach is that you unconditionally add the new route +handler to the existing app even if the app is called for a different database +where your new addon is not installed.

    +

    The solution is to define a new router and to add it to the list of routers +returned by the method ‘_get_fastapi_routers’ of the model +‘fastapi.endpoint’ you are inheriting from into your new addon.

    +
    +class FastapiEndpoint(models.Model):
    +
    +    _inherit = "fastapi.endpoint"
    +
    +    def _get_fastapi_routers(self) -> List[APIRouter]:
    +        routers = super()._get_fastapi_routers()
    +        if self.app == "demo":
    +            routers.append(additional_demo_api_router)
    +        return routers
    +
    +additional_demo_api_router = APIRouter()
    +
    +@additional_demo_api_router.get(
    +    "/echo2",
    +    response_model=EchoResponse,
    +    dependencies=[Depends(odoo_env)],
    +)
    +async def echo2(
    +    message: str,
    +    odoo_env: OdooEnv = Depends(odoo_env),
    +) -> EchoResponse:
    +    """Echo the message"""
    +    echo = odoo_env["demo.fastapi.endpoint"].echo2(message)
    +    return EchoResponse(message=f"Echo2: {echo}")
    +
    +

    In this way, the new router is added to the list of routers of your app only if +the app is called for a database where your new addon is installed.

    +
    +
    +

    Extending the model used as parameter or as response of the route handler

    +

    The fastapi python library uses the pydantic library to define the models. By +default, once a model is defined, it’s not possible to extend it. However, a +companion python library called +extendable_pydantic provides +a way to use inheritance with pydantic models to extend an existing model. If +used alone, it’s your responsibility to instruct this library the list of +extensions to apply to a model and the order to apply them. This is not very +convenient. Fortunately, an dedicated odoo addon exists to make this process +complete transparent. This addon is called +odoo-addon-extendable.

    +

    When you want to allow other addons to extend a pydantic model, you must +first define the model as an extendable model by using a dedicated metaclass

    +
    +from pydantic import BaseModel
    +from extendable_pydantic import ExtendableModelMeta
    +
    +class Partner(BaseModel, metaclass=ExtendableModelMeta):
    +  name = 0.1
    +
    +

    As any other pydantic model, you can now use this model as parameter or as response +of a route handler. You can also use all the features of models defined with +pydantic.

    +
    +@demo_api_router.get(
    +    "/partner",
    +    response_model=Location,
    +    dependencies=[Depends(authenticated_partner)],
    +)
    +async def partner(
    +    partner: ResPartner = Depends(authenticated_partner),
    +) -> Partner:
    +    """Return the location"""
    +    return Partner.from_orm(partner)
    +
    +

    If you need to add a new field into the model ‘Partner’, you can extend it +in your new addon by defining a new model that inherits from the model ‘Partner’.

    +
    +from typing import Optional
    +from odoo.addons.fastapi.models.fastapi_endpoint_demo import Partner
    +
    +class PartnerExtended(Partner, extends=Partner):
    +    email: Optional[str]
    +
    +

    If your new addon is installed in a database, a call to the route handler +‘/demo/partner’ will return a response with the new field ‘email’ if a +value is provided by the odoo record.

    +
    +{
    +  "name": "John Doe",
    +  "email": "jhon.doe@acsone.eu"
    +}
    +
    +

    If your new addon is not installed in a database, a call to the route handler +‘/demo/partner’ will only return the name of the partner.

    +
    +{
    +  "name": "John Doe"
    +}
    +
    +

    ..note:

    +
    +The liskov substitution principle has also to be respected. That means that
    +if you extend a model, you must add new required fields or you must provide
    +default values for the new optional fields.
    +
    +
    +
    +
    +

    Managing security into the route handlers

    +

    By default the route handlers are processed the user configured on the +‘fastapi.endpoint’ model instance. (default is the Public user). +You have seen previously how to define a dependency that will be used to enforce +the authentication of a partner. When a method depends on this dependency, the +‘authenticated_partner_id’ key is added to the context of the partner environment. +(If you don’t need the partner as dependency but need to get an environment +with the authenticated user, you can use the dependency ‘authenticated_partner_env’ instead of +‘authenticated_partner’.)

    +

    The fastapi addon extends the ‘ir.rule’ model to add into the evaluation context +of the security rules the key ‘authenticated_partner_id’ that contains the id +of the authenticated partner.

    +

    A goog practice when you develop a fastapi app and you want to protect your data +in an efficient and traceable way is to:

    +
      +
    • create a new user specific to the app but with any access rights.
    • +
    • create a security group specific to the app and add the user to this group.
    • +
    • for each model you want to protect:
        +
      • add a ‘ir.model.access’ record for the model to allow read access to your model +and add the group to the record.
      • +
      • create a new ‘ir.rule’ record for the model that restricts the access to the +records of the model to the authenticated partner by using the key +‘authenticated_partner_id’ in domain of the rule.
    • +
    • add a dependency on the ‘authenticated_partner’ to your handlers when you need +to access the authenticated partner or ensure that the service is called by an +authenticated partner.
    +
    +<record id="demo_app_user" model="res.users">
    +  <field name="name">My Demo App User</field>
    +  <field name="login">demo_app_user</field>
    +</record>
    +
    +<record id="demo_app_group" model="res.groups">
    +  <field name="name">My Demo App</field>
    +  <field name="users" eval="[(4, ref('demo_app_user'))]"/>
    +</record>
    +
    +<!-- acl for the model 'sale.order' -->
    +<record id="sale_order_demo_app_access" model="ir.model.access">
    +  <field name="name">My Demo App: access to sale.order</field>
    +  <field name="model_id" ref="model_sale_order"/>
    +  <field name="group_id" ref="demo_app_group"/>
    +  <field name="perm_read" eval="True"/>
    +  <field name="perm_write" eval="False"/>
    +  <field name="perm_create" eval="False"/>
    +  <field name="perm_unlink" eval="False"/>
    +</record>
    +
    +<!-- a record rule to allows the authenticated partner to access only its sale orders -->
    +<record id="demo_app_sale_order_rule" model="ir.rule">
    +  <field name="name">Sale Order Rule</field>
    +  <field name="model_id" ref="model_sale_order"/>
    +  <field name="domain_force">[('partner_id', '=', authenticated_partner_id)]</field>
    +  <field name="groups" eval="[(4, ref('demo_app_group'))]"/>
    +</record>
    +
    +
    +
    +

    How to test your fastapi app

    +

    Thanks to the starlette test client, it’s possible to test your fastapi app +in a very simple way. With the test client, you can call your route handlers +as if they were real http endpoints. The test client is available in the +‘fastapi.testclient’ module.

    +

    Once again the dependency injection mechanism comes to the rescue by allowing +you to inject into the test client specific implementations of the dependencies +normally provided by the normal processing of the request by the fastapi app. +(for example, you can inject a mock of the dependency ‘authenticated_partner’ +to test the behavior of your route handlers when the partner is not authenticated, +you can also inject a mock for the odoo_env etc…)

    +

    With all these features, writing a test for the ‘Hello world’ route handler +defined into the demo app is as simple as

    +
    +from functools import partial
    +
    +from requests import Response
    +
    +from odoo.tests.common import TransactionCase
    +
    +from fastapi.testclient import TestClient
    +
    +from .. import depends
    +from ..context import odoo_env_ctx
    +
    +
    +class FastAPIDemoCase(TransactionCase):
    +
    +    @classmethod
    +    def setUpClass(cls) -> None:
    +        super().setUpClass()
    +        cls.test_partner = cls.env["res.partner"].create({"name": "FastAPI Demo"})
    +        cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo")
    +        cls.app = cls.fastapi_demo_app._get_app()
    +        cls.app.dependency_overrides[depends.authenticated_partner_impl] = partial(
    +            lambda a: a, cls.test_partner
    +        )
    +        cls.client = TestClient(cls.app)
    +        cls._ctx_token = odoo_env_ctx.set(cls.env)
    +
    +    @classmethod
    +    def tearDownClass(cls) -> None:
    +        odoo_env_ctx.reset(cls._ctx_token)
    +        cls.fastapi_demo_app._reset_app()
    +
    +        super().tearDownClass()
    +
    +    def _get_path(self, path) -> str:
    +        return self.fastapi_demo_app.root_path + path
    +
    +    def test_hello_world(self) -> None:
    +        response: Response = self.client.get(self._get_path("/"))
    +        self.assertEqual(response.status_code, status.HTTP_200_OK)
    +        self.assertDictEqual(response.json(), {"Hello": "World"})
    +
    +
    +

    Overall considerations when you develop an fastapi app

    +

    Developing a fastapi app requires to follow some good practices to ensure that +the app is robust and easy to maintain. Here are some of them:

    +
      +
    • A route handler must be as simple as possible. It must not contain any +business logic. The business logic must be implemented into the service +layer. The route handler must only call the service layer and return the +result of the service layer. To ease extension on your business logic, your +service layer can be implemented as an odoo abstract model that can be +inherited by other addons.
    • +
    • A route handler should not expose the internal data structure and api of Odoo. +It should provide the api that is needed by the client. More widely, an app +provides a set of services that address a set of use cases specific to +a well defined functional domain. You must always keep in mind that your api +will remain the same for a long time even if you upgrade your odoo version +of modify your business logic.
    • +
    • A route handler is a transactional unit of work. When you design your api +you must ensure that the completeness of a use case is guaranteed by a single +transaction. If you need to perform several transactions to complete a use +case, you introduce a risk of inconsistency in your data or extra complexity +in your client code.
    • +
    • Properly handle the errors. The route handler must return a proper error +response when an error occurs. The error response must be consistent with +the rest of the api. The error response must be documented in the api +documentation. By default, the ‘odoo-addon-fastapi’ module handles +the common exception types defined in the ‘odoo.exceptions’ module +and returns a proper error response with the corresponding http status code. +An error in the route handler must always return an error response with a +http status code different from 200. The error response must contain a +human readable message that can be displayed to the user. The error response +can also contain a machine readable code that can be used by the client to +handle the error in a specific way.
    • +
    • When you design your json document through the pydantic models, you must +use the appropriate data types. For example, you must use the data type +‘datetime.date’ to represent a date and not a string. You must also +properly define the constraints on the fields. For example, if a field +is optional, you must use the data type ‘typing.Optional’. +pydantic provides everything you need to +properly define your json document.
    • +
    • Always use an appropriate pydantic model as request and/or response for +your route handler. Constraints on the fields of the pydantic model must +apply to the specific use case. For example, il you route handler is used +to create a sale order, the pydantic model must not contain the field +‘id’ because the id of the sale order will be generated by the route handler. +But if the id is required afterwords, the pydantic model for the response +must contain the field ‘id’ as required.
    • +
    • Uses descriptive property names in your json documents. For example, avoid the +use of documents providing a flat list of key value pairs.
    • +
    • Be consistent in the naming of your fields into your json documents. For example, +if you use ‘id’ to represent the id of a sale order, you must use ‘id’ to represent +the id of all the other objects.
    • +
    • Be consistent in the naming style of your fields. Always prefer underscore +to camel case.
    • +
    • Always use plural for the name of the fields that contain a list of items. +For example, if you have a field ‘lines’ that contains a list of sale order +lines, you must use ‘lines’ and not ‘line’.
    • +
    • You can’t expect that a client will provide you the identifier of a specific +record in odoo (for example the id of a carrier) if you don’t provide a +specific route handler to retrieve the list of available records. Sometimes, +the client must share with odoo the identity of a specific record to be +able to perform an appropriate action specific to this record (for example, +the processing of a payment is different for each payment acquirer). In this +case, you must provide a specific attribute that allows both the client and +odoo to identify the record. The field ‘provider’ on a payment acquirer allows +you to identify a specific record in odoo. This kind of approach +allows both the client and odoo to identify the record without having to rely +on the id of the record. (This will ensure that the client will not break +if the id of the record is changed in odoo for example when tests are run +on an other database).
    • +
    • Always use the same name for the same kind of object. For example, if you +have a field ‘lines’ that contains a list of sale order lines, you must +use the same name for the same kind of object in all the other json documents.
    • +
    • Manage relations between objects in your json documents the same way. +By default, you should return the id of the related object in the json document. +But this is not always possible or convenient, so you can also return the +related object in the json document. The main advantage of returning the id +of the related object is that it allows you to avoid the n+1 problem . The +main advantage of returning the related object in the json document is that +it allows you to avoid an extra call to retrieve the related object. +By keeping in mind the pros and cons of each approach, you can choose the +best one for your use case. Once it’s done, you must be consistent in the +way you manage the relations of the same object.
    • +
    • It’s not always a good idea to name your fields into your json documents +with the same name as the fields of the corresponding odoo model. For example, +in your document representing a sale order, you must not use the name ‘order_line’ +for the field that contains the list of sale order lines. The name ‘order_line’ +in addition to being confusing and not consistent with the best practices, is +not auto-descriptive. The name ‘lines’ is much better.
    • +
    • Keep a defensive programming approach. If you provide a route handler that +returns a list of records, you must ensure that the computation of the list +is not too long or will not drain your server resources. For example, +for search route handlers, you must ensure that the search is limited to +a reasonable number of records by default.
    • +
    • As a corollary of the previous point, a search handler must always use the +pagination mechanism with a reasonable default page size. The result list +must be enclosed in a json document that contains the total number of records +and the list of records.
    • +
    • Use plural for the name of a service. For example, if you provide a service +that allows you to manage the sale orders, you must use the name ‘sale_orders’ +and not ‘sale_order’.
    • +
    • … and many more.
    • +
    +

    We could write a book about the best practices to follow when you design your api +but we will stop here. This list is the result of our experience at ACSONE SA/NV and it evolve over time. It’s a kind of rescue kit that we +would provide to a new developer that starts to design an api. This kit must +be accompanied with the reading of some useful resources link like the REST Guidelines. On a technical level, +the fastapi documentation provides a lot of +useful information as well, with a lot of examples. Last but not least, the +pydantic documentation is also very useful.

    +
    +
    +

    Miscellaneous

    +
    +

    Development of a search route handler

    +

    The ‘odoo-addon-fastapi’ module provides 2 useful piece of code to help +you be consistent when writing a route handler for a search route.

    +
      +
    1. A dependency method to use to specify the pagination parameters in the same +way for all the search route handlers: ‘odoo.addons.fastapi.paging’.
    2. +
    3. A PagedCollection pydantic model to use to return the result of a search route +handler enclosed in a json document that contains the total number of records.
    4. +
    +
    +from pydantic import BaseModel
    +
    +from odoo.api import Environment
    +from odoo.addons.fastapi.depends import paging, authenticated_partner_env
    +from odoo.addons.fastapi.schemas import PagedCollection, Paging
    +
    +class SaleOrder(BaseModel):
    +    id: int
    +    name: str
    +
    +
    +@router.get(
    +    "/sale_orders",
    +    response_model=PagedCollection[SaleOrder],
    +    response_model_exclude_unset=True,
    +)
    +def get_sale_orders(
    +    paging: Paging = Depends(paging),
    +    env: Environment = Depends(authenticated_partner_env),
    +) -> PagedCollection[SaleOrder]:
    +    """Get the list of sale orders."""
    +    count = env["sale.order"].search_count([])
    +    orders = env["sale.order"].search([], limit=paging.limit, offset=paging.offset)
    +    return PagedCollection[SaleOrder](
    +        total=count,
    +        items=[SaleOrder.from_orm(order) for order in orders],
    +    )
    +
    +
    +

    Note

    +

    The ‘odoo.addons.fastapi.schemas.Paging’ and ‘odoo.addons.fastapi.schemas.PagedCollection’ +pydantic models are not designed to be extended to not introduce a +dependency between the ‘odoo-addon-fastapi’ module and the ‘odoo-addon-extendable’ +Moreover, at the time of writing, the ‘extendable-pydantic’ library does not +support Generic models. Nevertheless, a pull request has been submitted to +add this feature to the library. (see PR 1)

    +
    +
    +
    +

    Customization of the error handling

    +

    The error handling a very important topic in the design of the fastapi integration +with odoo. It must ensure that the error messages are properly return to the client +and that the transaction is properly roll backed. The ‘fastapi’ module provides +a way to register custom error handlers. The ‘odoo.addons.fastapi.error_handlers’ +module provides the default error handlers that are registered by default when +a new instance of the ‘FastAPI’ class is created. When an app is initialized in +‘fastapi.endpoint’ model, the method _get_app_exception_handlers is called to +get a dictionary of error handlers. This method is designed to be overridden +in a custom module to provide custom error handlers. You can override the handler +for a specific exception class or you can add a new handler for a new exception +or even replace all the handlers by your own handlers. Whatever you do, you must +ensure that the transaction is properly roll backed.

    +

    Some could argue that the error handling can’t be extended since the error handlers +are global method not defined in an odoo model. Since the method providing the +the error handlers definitions is defined on the ‘fastapi.endpoint’ model, it’s +not a problem at all, you just need to think another way to do it that by inheritance.

    +

    A solution could be to develop you own error handler to be able to process the +error and chain the call to the default error handler.

    +
    +class MyCustomErrorHandler():
    +    def __init__(self, next_handler):
    +        self.next_handler = next_handler
    +
    +    def __call__(self, request: Request, exc: Exception) -> JSONResponse:
    +        # do something with the error
    +        response = self.next_handler(request, exc)
    +        # do something with the response
    +        return response
    +
    +

    With this solution, you can now register your custom error handler by overriding +the method _get_app_exception_handlers in your custom module.

    +
    +class FastapiEndpoint(models.Model):
    +    _inherit = "fastapi.endpoint"
    +
    +    def _get_app_exception_handlers(
    +    self,
    +) -> Dict[
    +    int | Type[Exception],
    +    Callable[[Request, Exception], Union[Response, Awaitable[Response]]],
    +]:
    +        handlers = super()._get_app_exception_handlers()
    +        access_error_handler = handlers.get(odoo.exceptions.AccessError)
    +        handlers[odoo.exceptions.AccessError] = MyCustomErrorHandler(access_error_handler)
    +        return handlers
    +
    +

    In the previous example, we extend the error handler for the ‘AccessError’ exception +for all the endpoints. You can do the same for a specific app by checking the +‘app’ field of the ‘fastapi.endpoint’ record before registering your custom error +handler.

    +
    +
    +
    +

    What’s next?

    +

    The ‘odoo-addon-fastapi’ module is still in its early stage of development. +It will evolve over time to integrate your feedback and to provide the missing +features. It’s now up to you to try it and to provide your feedback.

    -

    Known issues / Roadmap

    +

    Known issues / Roadmap

    The roadmap and known issues can be found on GitHub.

    +

    The FastAPI module provides an easy way to use WebSockets. Unfortunately, this +support is not ‘yet’ available in the Odoo framework. The challenge is high +because the integration of the fastapi is based on the use of a specific middleware +that convert the WSGI request consumed by odoo to a ASGI request. The question +is to know if it is also possible to develop the same kind of bridge for the +WebSockets.

    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us smashing it by providing a detailed and welcomed @@ -400,21 +1465,21 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • ACSONE SA/NV
    -

    Contributors

    +

    Contributors

    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association

    OCA, or the Odoo Community Association, is a nonprofit organization whose @@ -427,5 +1492,6 @@

    Maintainers

    + From d58e092f6975d90aac1dfd80205f2db6cff751d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sat, 10 Dec 2022 19:04:39 +0100 Subject: [PATCH 012/118] Fix a few typos --- fastapi/depends.py | 6 +++--- fastapi/models/fastapi_endpoint.py | 4 ++-- fastapi/models/ir_rule.py | 4 ++-- fastapi/readme/USAGE.rst | 16 ++++++++-------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/fastapi/depends.py b/fastapi/depends.py index 5da6b0636..eadfab694 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -50,7 +50,7 @@ def authenticated_partner( def authenticated_partner_env( partner: Partner = Depends(authenticated_partner), # noqa: B008 ) -> Environment: - """Return an environment the authenticated partner id into the context""" + """Return an environment with the authenticated partner id in the context""" return partner.env @@ -87,7 +87,7 @@ def authenticated_partner_from_basic_auth_user( def fastapi_endpoint_id() -> int: - """This method is overriden by default to make the fastapi.endpoint record + """This method is overriden by the FastAPI app to make the fastapi.endpoint record available for your endpoint method. To get the fastapi.endpoint record in your method, you just need to add a dependency on the fastapi_endpoint method defined below @@ -100,7 +100,7 @@ def fastapi_endpoint( ) -> "FastapiEndpoint": """Return the fastapi.endpoint record - Be careful, the information are returned as sudo + Be careful, the returned FastapiEndpoint record is a sudoed record. """ # TODO we should declare a technical user with read access only on the # fastapi.endpoint model diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index 5b1408461..32145369c 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -145,7 +145,7 @@ def _register_endpoints(self, init: bool = False): rec._endpoint_registry.add_or_update_rule(rule, init=init) def _make_routing_rule(self): - """Generator of rule for every route ino the routing info""" + """Generator of rule for every route into the routing info""" self.ensure_one() routing = self._get_routing_info() for route in routing["routes"]: @@ -261,7 +261,7 @@ def _prepare_fastapi_endpoint_params(self) -> Dict[str, Any]: } def _get_fastapi_routers(self) -> List[APIRouter]: - """Return the api routers to use for the innstance. + """Return the api routers to use for the instance. This methoud must be implemented when registering a new api type. """ diff --git a/fastapi/models/ir_rule.py b/fastapi/models/ir_rule.py index 20eefe816..b1d10fc59 100644 --- a/fastapi/models/ir_rule.py +++ b/fastapi/models/ir_rule.py @@ -16,7 +16,7 @@ class IrRule(models.Model): @api.model def _eval_context(self): - ctx = super(IrRule, self)._eval_context() + ctx = super()._eval_context() if "authenticated_partner_id" in self.env.context: ctx["authenticated_partner_id"] = self.env.context[ "authenticated_partner_id" @@ -25,4 +25,4 @@ def _eval_context(self): def _compute_domain_keys(self): """Return the list of context keys to use for caching ``_compute_domain``.""" - return super(IrRule, self)._compute_domain_keys() + ["authenticated_partner_id"] + return super()._compute_domain_keys() + ["authenticated_partner_id"] diff --git a/fastapi/readme/USAGE.rst b/fastapi/readme/USAGE.rst index 58a47598b..ff3339d1e 100644 --- a/fastapi/readme/USAGE.rst +++ b/fastapi/readme/USAGE.rst @@ -2,7 +2,7 @@ What's building an API with fastapi? ************************************ FastAPI is a modern, fast (high-performance), web framework for building APIs -with Python 3.6+ based on standard Python type hints. This addons let's you +with Python 3.7+ based on standard Python type hints. This addons let's you keep advantage of the fastapi framework and use it with Odoo. Before you start, we must define some terms: @@ -44,7 +44,7 @@ The FastAPI framework is based on the following principles: The first step is to install the fastapi addon. You can do it with the following command: - $ pip install odoo-fastapi + $ pip install odoo-addon-fastapi Once the addon is installed, you can start building your API. The first thing you need to do is to create a new addon that depends on 'fastapi'. For example, @@ -188,7 +188,7 @@ odoo models and the database from your route handlers. As you can see, you can use the **'Depends'** function to inject the dependency into your route handler. The **'Depends'** function is provided by the -**'fastapi'** module. You can use it to inject any dependency into your route +**'fastapi'** framework. You can use it to inject any dependency into your route handler. As your handler is a python function, the only way to get access to the odoo environment is to inject it as a dependency. The fastapi addon provides a set of function that can be used as dependencies: @@ -288,7 +288,7 @@ At this stage, your handler is not tied to a specific authentication mechanism but only expects to get a partner as a dependency. Depending on your needs, you can implement different authentication mechanism available for your app. The fastapi addon provides a default authentication mechanism using the -'BasiAuth' method. This authentication mechanism is implemented in the +'BasicAuth' method. This authentication mechanism is implemented in the **'odoo.addons.fastapi.depends'** module and relies on functionalities provided by the **'fastapi.security'** module. @@ -357,7 +357,7 @@ implemented, we will only implement the api key authentication mechanism. ) return partner -As for the 'BaseAuth' authentication mechanism, we also rely one of the native +As for the 'BasicAuth' authentication mechanism, we also rely one of the native security dependency provided by the **'fastapi.security'** module. Now that we have an implementation for our two authentication mechanism, we @@ -775,7 +775,7 @@ If your new addon is not installed in a database, a call to the route handler Managing security into the route handlers ***************************************** -By default the route handlers are processed the user configured on the +By default the route handlers are processed using the user configured on the **'fastapi.endpoint'** model instance. (default is the Public user). You have seen previously how to define a dependency that will be used to enforce the authentication of a partner. When a method depends on this dependency, the @@ -788,7 +788,7 @@ The fastapi addon extends the 'ir.rule' model to add into the evaluation context of the security rules the key 'authenticated_partner_id' that contains the id of the authenticated partner. -A goog practice when you develop a fastapi app and you want to protect your data +A good practice when you develop a fastapi app and you want to protect your data in an efficient and traceable way is to: * create a new user specific to the app but with any access rights. @@ -946,7 +946,7 @@ the app is robust and easy to maintain. Here are some of them: * Always use an appropriate pydantic model as request and/or response for your route handler. Constraints on the fields of the pydantic model must - apply to the specific use case. For example, il you route handler is used + apply to the specific use case. For example, if your route handler is used to create a sale order, the pydantic model must not contain the field 'id' because the id of the sale order will be generated by the route handler. But if the id is required afterwords, the pydantic model for the response From e7ed75a32731b6dc950ffea07239dd5bb553a6a7 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 12 Dec 2022 10:01:44 +0100 Subject: [PATCH 013/118] Fix typo --- fastapi/README.rst | 20 +++++++------- fastapi/readme/USAGE.rst | 4 +-- fastapi/static/description/index.html | 38 +++++++++++++-------------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/fastapi/README.rst b/fastapi/README.rst index aba0fa1f5..c91a5c523 100644 --- a/fastapi/README.rst +++ b/fastapi/README.rst @@ -77,7 +77,7 @@ What's building an API with fastapi? ************************************ FastAPI is a modern, fast (high-performance), web framework for building APIs -with Python 3.6+ based on standard Python type hints. This addons let's you +with Python 3.7+ based on standard Python type hints. This addons let's you keep advantage of the fastapi framework and use it with Odoo. Before you start, we must define some terms: @@ -119,7 +119,7 @@ The FastAPI framework is based on the following principles: The first step is to install the fastapi addon. You can do it with the following command: - $ pip install odoo-fastapi + $ pip install odoo-addon-fastapi Once the addon is installed, you can start building your API. The first thing you need to do is to create a new addon that depends on 'fastapi'. For example, @@ -263,7 +263,7 @@ odoo models and the database from your route handlers. As you can see, you can use the **'Depends'** function to inject the dependency into your route handler. The **'Depends'** function is provided by the -**'fastapi'** module. You can use it to inject any dependency into your route +**'fastapi'** framework. You can use it to inject any dependency into your route handler. As your handler is a python function, the only way to get access to the odoo environment is to inject it as a dependency. The fastapi addon provides a set of function that can be used as dependencies: @@ -363,7 +363,7 @@ At this stage, your handler is not tied to a specific authentication mechanism but only expects to get a partner as a dependency. Depending on your needs, you can implement different authentication mechanism available for your app. The fastapi addon provides a default authentication mechanism using the -'BasiAuth' method. This authentication mechanism is implemented in the +'BasicAuth' method. This authentication mechanism is implemented in the **'odoo.addons.fastapi.depends'** module and relies on functionalities provided by the **'fastapi.security'** module. @@ -432,7 +432,7 @@ implemented, we will only implement the api key authentication mechanism. ) return partner -As for the 'BaseAuth' authentication mechanism, we also rely one of the native +As for the 'BasicAuth' authentication mechanism, we also rely one of the native security dependency provided by the **'fastapi.security'** module. Now that we have an implementation for our two authentication mechanism, we @@ -679,7 +679,7 @@ method **'echo'**. return f"Hello {message}" -..note:: +.. note:: It's a good programming practice to implement the business logic outside the route handler. This way, you can easily test your business logic without @@ -839,7 +839,7 @@ If your new addon is not installed in a database, a call to the route handler "name": "John Doe" } -..note:: +.. note:: The liskov substitution principle has also to be respected. That means that if you extend a model, you must add new required fields or you must provide @@ -850,7 +850,7 @@ If your new addon is not installed in a database, a call to the route handler Managing security into the route handlers ***************************************** -By default the route handlers are processed the user configured on the +By default the route handlers are processed using the user configured on the **'fastapi.endpoint'** model instance. (default is the Public user). You have seen previously how to define a dependency that will be used to enforce the authentication of a partner. When a method depends on this dependency, the @@ -863,7 +863,7 @@ The fastapi addon extends the 'ir.rule' model to add into the evaluation context of the security rules the key 'authenticated_partner_id' that contains the id of the authenticated partner. -A goog practice when you develop a fastapi app and you want to protect your data +A good practice when you develop a fastapi app and you want to protect your data in an efficient and traceable way is to: * create a new user specific to the app but with any access rights. @@ -1021,7 +1021,7 @@ the app is robust and easy to maintain. Here are some of them: * Always use an appropriate pydantic model as request and/or response for your route handler. Constraints on the fields of the pydantic model must - apply to the specific use case. For example, il you route handler is used + apply to the specific use case. For example, if your route handler is used to create a sale order, the pydantic model must not contain the field 'id' because the id of the sale order will be generated by the route handler. But if the id is required afterwords, the pydantic model for the response diff --git a/fastapi/readme/USAGE.rst b/fastapi/readme/USAGE.rst index ff3339d1e..4af612554 100644 --- a/fastapi/readme/USAGE.rst +++ b/fastapi/readme/USAGE.rst @@ -604,7 +604,7 @@ method **'echo'**. return f"Hello {message}" -..note:: +.. note:: It's a good programming practice to implement the business logic outside the route handler. This way, you can easily test your business logic without @@ -764,7 +764,7 @@ If your new addon is not installed in a database, a call to the route handler "name": "John Doe" } -..note:: +.. note:: The liskov substitution principle has also to be respected. That means that if you extend a model, you must add new required fields or you must provide diff --git a/fastapi/static/description/index.html b/fastapi/static/description/index.html index 29c1c02e1..1010f5f76 100644 --- a/fastapi/static/description/index.html +++ b/fastapi/static/description/index.html @@ -413,7 +413,7 @@

    Usage

    What’s building an API with fastapi?

    FastAPI is a modern, fast (high-performance), web framework for building APIs -with Python 3.6+ based on standard Python type hints. This addons let’s you +with Python 3.7+ based on standard Python type hints. This addons let’s you keep advantage of the fastapi framework and use it with Odoo.

    Before you start, we must define some terms:

      @@ -454,7 +454,7 @@

      What’s building an API with fastapi?

      The first step is to install the fastapi addon. You can do it with the following command:

      -$ pip install odoo-fastapi
      +$ pip install odoo-addon-fastapi

      Once the addon is installed, you can start building your API. The first thing you need to do is to create a new addon that depends on ‘fastapi’. For example, let’s create an addon called my_demo_api.

      @@ -581,7 +581,7 @@

      Dealing with the odoo environment

      As you can see, you can use the ‘Depends’ function to inject the dependency into your route handler. The ‘Depends’ function is provided by the -‘fastapi’ module. You can use it to inject any dependency into your route +‘fastapi’ framework. You can use it to inject any dependency into your route handler. As your handler is a python function, the only way to get access to the odoo environment is to inject it as a dependency. The fastapi addon provides a set of function that can be used as dependencies:

      @@ -669,7 +669,7 @@

      The authentication mechanism

      but only expects to get a partner as a dependency. Depending on your needs, you can implement different authentication mechanism available for your app. The fastapi addon provides a default authentication mechanism using the -‘BasiAuth’ method. This authentication mechanism is implemented in the +‘BasicAuth’ method. This authentication mechanism is implemented in the ‘odoo.addons.fastapi.depends’ module and relies on functionalities provided by the ‘fastapi.security’ module.

      @@ -732,7 +732,7 @@ 

      The authentication mechanism

      ) return partner
      -

      As for the ‘BaseAuth’ authentication mechanism, we also rely one of the native +

      As for the ‘BasicAuth’ authentication mechanism, we also rely one of the native security dependency provided by the ‘fastapi.security’ module.

      Now that we have an implementation for our two authentication mechanism, we can allows the user to select one of these authentication mechanism by adding @@ -958,14 +958,14 @@

      Changing the implementation of the route handler

      def echo(self, message: str) -> str: return f"Hello {message}" -

      ..note:

      -
      -It's a good programming practice to implement the business logic outside
      +
      +

      Note

      +

      It’s a good programming practice to implement the business logic outside the route handler. This way, you can easily test your business logic without having to test the route handler. In the example above, the business logic -is implemented in the method **'echo'** of the model **'demo.fastapi.endpoint'**. -The route handler just delegate the implementation to this method. -

      +is implemented in the method ‘echo’ of the model ‘demo.fastapi.endpoint’. +The route handler just delegate the implementation to this method.

      +

    Overriding the dependencies of the route handler

    @@ -1097,17 +1097,17 @@

    Extending the model used as parameter or as response of the route handler"name": "John Doe" } -

    ..note:

    -
    -The liskov substitution principle has also to be respected. That means that
    +
    +

    Note

    +

    The liskov substitution principle has also to be respected. That means that if you extend a model, you must add new required fields or you must provide -default values for the new optional fields. -

    +default values for the new optional fields.

    +

    Managing security into the route handlers

    -

    By default the route handlers are processed the user configured on the +

    By default the route handlers are processed using the user configured on the ‘fastapi.endpoint’ model instance. (default is the Public user). You have seen previously how to define a dependency that will be used to enforce the authentication of a partner. When a method depends on this dependency, the @@ -1118,7 +1118,7 @@

    Managing security into the route handlers

    The fastapi addon extends the ‘ir.rule’ model to add into the evaluation context of the security rules the key ‘authenticated_partner_id’ that contains the id of the authenticated partner.

    -

    A goog practice when you develop a fastapi app and you want to protect your data +

    A good practice when you develop a fastapi app and you want to protect your data in an efficient and traceable way is to:

    • create a new user specific to the app but with any access rights.
    • @@ -1265,7 +1265,7 @@

      Overall considerations when you develop an fastapi app

      properly define your json document.
    • Always use an appropriate pydantic model as request and/or response for your route handler. Constraints on the fields of the pydantic model must -apply to the specific use case. For example, il you route handler is used +apply to the specific use case. For example, if your route handler is used to create a sale order, the pydantic model must not contain the field ‘id’ because the id of the sale order will be generated by the route handler. But if the id is required afterwords, the pydantic model for the response From 56bbee11e4e67f5626b1c2eff15a8f004fabb118 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 27 Jan 2023 11:35:15 +0100 Subject: [PATCH 014/118] little fixes --- fastapi/depends.py | 23 ++++++++++++----------- fastapi/models/fastapi_endpoint_demo.py | 6 +++--- fastapi/readme/ROADMAP.rst | 2 +- fastapi/readme/USAGE.rst | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/fastapi/depends.py b/fastapi/depends.py index eadfab694..9bb60c796 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -31,8 +31,16 @@ def authenticated_partner_impl() -> Partner: See the fastapi_endpoint_demo for an example""" +def authenticated_partner_env( + partner: Partner = Depends(authenticated_partner_impl), # noqa: B008 +) -> Environment: + """Return an environment with the authenticated partner id in the context""" + return partner.with_context(authenticated_partner_id=partner.id).env + + def authenticated_partner( partner: Partner = Depends(authenticated_partner_impl), # noqa: B008 + partner_env: Environment = Depends(authenticated_partner_env), # noqa: B008 ) -> Partner: """If you need to get access to the authenticated partner into your endpoint, you can add a dependency into the endpoint definition on this @@ -41,17 +49,9 @@ def authenticated_partner( specific implementation. It depends on `authenticated_partner_impl`. The concrete implementation of authenticated_partner_impl has to be provided when the FastAPI app is created. - This method is also responsible to put the authenticated partner id - into the context of the current environment. + This method return a partner into the authenticated_partner_env """ - return partner.with_context(authenticated_partner_id=partner.id) - - -def authenticated_partner_env( - partner: Partner = Depends(authenticated_partner), # noqa: B008 -) -> Environment: - """Return an environment with the authenticated partner id in the context""" - return partner.env + return partner_env["res.partner"].browse(partner.id) def paging( @@ -82,8 +82,9 @@ def basic_auth_user( def authenticated_partner_from_basic_auth_user( user: Users = Depends(basic_auth_user), # noqa: B008 + env: Environment = Depends(odoo_env), # noqa: B008 ) -> Partner: - return user.partner_id + return env["res.partner"].browse(user.partner_id.id) def fastapi_endpoint_id() -> int: diff --git a/fastapi/models/fastapi_endpoint_demo.py b/fastapi/models/fastapi_endpoint_demo.py index 78b19ffd9..68fa5bac4 100644 --- a/fastapi/models/fastapi_endpoint_demo.py +++ b/fastapi/models/fastapi_endpoint_demo.py @@ -69,9 +69,9 @@ def _get_app(self): authenticated_partner_impl_override = ( api_key_based_authenticated_partner_impl ) - app.dependency_overrides[ - authenticated_partner_impl - ] = authenticated_partner_impl_override + app.dependency_overrides[ + authenticated_partner_impl + ] = authenticated_partner_impl_override return app diff --git a/fastapi/readme/ROADMAP.rst b/fastapi/readme/ROADMAP.rst index 1975e0974..415a069c8 100644 --- a/fastapi/readme/ROADMAP.rst +++ b/fastapi/readme/ROADMAP.rst @@ -7,4 +7,4 @@ support is not 'yet' available in the **Odoo** framework. The challenge is high because the integration of the fastapi is based on the use of a specific middleware that convert the WSGI request consumed by odoo to a ASGI request. The question is to know if it is also possible to develop the same kind of bridge for the -WebSockets. +WebSockets and to stream large responses. diff --git a/fastapi/readme/USAGE.rst b/fastapi/readme/USAGE.rst index 4af612554..0a445b08e 100644 --- a/fastapi/readme/USAGE.rst +++ b/fastapi/readme/USAGE.rst @@ -196,10 +196,24 @@ a set of function that can be used as dependencies: * **'odoo_env'**: Returns the current odoo environment. * **'fastapi_endpoint'**: Returns the current fastapi endpoint model instance. * **'authenticated_partner'**: Returns the authenticated partner. +* **'authenticated_partner_env'**: Returns the current odoo environment with the + authenticated_partner_id into the context. By default, the **'odoo_env'** and **'fastapi_endpoint'** dependencies are available without extra work. +.. note:: + Even if 'odoo_env' and 'authenticated_partner_env' returns the current odoo + environment, they are not the same. The 'odoo_env' dependency returns the + environment without any modification while the 'authenticated_partner_env' + adds the authenticated partner id into the context of the environment. As it will + be explained in the section `Managing security into the route handlers`_ dedicated + to the security, the presence of the authenticated partner id into the context + is the key information that will allow you to enforce the security of your endpoint + methods. As consequence, you should always use the 'authenticated_partner_env' + dependency instead of the 'odoo_env' dependency for all the methods that are + not public. + The dependency injection mechanism ********************************** From 236241818c344f6ac5d98bc9eff74e2cf009cf39 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 24 Feb 2023 16:26:38 +0100 Subject: [PATCH 015/118] [FIX] fastapi: Uses new implementation of endpoint_route_handler --- fastapi/models/fastapi_endpoint.py | 105 +++++++++++++---------------- fastapi/tests/test_fastapi.py | 14 +--- fastapi/views/fastapi_endpoint.xml | 1 + 3 files changed, 48 insertions(+), 72 deletions(-) diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index 32145369c..93ced7d57 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -3,15 +3,14 @@ import logging from functools import partial -from typing import Any, Awaitable, Callable, Dict, List, Type, Union +from itertools import chain +from typing import Any, Awaitable, Callable, Dict, List, Tuple, Type, Union from a2wsgi import ASGIMiddleware import odoo from odoo import _, api, exceptions, fields, models, tools -from odoo.addons.endpoint_route_handler.registry import EndpointRegistry - from fastapi import APIRouter, FastAPI, HTTPException, Request, Response from .. import depends, error_handlers @@ -22,6 +21,7 @@ class FastapiEndpoint(models.Model): _name = "fastapi.endpoint" + _inherit = "endpoint.route.sync.mixin" _description = "FastAPI Endpoint" name: str = fields.Char(required=True, help="The title of the API.") @@ -48,8 +48,6 @@ class FastapiEndpoint(models.Model): redoc_url: str = fields.Char(compute="_compute_urls") openapi_url: str = fields.Char(compute="_compute_urls") - active: bool = fields.Boolean(default=True) - @api.depends("root_path") def _compute_root_path(self): for rec in self: @@ -86,12 +84,29 @@ def _compute_urls(self): rec.redoc_url = f"{rec.root_path}/redoc" rec.openapi_url = f"{rec.root_path}/openapi.json" - @api.model_create_multi - def create(self, vals_list): - rec = super().create(vals_list) - if rec.active: - rec._register_endpoints() - return rec + # + # endpoint.route.sync.mixin methods implementation + # + def _prepare_endpoint_rules(self, options=None): + return list(chain(*[rec._make_routing_rule(options=options) for rec in self])) + + def _registered_endpoint_rule_keys(self): + res = [] + for rec in self: + routing = self._get_routing_info() + for route in routing: + res.append(rec._endpoint_registry_route_unique_key(route)) + return tuple(res) + + @api.model + def _routing_impacting_fields(self) -> Tuple[str]: + """The list of fields requiring to refresh the mount point of the pp + into odoo if modified""" + return ("root_path",) + + # + # end of endpoint.route.sync.mixin methods implementation + # def write(self, vals): res = super().write(vals) @@ -99,74 +114,52 @@ def write(self, vals): return res def _handle_route_updates(self, vals): - if "active" in vals: - if vals["active"]: - self._register_endpoints() - else: - self._unregister_endpoints() - return True - refresh_endpoints = any([x in vals for x in self._routing_fields()]) - refresh_fastapi_app = ( - any([x in vals for x in self._fastapi_app_fields()]) or refresh_endpoints - ) - if refresh_endpoints: - self._register_endpoints() + observed_fields = [self._routing_impacting_fields(), self._fastapi_app_fields()] + refresh_fastapi_app = any([x in vals for x in chain(*observed_fields)]) if refresh_fastapi_app: self._reset_app() if "user_id" in vals: self.get_uid.clear_cache(self) return False - def unlink(self): - self._unregister_endpoints() - return super().unlink() - - @api.model - def _routing_fields(self) -> List[str]: - """The list of fields requiring to refresh the mount point of the pp - into odoo if modified""" - return ["root_path"] - @api.model def _fastapi_app_fields(self) -> List[str]: """The list of fields requiring to refresh the fastapi app if modified""" return [] - @property - def _endpoint_registry(self) -> EndpointRegistry: - return EndpointRegistry.registry_for(self.env.cr.dbname) - - def _register_hook(self): - self.search([("active", "=", True)])._register_endpoints(init=True) - - def _register_endpoints(self, init: bool = False): - for rec in self: - for rule in rec._make_routing_rule(): - rec._endpoint_registry.add_or_update_rule(rule, init=init) - - def _make_routing_rule(self): + def _make_routing_rule(self, options=None): """Generator of rule for every route into the routing info""" self.ensure_one() routing = self._get_routing_info() + options = options or self._default_endpoint_options() for route in routing["routes"]: key = self._endpoint_registry_route_unique_key(route) endpoint_hash = hash(route) - - def endpoint(self): - """Dummy method only used to register a route with type='fastapi'""" - - endpoint.routing = routing rule = self._endpoint_registry.make_rule( - key, route, endpoint, routing, endpoint_hash + key, route, options, routing, endpoint_hash ) yield rule + def _default_endpoint_options(self): + options = {"handler": self._default_endpoint_options_handler()} + return options + + def _default_endpoint_options_handler(self): + # The handler is useless in the context of a fastapi endpoint since the + # routing type is "fastapi" and the routing is handled by a dedicated + # dispatcher that will forward the request to the fastapi app. + base_path = "odoo.addons.endpoint_route_handler.controllers.main" + return { + "klass_dotted_path": f"{base_path}.EndpointNotFoundController", + "method_name": "auto_not_found", + } + def _get_routing_info(self): self.ensure_one() return { "type": "fastapi", "auth": "public", - "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"], "routes": [ f"{self.root_path}/", f"{self.root_path}/", @@ -178,12 +171,6 @@ def _endpoint_registry_route_unique_key(self, route: str): path = route.replace(self.root_path, "") return f"{self._name}:{self.id}:{path}" - def _unregister_endpoints(self): - for rec in self: - for route in rec._get_routing_info()["routes"]: - key = rec._endpoint_registry_route_unique_key(route) - rec._endpoint_registry.drop_rule(key) - def _reset_app(self): self.get_app.clear_cache(self) diff --git a/fastapi/tests/test_fastapi.py b/fastapi/tests/test_fastapi.py index 9117cb8bd..aadb24b74 100644 --- a/fastapi/tests/test_fastapi.py +++ b/fastapi/tests/test_fastapi.py @@ -12,22 +12,10 @@ class FastAPIHttpCase(HttpCase): def setUp(self): super().setUp() self.fastapi_demo_app = self.env.ref("fastapi.fastapi_endpoint_demo") + self.fastapi_demo_app._handle_registry_sync() def test_call(self): route = "/fastapi_demo/" response = self.url_open(route) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b'{"Hello":"World"}') - - def test_update_route(self): - route = "/fastapi_demo/" - response = self.url_open(route) - self.assertEqual(response.status_code, 200) - response = self.url_open("/new_root/") - self.assertEqual(response.status_code, 404) - self.fastapi_demo_app.root_path = "/new_root" - self.env.flush_all() - response = self.url_open(route) - self.assertEqual(response.status_code, 404) - response = self.url_open("/new_root/") - self.assertEqual(response.status_code, 200) diff --git a/fastapi/views/fastapi_endpoint.xml b/fastapi/views/fastapi_endpoint.xml index 27592a45c..fb9f0ca36 100644 --- a/fastapi/views/fastapi_endpoint.xml +++ b/fastapi/views/fastapi_endpoint.xml @@ -27,6 +27,7 @@ + From 21113bfc7f230c1c9721abbfcff29f71e439a5fc Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 24 Feb 2023 17:49:00 +0100 Subject: [PATCH 016/118] [FIX] fastapi: change annotation to support python 3.7+ --- fastapi/models/fastapi_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index 93ced7d57..b5814fee7 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -208,7 +208,7 @@ def _get_app(self) -> FastAPI: def _get_app_exception_handlers( self, ) -> Dict[ - int | Type[Exception], + Union[int, Type[Exception]], Callable[[Request, Exception], Union[Response, Awaitable[Response]]], ]: """Return a dict of exception handlers to register on the app From f60232473d6417ee0617009cd21a982023e00419 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 24 Feb 2023 18:40:22 +0100 Subject: [PATCH 017/118] [ADD] extendable_fastapi: New addon to allow the use of extendable within fastapi apps --- fastapi/models/fastapi_endpoint.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index b5814fee7..3e9a37ffd 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -7,6 +7,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Tuple, Type, Union from a2wsgi import ASGIMiddleware +from starlette.middleware import Middleware import odoo from odoo import _, api, exceptions, fields, models, tools @@ -195,7 +196,7 @@ def get_uid(self, root_path): return record.user_id.id def _get_app(self) -> FastAPI: - app = FastAPI(**self._prepare_fastapi_endpoint_params()) + app = FastAPI(**self._prepare_fastapi_app_params()) for router in self._get_fastapi_routers(): app.include_router(prefix=self.root_path, router=router) app.dependency_overrides[depends.fastapi_endpoint_id] = partial( @@ -237,7 +238,7 @@ def _get_app_exception_handlers( odoo.exceptions.ValidationError: error_handlers._odoo_validation_error_handler, } - def _prepare_fastapi_endpoint_params(self) -> Dict[str, Any]: + def _prepare_fastapi_app_params(self) -> Dict[str, Any]: """Return the params to pass to the Fast API app constructor""" return { "title": self.name, @@ -245,11 +246,16 @@ def _prepare_fastapi_endpoint_params(self) -> Dict[str, Any]: "openapi_url": self.openapi_url, "docs_url": self.docs_url, "redoc_url": self.redoc_url, + "middleware": self._get_fastapi_app_middlewares(), } def _get_fastapi_routers(self) -> List[APIRouter]: """Return the api routers to use for the instance. - This methoud must be implemented when registering a new api type. + This method must be implemented when registering a new api type. """ return [] + + def _get_fastapi_app_middlewares(self) -> List[Middleware]: + """Return the middlewares to use for the fastapi app.""" + return [] From 11f317e0d0c7535d70ae24a7f98597632c3a40b0 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Sun, 26 Feb 2023 13:55:43 +0100 Subject: [PATCH 018/118] [FIX] fastapi: typo Co-authored-by: Fernando --- fastapi/README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastapi/README.rst b/fastapi/README.rst index c91a5c523..dd8ca6ea6 100644 --- a/fastapi/README.rst +++ b/fastapi/README.rst @@ -150,7 +150,7 @@ figure:: static/description/endpoint.png FastAPI Endpoint Thanks to the **'fastapi.endpoint'** model, you can create as many endpoints as -you wand and mount as many apps as you want in each endpoint. The endpoint is +you want and mount as many apps as you want in each endpoint. The endpoint is also the place where you can define configuration parameters for your app. A typical example is the authentication method that you want to use for your app when accessed at the endpoint path. @@ -397,7 +397,7 @@ In this dummy implementation, we just check that the provided credentials can be used to authenticate a user in odoo. If the authentication is successful, we return the partner record linked to the authenticated user. -In some cas you could want to implement a more complex authentication mechanism +In some case you could want to implement a more complex authentication mechanism that could rely on a token or a session. In this case, you can override the **'authenticated_partner'** dependency by registering a specific method that returns the authenticated partner. Moreover, you can make it configurable on From a00388c3f47115c4c3ad1deffaf722f15e3d5c7e Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 27 Feb 2023 11:54:29 +0100 Subject: [PATCH 019/118] [IMP]fastapi: Uses the accept-languages header to determine the expexted processing lang --- fastapi/__manifest__.py | 8 ++++- fastapi/depends.py | 18 +++++++++- fastapi/fastapi_dispatcher.py | 8 +++++ fastapi/models/__init__.py | 1 + fastapi/models/fastapi_endpoint.py | 7 +++- fastapi/models/fastapi_endpoint_demo.py | 13 ++++++- fastapi/models/res_lang.py | 46 +++++++++++++++++++++++++ fastapi/tests/test_fastapi.py | 27 ++++++++++++--- 8 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 fastapi/models/res_lang.py diff --git a/fastapi/__manifest__.py b/fastapi/__manifest__.py index 69ee62b86..f089ab121 100644 --- a/fastapi/__manifest__.py +++ b/fastapi/__manifest__.py @@ -20,6 +20,12 @@ ], "demo": ["demo/fastapi_endpoint_demo.xml"], "external_dependencies": { - "python": ["fastapi", "python-multipart", "ujson", "a2wsgi"] + "python": [ + "fastapi", + "python-multipart", + "ujson", + "a2wsgi", + "parse-accept-language", + ] }, } diff --git a/fastapi/depends.py b/fastapi/depends.py index 9bb60c796..0d5012e44 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -9,7 +9,7 @@ from odoo.addons.base.models.res_partner import Partner from odoo.addons.base.models.res_users import Users -from fastapi import Depends, HTTPException, Query, status +from fastapi import Depends, Header, HTTPException, Query, status from fastapi.security import HTTPBasic, HTTPBasicCredentials from .context import odoo_env_ctx @@ -106,3 +106,19 @@ def fastapi_endpoint( # TODO we should declare a technical user with read access only on the # fastapi.endpoint model return env["fastapi.endpoint"].sudo().browse(_id) + + +def accept_language( + accept_language: str = Header( # noqa: B008 + None, + alias="Accept-Language", + description="The Accept-Language header is used to specify the language " + "of the content to be returned. If a language is not available, the " + "server will return the content in the default language.", + ) +) -> str: + """This dependency is used at application level to document the way the language + to use for the response is specified. The header is processed outside of the + fastapi app to initialize the odoo environment with the right language. + """ + return accept_language diff --git a/fastapi/fastapi_dispatcher.py b/fastapi/fastapi_dispatcher.py index cee15e325..904deeff7 100644 --- a/fastapi/fastapi_dispatcher.py +++ b/fastapi/fastapi_dispatcher.py @@ -49,6 +49,14 @@ def _get_environ(self): @contextmanager def _manage_odoo_env(self, uid=None): env = request.env + accept_language = request.httprequest.headers.get("Accept-language") + context = env.context + if accept_language: + lang = ( + env["res.lang"].sudo()._get_lang_from_accept_language(accept_language) + ) + if lang: + env = env(context=dict(context, lang=lang)) if uid: env = env(user=uid) token = odoo_env_ctx.set(env) diff --git a/fastapi/models/__init__.py b/fastapi/models/__init__.py index 6768870cb..40ff1b979 100644 --- a/fastapi/models/__init__.py +++ b/fastapi/models/__init__.py @@ -1,3 +1,4 @@ from . import fastapi_endpoint from . import fastapi_endpoint_demo from . import ir_rule +from . import res_lang diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index 3e9a37ffd..7be3a512a 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -12,7 +12,7 @@ import odoo from odoo import _, api, exceptions, fields, models, tools -from fastapi import APIRouter, FastAPI, HTTPException, Request, Response +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response from .. import depends, error_handlers @@ -247,6 +247,7 @@ def _prepare_fastapi_app_params(self) -> Dict[str, Any]: "docs_url": self.docs_url, "redoc_url": self.redoc_url, "middleware": self._get_fastapi_app_middlewares(), + "dependencies": self._get_fastapi_app_dependencies(), } def _get_fastapi_routers(self) -> List[APIRouter]: @@ -259,3 +260,7 @@ def _get_fastapi_routers(self) -> List[APIRouter]: def _get_fastapi_app_middlewares(self) -> List[Middleware]: """Return the middlewares to use for the fastapi app.""" return [] + + def _get_fastapi_app_dependencies(self) -> List[Depends]: + """Return the dependencies to use for the fastapi app.""" + return [Depends(depends.accept_language)] diff --git a/fastapi/models/fastapi_endpoint_demo.py b/fastapi/models/fastapi_endpoint_demo.py index 68fa5bac4..3844c1fdc 100644 --- a/fastapi/models/fastapi_endpoint_demo.py +++ b/fastapi/models/fastapi_endpoint_demo.py @@ -113,7 +113,7 @@ class ExceptionType(str, Enum): async def exception(exception_type: ExceptionType, error_message: str): """Raise an exception - This method is called into the test suite to check that any exception + This method is used in the test suite to check that any exception is correctly handled by the fastapi endpoint and that the transaction is roll backed. """ @@ -131,6 +131,17 @@ async def exception(exception_type: ExceptionType, error_message: str): raise exception_classes[exception_type](error_message) +@demo_api_router.get("/lang") +async def get_lang(env: Environment = Depends(odoo_env)): # noqa: B008 + """Returns the language according to the available languages in Odoo and the + Accept-Language header. + + This method is used in the test suite to check that the language is correctly + set in the Odoo environment according to the Accept-Language header + """ + return env.context.get("lang") + + @demo_api_router.get("/who_ami", response_model=UserInfo) async def who_ami(partner=Depends(authenticated_partner)) -> UserInfo: # noqa: B008 """Who am I? diff --git a/fastapi/models/res_lang.py b/fastapi/models/res_lang.py new file mode 100644 index 000000000..55453c405 --- /dev/null +++ b/fastapi/models/res_lang.py @@ -0,0 +1,46 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from accept_language import parse_accept_language + +from odoo import api, models, tools + + +class ResLang(models.Model): + _inherit = "res.lang" + + @api.model + @tools.ormcache("accept_language") + def _get_lang_from_accept_language(self, accept_language): + """Get the language from the Accept-Language header. + + :param accept_language: The Accept-Language header. + :return: The language code. + """ + if not accept_language: + return + parsed_accepted_langs = parse_accept_language(accept_language) + installed_locale_langs = set() + installed_locale_by_lang = defaultdict(list) + for lang_code, _name in self.get_installed(): + installed_locale_langs.add(lang_code) + installed_locale_by_lang[lang_code.split("_")[0]].append(lang_code) + + # parsed_acccepted_langs is sorted by priority (higher first) + for lang in parsed_accepted_langs: + # we first check if a locale (en_GB) is available into the list of + # available locales into Odoo + locale = None + if lang.locale in installed_locale_langs: + locale = lang.locale + # if no locale language is installed, we look for an available + # locale for the given language (en). We return the first one + # found for this language. + else: + locales = installed_locale_by_lang.get(lang.language) + if locales: + locale = locales[0] + if locale: + return locale diff --git a/fastapi/tests/test_fastapi.py b/fastapi/tests/test_fastapi.py index aadb24b74..ded7a85d3 100644 --- a/fastapi/tests/test_fastapi.py +++ b/fastapi/tests/test_fastapi.py @@ -9,13 +9,32 @@ @unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "EndpointHttpCase skipped") class FastAPIHttpCase(HttpCase): - def setUp(self): - super().setUp() - self.fastapi_demo_app = self.env.ref("fastapi.fastapi_endpoint_demo") - self.fastapi_demo_app._handle_registry_sync() + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo") + cls.fastapi_demo_app._handle_registry_sync() + lang = ( + cls.env["res.lang"] + .with_context(active_test=False) + .search([("code", "=", "fr_BE")]) + ) + lang.active = True + + def _assert_expected_lang(self, accept_language, expected_lang): + route = "/fastapi_demo/lang" + response = self.url_open(route, headers={"Accept-language": accept_language}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, expected_lang) def test_call(self): route = "/fastapi_demo/" response = self.url_open(route) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b'{"Hello":"World"}') + + def test_lang(self): + self._assert_expected_lang("fr,en;q=0.7,en-GB;q=0.3", b'"fr_BE"') + self._assert_expected_lang("en,fr;q=0.7,en-GB;q=0.3", b'"en_US"') + self._assert_expected_lang("fr-FR,en;q=0.7,en-GB;q=0.3", b'"fr_BE"') + self._assert_expected_lang("fr-FR;q=0.1,en;q=1.0,en-GB;q=0.8", b'"en_US"') From 3b32e9e8e428cc018ea930e5d0b61b2faf5f6c90 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 27 Feb 2023 12:36:02 +0100 Subject: [PATCH 020/118] [IMP] fastapi: Register only one handler by application --- fastapi/models/fastapi_endpoint.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index 7be3a512a..0e799a01d 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -89,14 +89,13 @@ def _compute_urls(self): # endpoint.route.sync.mixin methods implementation # def _prepare_endpoint_rules(self, options=None): - return list(chain(*[rec._make_routing_rule(options=options) for rec in self])) + return [rec._make_routing_rule(options=options) for rec in self] def _registered_endpoint_rule_keys(self): res = [] for rec in self: - routing = self._get_routing_info() - for route in routing: - res.append(rec._endpoint_registry_route_unique_key(route)) + routing = rec._get_routing_info() + res.append(rec._endpoint_registry_route_unique_key(routing)) return tuple(res) @api.model @@ -129,17 +128,16 @@ def _fastapi_app_fields(self) -> List[str]: return [] def _make_routing_rule(self, options=None): - """Generator of rule for every route into the routing info""" + """Generator of rule""" self.ensure_one() routing = self._get_routing_info() options = options or self._default_endpoint_options() - for route in routing["routes"]: - key = self._endpoint_registry_route_unique_key(route) - endpoint_hash = hash(route) - rule = self._endpoint_registry.make_rule( - key, route, options, routing, endpoint_hash - ) - yield rule + route = "|".join(routing["routes"]) + key = self._endpoint_registry_route_unique_key(routing) + endpoint_hash = hash(route) + return self._endpoint_registry.make_rule( + key, route, options, routing, endpoint_hash + ) def _default_endpoint_options(self): options = {"handler": self._default_endpoint_options_handler()} @@ -168,7 +166,8 @@ def _get_routing_info(self): # csrf ????? } - def _endpoint_registry_route_unique_key(self, route: str): + def _endpoint_registry_route_unique_key(self, routing: Dict[str, Any]): + route = "|".join(routing["routes"]) path = route.replace(self.root_path, "") return f"{self._name}:{self.id}:{path}" From 3bcd468f369cd444b301aaafa908bbe55cc54d4d Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 27 Feb 2023 14:22:52 +0100 Subject: [PATCH 021/118] [IMP] fastapi: Improves UX for the endpoint registry synchronisation A ribbon and a button are dislayed on the form view when a record needs to be synchronized. In the tree view the records to synchronize are displayed with the text decoration defined for warning. A button is also displayed on the line if sync is required and an server actions allows you to sync selected records in one click --- fastapi/models/fastapi_endpoint.py | 3 ++ fastapi/views/fastapi_endpoint.xml | 45 ++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index 0e799a01d..e0bb322cf 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -113,6 +113,9 @@ def write(self, vals): self._handle_route_updates(vals) return res + def action_sync_registry(self): + self.filtered(lambda e: not e.registry_sync).write({"registry_sync": True}) + def _handle_route_updates(self, vals): observed_fields = [self._routing_impacting_fields(), self._fastapi_app_fields()] refresh_fastapi_app = any([x in vals for x in chain(*observed_fields)]) diff --git a/fastapi/views/fastapi_endpoint.xml b/fastapi/views/fastapi_endpoint.xml index fb9f0ca36..c26c75639 100644 --- a/fastapi/views/fastapi_endpoint.xml +++ b/fastapi/views/fastapi_endpoint.xml @@ -11,29 +11,47 @@
      + + +
      +
      +
      - + - +
      @@ -67,17 +85,38 @@ fastapi.endpoint.tree (in fastapi) fastapi.endpoint - + + + +

    By default, the ‘odoo_env’ and ‘fastapi_endpoint’ dependencies are available without extra work.

    +
    +

    Note

    +

    Even if ‘odoo_env’ and ‘authenticated_partner_env’ returns the current odoo +environment, they are not the same. The ‘odoo_env’ dependency returns the +environment without any modification while the ‘authenticated_partner_env’ +adds the authenticated partner id into the context of the environment. As it will +be explained in the section Managing security into the route handlers dedicated +to the security, the presence of the authenticated partner id into the context +is the key information that will allow you to enforce the security of your endpoint +methods. As consequence, you should always use the ‘authenticated_partner_env’ +dependency instead of the ‘odoo_env’ dependency for all the methods that are +not public.

    +

    The dependency injection mechanism

    @@ -872,6 +887,18 @@

    Managing configuration parameters for your app

    return fields
    +
    +

    Dealing with languages

    +

    The fastapi addon parses the Accept-Language header of the request to determine +the language to use. This parsing is done by respecting the RFC 7231 specification. That means that +the language is determined by the first language found in the header that is +supported by odoo (with care of the priority order). If no language is found in +the header, the odoo default language is used. This language is then used to +initialize the Odoo’s environment context used by the route handlers. All this +makes the management of languages very easy. You don’t have to worry about. This +feature is also documented by default into the generated openapi documentation +of your app to instruct the api consumers how to request a specific language.

    +

    How to extend an existing app

    When you develop a fastapi app, in a native python app it’s not possible @@ -1047,7 +1074,7 @@

    Extending the model used as parameter or as response of the route handlerodoo-addon-extendable.

    +odoo-addon-extendable-fastapi.

    When you want to allow other addons to extend a pydantic model, you must first define the model as an extendable model by using a dedicated metaclass

    @@ -1454,7 +1481,7 @@ 

    Known issues / Roadmap

    because the integration of the fastapi is based on the use of a specific middleware that convert the WSGI request consumed by odoo to a ASGI request. The question is to know if it is also possible to develop the same kind of bridge for the -WebSockets.

    +WebSockets and to stream large responses.

    Bug Tracker

    From 59b69682c6b9c44c1d6687c5cac687c1aad0c212 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 10 Mar 2023 11:54:51 +0100 Subject: [PATCH 023/118] [IMP] fastapi: Improves documentation on security aspects In the same time, applies the security guidelines for the demo app --- fastapi/README.rst | 97 ++++++++++++++++++++------ fastapi/__manifest__.py | 1 + fastapi/demo/fastapi_endpoint_demo.xml | 63 ++++++++--------- fastapi/depends.py | 21 +++--- fastapi/fastapi_dispatcher.py | 4 ++ fastapi/readme/USAGE.rst | 97 ++++++++++++++++++++------ fastapi/security/ir_rule+acl.xml | 65 +++++++++++++++++ fastapi/security/res_groups.xml | 7 ++ fastapi/static/description/index.html | 94 +++++++++++++++++++------ fastapi/tests/test_fastapi_demo.py | 4 +- 10 files changed, 343 insertions(+), 110 deletions(-) create mode 100644 fastapi/security/ir_rule+acl.xml diff --git a/fastapi/README.rst b/fastapi/README.rst index 68b391c65..c271e5800 100644 --- a/fastapi/README.rst +++ b/fastapi/README.rst @@ -242,6 +242,58 @@ Now, you can start your Odoo server, install your addon and create a new endpoin instance for your app. Once it's done click on the docs url to access the interactive documentation of your app. +Before trying to test your app, you need to define on the endpoint instance the +user that will be used to run the app. You can do it by setting the **'user_id'** +field. This information is the most important one because it's the basis for +the security of your app. The user that you define in the endpoint instance +will be used to run the app and to access the database. This means that the +user will be able to access all the data that he has access to in Odoo. To ensure +the security of your app, you should create a new user that will be used only +to run your app and that will have no access to the database. + +.. code-block:: xml + + + My Demo Endpoint User + my_demo_app_user + + + +At the same time you should create a new group that will be used to define the +access rights of the user that will run your app. This group should imply +the predefined group **'FastAPI Endpoint Runner'**. This group defines the +minimum access rights that the user needs to: + +* access the endpoint instance it belongs to +* access to its own user record +* access to the partner record that is linked to its user record + +.. code-block:: xml + + + My Demo Endpoint Group + + + + + +Now, you can test your app. You can do it by clicking on the 'Try it out' button +of the route that you have defined. The result of the request will be displayed +in the 'Response' section and contains the list of partners. + +.. note:: + The **'FastAPI Endpoint Runner'** group ensures that the user can access any + information others than the 3 ones mentioned above. This means that for every + information that you want to access from your app, you need to create the + proper ACLs and record rules. (see `Managing security into the route handlers`_) + It's a good practice to use a dedicated user into a specific group from the + beginning of your project and in your tests. This will force you to define + the proper security rules for your endoints. + Dealing with the odoo environment ********************************* @@ -318,19 +370,15 @@ of these parameters are dependencies themselves. _id: int = Depends(fastapi_endpoint_id), # noqa: B008 env: Environment = Depends(odoo_env), # noqa: B008 ) -> "FastapiEndpoint": - """Return the fastapi.endpoint record - - Be careful, the information are returned as sudo - """ - # TODO we should declare a technical user with read access only on the - # fastapi.endpoint model - return env["fastapi.endpoint"].sudo().browse(_id) + """Return the fastapi.endpoint record""" + return env["fastapi.endpoint"].browse(_id) As you can see, one of these dependencies is the **'fastapi_endpoint_id'** dependency and has no concrete implementation. This method is used as a contract that must be implemented/provided at the time the fastapi app is created. Here comes the power of the dependency_overrides mechanism. + If you take a look at the **'_get_app'** method of the **'FastapiEndpoint'** model, you will see that the **'fastapi_endpoint_id'** dependency is overriden by registering a specific method that returns the id of the current fastapi endpoint @@ -873,8 +921,6 @@ If your new addon is not installed in a database, a call to the route handler if you extend a model, you must add new required fields or you must provide default values for the new optional fields. - - Managing security into the route handlers ***************************************** @@ -891,18 +937,21 @@ The fastapi addon extends the 'ir.rule' model to add into the evaluation context of the security rules the key 'authenticated_partner_id' that contains the id of the authenticated partner. -A good practice when you develop a fastapi app and you want to protect your data -in an efficient and traceable way is to: +As briefly introduced in a previous section, a good practice when you develop a +fastapi app and you want to protect your data in an efficient and traceable way is to: * create a new user specific to the app but with any access rights. -* create a security group specific to the app and add the user to this group. +* create a security group specific to the app and add the user to this group. (This + group must implies the group 'AFastAPI Endpoint Runner' that give the + minimal access rights) * for each model you want to protect: * add a 'ir.model.access' record for the model to allow read access to your model and add the group to the record. * create a new 'ir.rule' record for the model that restricts the access to the records of the model to the authenticated partner by using the key - 'authenticated_partner_id' in domain of the rule. + 'authenticated_partner_id' in domain of the rule. (or to the user defined on + the 'fastapi.endpoint' model instance if the method is public) * add a dependency on the 'authenticated_partner' to your handlers when you need to access the authenticated partner or ensure that the service is called by an @@ -910,21 +959,27 @@ in an efficient and traceable way is to: .. code-block:: xml - - My Demo App User - demo_app_user + + My Demo Endpoint User + my_demo_app_user + - - My Demo App - + + My Demo Endpoint Group + + My Demo App: access to sale.order - + @@ -936,7 +991,7 @@ in an efficient and traceable way is to: Sale Order Rule [('partner_id', '=', authenticated_partner_id)] - + How to test your fastapi app diff --git a/fastapi/__manifest__.py b/fastapi/__manifest__.py index f089ab121..d8f8a1da3 100644 --- a/fastapi/__manifest__.py +++ b/fastapi/__manifest__.py @@ -14,6 +14,7 @@ "data": [ "security/res_groups.xml", "security/fastapi_endpoint.xml", + "security/ir_rule+acl.xml", "views/fastapi_menu.xml", "views/fastapi_endpoint.xml", "views/fastapi_endpoint_demo.xml", diff --git a/fastapi/demo/fastapi_endpoint_demo.xml b/fastapi/demo/fastapi_endpoint_demo.xml index 502083377..a1c34e34b 100644 --- a/fastapi/demo/fastapi_endpoint_demo.xml +++ b/fastapi/demo/fastapi_endpoint_demo.xml @@ -2,6 +2,32 @@ + + + My Demo Endpoint User + my_demo_app_user + + + + + + My Demo Endpoint Group + + + + + + Fastapi Demo Endpoint List[APIRouter]: - if self.app == "demo": - return [demo_api_router] - return super()._get_fastapi_routers() - - -demo_api_router = APIRouter() - - -@demo_api_router.get("/") -async def read_root(): - return {"Hello": "World"} - -``` - +methods. See documentation to learn more about how to create a new app. ]]> demo /fastapi_demo http_basic + diff --git a/fastapi/depends.py b/fastapi/depends.py index 0d5012e44..fc1881494 100644 --- a/fastapi/depends.py +++ b/fastapi/depends.py @@ -68,10 +68,14 @@ def basic_auth_user( username = credential.username password = credential.password try: - uid = env["res.users"].authenticate( - db=env.cr.dbname, login=username, password=password, user_agent_env=None + uid = ( + env["res.users"] + .sudo() + .authenticate( + db=env.cr.dbname, login=username, password=password, user_agent_env=None + ) ) - return env["res.users"].sudo().browse(uid) + return env["res.users"].browse(uid) except AccessDenied as ad: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -84,7 +88,7 @@ def authenticated_partner_from_basic_auth_user( user: Users = Depends(basic_auth_user), # noqa: B008 env: Environment = Depends(odoo_env), # noqa: B008 ) -> Partner: - return env["res.partner"].browse(user.partner_id.id) + return env["res.partner"].browse(user.sudo().partner_id.id) def fastapi_endpoint_id() -> int: @@ -99,13 +103,8 @@ def fastapi_endpoint( _id: int = Depends(fastapi_endpoint_id), # noqa: B008 env: Environment = Depends(odoo_env), # noqa: B008 ) -> "FastapiEndpoint": - """Return the fastapi.endpoint record - - Be careful, the returned FastapiEndpoint record is a sudoed record. - """ - # TODO we should declare a technical user with read access only on the - # fastapi.endpoint model - return env["fastapi.endpoint"].sudo().browse(_id) + """Return the fastapi.endpoint record""" + return env["fastapi.endpoint"].browse(_id) def accept_language( diff --git a/fastapi/fastapi_dispatcher.py b/fastapi/fastapi_dispatcher.py index 904deeff7..a78e3f816 100644 --- a/fastapi/fastapi_dispatcher.py +++ b/fastapi/fastapi_dispatcher.py @@ -49,6 +49,10 @@ def _get_environ(self): @contextmanager def _manage_odoo_env(self, uid=None): env = request.env + # add authenticated_partner_id=False in the context + # to ensure that the ir.rule defined for user's endpoint can be + # evaluated even if not authenticated partner is set + env = env(context=dict(env.context, authenticated_partner_id=False)) accept_language = request.httprequest.headers.get("Accept-language") context = env.context if accept_language: diff --git a/fastapi/readme/USAGE.rst b/fastapi/readme/USAGE.rst index de2d56d52..29bd5d0ce 100644 --- a/fastapi/readme/USAGE.rst +++ b/fastapi/readme/USAGE.rst @@ -167,6 +167,58 @@ Now, you can start your Odoo server, install your addon and create a new endpoin instance for your app. Once it's done click on the docs url to access the interactive documentation of your app. +Before trying to test your app, you need to define on the endpoint instance the +user that will be used to run the app. You can do it by setting the **'user_id'** +field. This information is the most important one because it's the basis for +the security of your app. The user that you define in the endpoint instance +will be used to run the app and to access the database. This means that the +user will be able to access all the data that he has access to in Odoo. To ensure +the security of your app, you should create a new user that will be used only +to run your app and that will have no access to the database. + +.. code-block:: xml + + + My Demo Endpoint User + my_demo_app_user + + + +At the same time you should create a new group that will be used to define the +access rights of the user that will run your app. This group should imply +the predefined group **'FastAPI Endpoint Runner'**. This group defines the +minimum access rights that the user needs to: + +* access the endpoint instance it belongs to +* access to its own user record +* access to the partner record that is linked to its user record + +.. code-block:: xml + + + My Demo Endpoint Group + + + + + +Now, you can test your app. You can do it by clicking on the 'Try it out' button +of the route that you have defined. The result of the request will be displayed +in the 'Response' section and contains the list of partners. + +.. note:: + The **'FastAPI Endpoint Runner'** group ensures that the user can access any + information others than the 3 ones mentioned above. This means that for every + information that you want to access from your app, you need to create the + proper ACLs and record rules. (see `Managing security into the route handlers`_) + It's a good practice to use a dedicated user into a specific group from the + beginning of your project and in your tests. This will force you to define + the proper security rules for your endoints. + Dealing with the odoo environment ********************************* @@ -243,19 +295,15 @@ of these parameters are dependencies themselves. _id: int = Depends(fastapi_endpoint_id), # noqa: B008 env: Environment = Depends(odoo_env), # noqa: B008 ) -> "FastapiEndpoint": - """Return the fastapi.endpoint record - - Be careful, the information are returned as sudo - """ - # TODO we should declare a technical user with read access only on the - # fastapi.endpoint model - return env["fastapi.endpoint"].sudo().browse(_id) + """Return the fastapi.endpoint record""" + return env["fastapi.endpoint"].browse(_id) As you can see, one of these dependencies is the **'fastapi_endpoint_id'** dependency and has no concrete implementation. This method is used as a contract that must be implemented/provided at the time the fastapi app is created. Here comes the power of the dependency_overrides mechanism. + If you take a look at the **'_get_app'** method of the **'FastapiEndpoint'** model, you will see that the **'fastapi_endpoint_id'** dependency is overriden by registering a specific method that returns the id of the current fastapi endpoint @@ -798,8 +846,6 @@ If your new addon is not installed in a database, a call to the route handler if you extend a model, you must add new required fields or you must provide default values for the new optional fields. - - Managing security into the route handlers ***************************************** @@ -816,18 +862,21 @@ The fastapi addon extends the 'ir.rule' model to add into the evaluation context of the security rules the key 'authenticated_partner_id' that contains the id of the authenticated partner. -A good practice when you develop a fastapi app and you want to protect your data -in an efficient and traceable way is to: +As briefly introduced in a previous section, a good practice when you develop a +fastapi app and you want to protect your data in an efficient and traceable way is to: * create a new user specific to the app but with any access rights. -* create a security group specific to the app and add the user to this group. +* create a security group specific to the app and add the user to this group. (This + group must implies the group 'AFastAPI Endpoint Runner' that give the + minimal access rights) * for each model you want to protect: * add a 'ir.model.access' record for the model to allow read access to your model and add the group to the record. * create a new 'ir.rule' record for the model that restricts the access to the records of the model to the authenticated partner by using the key - 'authenticated_partner_id' in domain of the rule. + 'authenticated_partner_id' in domain of the rule. (or to the user defined on + the 'fastapi.endpoint' model instance if the method is public) * add a dependency on the 'authenticated_partner' to your handlers when you need to access the authenticated partner or ensure that the service is called by an @@ -835,21 +884,27 @@ in an efficient and traceable way is to: .. code-block:: xml - - My Demo App User - demo_app_user + + My Demo Endpoint User + my_demo_app_user + - - My Demo App - + + My Demo Endpoint Group + + My Demo App: access to sale.order - + @@ -861,7 +916,7 @@ in an efficient and traceable way is to: Sale Order Rule [('partner_id', '=', authenticated_partner_id)] - + How to test your fastapi app diff --git a/fastapi/security/ir_rule+acl.xml b/fastapi/security/ir_rule+acl.xml new file mode 100644 index 000000000..592a4646f --- /dev/null +++ b/fastapi/security/ir_rule+acl.xml @@ -0,0 +1,65 @@ + + + + + + Fastapi: Running user read users + + + + + + + + + + + Fastapi: Running user rule + + [('id', '=', user.id)] + + + + + + Fastapi: Running user read partners + + + + + + + + + + + Fastapi: Running user rule + + ['|', ('user_id', '=', user.id), ('id', '=', authenticated_partner_id)] + + + + + + Fastapi: Running user read endpoints + + + + + + + + + + + Fastapi: Running user rule + + [('user_id', '=', user.id)] + + + + diff --git a/fastapi/security/res_groups.xml b/fastapi/security/res_groups.xml index e117c5f1a..589cbcf97 100644 --- a/fastapi/security/res_groups.xml +++ b/fastapi/security/res_groups.xml @@ -23,4 +23,11 @@ eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]" /> + + + + FastAPI Endpoint Runner + + diff --git a/fastapi/static/description/index.html b/fastapi/static/description/index.html index df16cfcf7..c4b9911c4 100644 --- a/fastapi/static/description/index.html +++ b/fastapi/static/description/index.html @@ -562,6 +562,54 @@

    What’s building an API with fastapi?

    Now, you can start your Odoo server, install your addon and create a new endpoint instance for your app. Once it’s done click on the docs url to access the interactive documentation of your app.

    +

    Before trying to test your app, you need to define on the endpoint instance the +user that will be used to run the app. You can do it by setting the ‘user_id’ +field. This information is the most important one because it’s the basis for +the security of your app. The user that you define in the endpoint instance +will be used to run the app and to access the database. This means that the +user will be able to access all the data that he has access to in Odoo. To ensure +the security of your app, you should create a new user that will be used only +to run your app and that will have no access to the database.

    +
    +<record
    +      id="my_demo_app_user"
    +      model="res.users"
    +      context="{'no_reset_password': True, 'no_reset_password': True}"
    +  >
    +  <field name="name">My Demo Endpoint User</field>
    +  <field name="login">my_demo_app_user</field>
    +  <field name="groups_id" eval="[(6, 0, [])]" />
    +</record>
    +
    +

    At the same time you should create a new group that will be used to define the +access rights of the user that will run your app. This group should imply +the predefined group ‘FastAPI Endpoint Runner’. This group defines the +minimum access rights that the user needs to:

    +
      +
    • access the endpoint instance it belongs to
    • +
    • access to its own user record
    • +
    • access to the partner record that is linked to its user record
    • +
    +
    +<record id="my_demo_app_group" model="res.groups">
    +  <field name="name">My Demo Endpoint Group</field>
    +  <field name="users" eval="[(4, ref('my_demo_app_user'))]" />
    +  <field name="implied_ids" eval="[(4, ref('fast_api.group_fastapi_endpoint_runner'))]" />
    +</record>
    +
    +

    Now, you can test your app. You can do it by clicking on the ‘Try it out’ button +of the route that you have defined. The result of the request will be displayed +in the ‘Response’ section and contains the list of partners.

    +
    +

    Note

    +

    The ‘FastAPI Endpoint Runner’ group ensures that the user can access any +information others than the 3 ones mentioned above. This means that for every +information that you want to access from your app, you need to create the +proper ACLs and record rules. (see Managing security into the route handlers) +It’s a good practice to use a dedicated user into a specific group from the +beginning of your project and in your tests. This will force you to define +the proper security rules for your endoints.

    +

    Dealing with the odoo environment

    @@ -633,19 +681,14 @@

    The dependency injection mechanism

    _id: int = Depends(fastapi_endpoint_id), # noqa: B008 env: Environment = Depends(odoo_env), # noqa: B008 ) -> "FastapiEndpoint": - """Return the fastapi.endpoint record - - Be careful, the information are returned as sudo - """ - # TODO we should declare a technical user with read access only on the - # fastapi.endpoint model - return env["fastapi.endpoint"].sudo().browse(_id) + """Return the fastapi.endpoint record""" + return env["fastapi.endpoint"].browse(_id)

    As you can see, one of these dependencies is the ‘fastapi_endpoint_id’ dependency and has no concrete implementation. This method is used as a contract that must be implemented/provided at the time the fastapi app is created. -Here comes the power of the dependency_overrides mechanism. -If you take a look at the ‘_get_app’ method of the ‘FastapiEndpoint’ model, +Here comes the power of the dependency_overrides mechanism.

    +

    If you take a look at the ‘_get_app’ method of the ‘FastapiEndpoint’ model, you will see that the ‘fastapi_endpoint_id’ dependency is overriden by registering a specific method that returns the id of the current fastapi endpoint model instance for the original method.

    @@ -1145,17 +1188,20 @@

    Managing security into the route handlers

    The fastapi addon extends the ‘ir.rule’ model to add into the evaluation context of the security rules the key ‘authenticated_partner_id’ that contains the id of the authenticated partner.

    -

    A good practice when you develop a fastapi app and you want to protect your data -in an efficient and traceable way is to:

    +

    As briefly introduced in a previous section, a good practice when you develop a +fastapi app and you want to protect your data in an efficient and traceable way is to:

    • create a new user specific to the app but with any access rights.
    • -
    • create a security group specific to the app and add the user to this group.
    • +
    • create a security group specific to the app and add the user to this group. (This +group must implies the group ‘AFastAPI Endpoint Runner’ that give the +minimal access rights)
    • for each model you want to protect:
      • add a ‘ir.model.access’ record for the model to allow read access to your model and add the group to the record.
      • create a new ‘ir.rule’ record for the model that restricts the access to the records of the model to the authenticated partner by using the key -‘authenticated_partner_id’ in domain of the rule.
      • +‘authenticated_partner_id’ in domain of the rule. (or to the user defined on +the ‘fastapi.endpoint’ model instance if the method is public)
    • add a dependency on the ‘authenticated_partner’ to your handlers when you need @@ -1163,21 +1209,27 @@

      Managing security into the route handlers

      authenticated partner.
    -<record id="demo_app_user" model="res.users">
    -  <field name="name">My Demo App User</field>
    -  <field name="login">demo_app_user</field>
    +<record
    +      id="my_demo_app_user"
    +      model="res.users"
    +      context="{'no_reset_password': True, 'no_reset_password': True}"
    +  >
    +  <field name="name">My Demo Endpoint User</field>
    +  <field name="login">my_demo_app_user</field>
    +  <field name="groups_id" eval="[(6, 0, [])]" />
     </record>
     
    -<record id="demo_app_group" model="res.groups">
    -  <field name="name">My Demo App</field>
    -  <field name="users" eval="[(4, ref('demo_app_user'))]"/>
    +<record id="my_demo_app_group" model="res.groups">
    +  <field name="name">My Demo Endpoint Group</field>
    +  <field name="users" eval="[(4, ref('my_demo_app_user'))]" />
    +  <field name="implied_ids" eval="[(4, ref('group_fastapi_endpoint_runner'))]" />
     </record>
     
     <!-- acl for the model 'sale.order' -->
     <record id="sale_order_demo_app_access" model="ir.model.access">
       <field name="name">My Demo App: access to sale.order</field>
       <field name="model_id" ref="model_sale_order"/>
    -  <field name="group_id" ref="demo_app_group"/>
    +  <field name="group_id" ref="my_demo_app_group"/>
       <field name="perm_read" eval="True"/>
       <field name="perm_write" eval="False"/>
       <field name="perm_create" eval="False"/>
    @@ -1189,7 +1241,7 @@ 

    Managing security into the route handlers

    <field name="name">Sale Order Rule</field> <field name="model_id" ref="model_sale_order"/> <field name="domain_force">[('partner_id', '=', authenticated_partner_id)]</field> - <field name="groups" eval="[(4, ref('demo_app_group'))]"/> + <field name="groups" eval="[(4, ref('my_demo_app_group'))]"/> </record>
    diff --git a/fastapi/tests/test_fastapi_demo.py b/fastapi/tests/test_fastapi_demo.py index b40874412..1600001f3 100644 --- a/fastapi/tests/test_fastapi_demo.py +++ b/fastapi/tests/test_fastapi_demo.py @@ -40,7 +40,9 @@ def setUpClass(cls) -> None: # TestClient will let unexpected exception bubble up to the test method # to allows you to process the error accordingly cls.client = TestClient(cls.app, raise_server_exceptions=False) - cls._ctx_token = odoo_env_ctx.set(cls.env) + cls._ctx_token = odoo_env_ctx.set( + cls.env(user=cls.env.ref("fastapi.my_demo_app_user")) + ) @classmethod def tearDownClass(cls) -> None: From 47f0f73ddab427bd71121fc4f2d9bbe7c50a394d Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 24 Apr 2023 09:26:17 +0200 Subject: [PATCH 024/118] [IMP] fastapi: Declared as Beta --- fastapi/__manifest__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fastapi/__manifest__.py b/fastapi/__manifest__.py index d8f8a1da3..d034b8d34 100644 --- a/fastapi/__manifest__.py +++ b/fastapi/__manifest__.py @@ -29,4 +29,5 @@ "parse-accept-language", ] }, + "development_status": "Beta", } From 39692dd5d458bd4da9164f0bf3f6e105cd04c645 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 24 Apr 2023 09:26:33 +0200 Subject: [PATCH 025/118] [IMP] fastapi: Typos --- fastapi/models/fastapi_endpoint_demo.py | 2 +- fastapi/readme/USAGE.rst | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fastapi/models/fastapi_endpoint_demo.py b/fastapi/models/fastapi_endpoint_demo.py index 3844c1fdc..1d1804e1c 100644 --- a/fastapi/models/fastapi_endpoint_demo.py +++ b/fastapi/models/fastapi_endpoint_demo.py @@ -30,7 +30,7 @@ class FastapiEndpoint(models.Model): selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} ) demo_auth_method = fields.Selection( - selection=[("api_key", "Api Key"), ("http_basic", "HTTP Bacic")], + selection=[("api_key", "Api Key"), ("http_basic", "HTTP Basic")], string="Authenciation method", ) diff --git a/fastapi/readme/USAGE.rst b/fastapi/readme/USAGE.rst index 29bd5d0ce..7e49df76d 100644 --- a/fastapi/readme/USAGE.rst +++ b/fastapi/readme/USAGE.rst @@ -133,7 +133,7 @@ that returns a list of partners. from fastapi import APIRouter from pydantic import BaseModel from odoo import api, fields, models - from ..depends import odoo_env + from odoo.addons.fastapi.depends import odoo_env class FastapiEndpoint(models.Model): @@ -202,7 +202,7 @@ minimum access rights that the user needs to: My Demo Endpoint Group - + @@ -229,7 +229,7 @@ odoo models and the database from your route handlers. .. code-block:: python - from ..depends import odoo_env + from odoo.addons.fastapi.depends import odoo_env @demo_api_router.get("/partners", response_model=list[PartnerInfo]) def get_partners(env=Depends(odoo_env)) -> list[PartnerInfo]: From 01c3a2ca6c4f1858da6a74a8e5f8795e6e93b0f6 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Wed, 7 Jun 2023 15:28:49 +0000 Subject: [PATCH 026/118] [UPD] Update fastapi.pot --- fastapi/i18n/fastapi.pot | 230 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 fastapi/i18n/fastapi.pot diff --git a/fastapi/i18n/fastapi.pot b/fastapi/i18n/fastapi.pot new file mode 100644 index 000000000..6b58fd041 --- /dev/null +++ b/fastapi/i18n/fastapi.pot @@ -0,0 +1,230 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fastapi +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: fastapi +#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__description +msgid "A short description of the API. It can use Markdown" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__active +msgid "Active" +msgstr "" + +#. module: fastapi +#: model:res.groups,name:fastapi.group_fastapi_manager +msgid "Administrator" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__demo_auth_method__api_key +msgid "Api Key" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__app +#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view +msgid "App" +msgstr "" + +#. module: fastapi +#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view +#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view +msgid "Archived" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__demo_auth_method +msgid "Authenciation method" +msgstr "" + +#. module: fastapi +#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_demo_form_view +msgid "Configuration" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__create_uid +msgid "Created by" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__create_date +msgid "Created on" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__app__demo +msgid "Demo Endpoint" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__description +msgid "Description" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__display_name +msgid "Display Name" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__docs_url +msgid "Docs Url" +msgstr "" + +#. module: fastapi +#: model:ir.module.category,name:fastapi.module_category_fastapi +#: model:ir.ui.menu,name:fastapi.menu_fastapi_root +msgid "FastAPI" +msgstr "" + +#. module: fastapi +#: model:ir.actions.act_window,name:fastapi.fastapi_endpoint_act_window +#: model:ir.model,name:fastapi.model_fastapi_endpoint +#: model:ir.ui.menu,name:fastapi.fastapi_endpoint_menu +msgid "FastAPI Endpoint" +msgstr "" + +#. module: fastapi +#: model:res.groups,name:fastapi.group_fastapi_endpoint_runner +msgid "FastAPI Endpoint Runner" +msgstr "" + +#. module: fastapi +#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_search_view +msgid "Group by..." +msgstr "" + +#. module: fastapi +#: model:ir.model.fields.selection,name:fastapi.selection__fastapi_endpoint__demo_auth_method__http_basic +msgid "HTTP Basic" +msgstr "" + +#. module: fastapi +#: model:ir.module.category,description:fastapi.module_category_fastapi +msgid "Helps you manage your Fastapi Endpoints" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__id +msgid "ID" +msgstr "" + +#. module: fastapi +#: model:ir.model,name:fastapi.model_res_lang +msgid "Languages" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint____last_update +msgid "Last Modified on" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__write_date +msgid "Last Updated on" +msgstr "" + +#. module: fastapi +#: model:res.groups,name:fastapi.my_demo_app_group +msgid "My Demo Endpoint Group" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__name +msgid "Name" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__openapi_url +msgid "Openapi Url" +msgstr "" + +#. module: fastapi +#: model:ir.model,name:fastapi.model_ir_rule +msgid "Record Rule" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__redoc_url +msgid "Redoc Url" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__registry_sync +msgid "Registry Sync" +msgstr "" + +#. module: fastapi +#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view +msgid "Registry Sync Required" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__root_path +msgid "Root Path" +msgstr "" + +#. module: fastapi +#: model:ir.actions.server,name:fastapi.fastapi_endpoint_action_sync_registry +#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_form_view +#: model_terms:ir.ui.view,arch_db:fastapi.fastapi_endpoint_tree_view +msgid "Sync Registry" +msgstr "" + +#. module: fastapi +#. odoo-python +#: code:addons/fastapi/models/fastapi_endpoint_demo.py:0 +#, python-format +msgid "The authentication method is required for app %(app)s" +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__name +msgid "The title of the API." +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__user_id +msgid "The user to use to execute the API calls." +msgstr "" + +#. module: fastapi +#: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__user_id +#: model:res.groups,name:fastapi.group_fastapi_user +msgid "User" +msgstr "" + +#. module: fastapi +#. odoo-python +#: code:addons/fastapi/models/fastapi_endpoint.py:0 +#, python-format +msgid "`%(name)s` uses a blacklisted root_path = `%(root_path)s`" +msgstr "" From 3d1e64557e4fe71d1d30be3260fd3143737aa0f9 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 7 Jun 2023 15:31:48 +0000 Subject: [PATCH 027/118] [UPD] README.rst --- fastapi/README.rst | 6 +++--- fastapi/static/description/index.html | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/fastapi/README.rst b/fastapi/README.rst index c271e5800..207cb17c8 100644 --- a/fastapi/README.rst +++ b/fastapi/README.rst @@ -208,7 +208,7 @@ that returns a list of partners. from fastapi import APIRouter from pydantic import BaseModel from odoo import api, fields, models - from ..depends import odoo_env + from odoo.addons.fastapi.depends import odoo_env class FastapiEndpoint(models.Model): @@ -277,7 +277,7 @@ minimum access rights that the user needs to: My Demo Endpoint Group - + @@ -304,7 +304,7 @@ odoo models and the database from your route handlers. .. code-block:: python - from ..depends import odoo_env + from odoo.addons.fastapi.depends import odoo_env @demo_api_router.get("/partners", response_model=list[PartnerInfo]) def get_partners(env=Depends(odoo_env)) -> list[PartnerInfo]: diff --git a/fastapi/static/description/index.html b/fastapi/static/description/index.html index c4b9911c4..ae29e81a0 100644 --- a/fastapi/static/description/index.html +++ b/fastapi/static/description/index.html @@ -3,7 +3,7 @@ - + Odoo FastAPI