# Copyright 2022 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). import logging from functools import partial from itertools import chain from typing import Any, Callable, Dict, List, Tuple from starlette.middleware import Middleware from starlette.routing import Mount from odoo import _, api, exceptions, fields, models, tools from fastapi import APIRouter, Depends, FastAPI from .. import dependencies from ..middleware import ASGIMiddleware _logger = logging.getLogger(__name__) 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.") 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) 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") company_id = fields.Many2one( "res.company", compute="_compute_company_id", store=True, readonly=False, domain="[('user_ids', 'in', user_id)]", ) save_http_session = fields.Boolean( string="Save HTTP Session", help="Whether session should be saved into the session store. This is " "required if for example you use the Odoo's authentication mechanism. " "Oherwise chance are high that you don't need it and could turn off " "this behaviour. Additionaly turning off this option will prevent useless " "IO operation when storing and reading the session on the disk and prevent " "unexpecteed disk space consumption.", 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.depends("user_id") def _compute_company_id(self): for endpoint in self: endpoint.company_id = endpoint.user_id.company_id # # endpoint.route.sync.mixin methods implementation # def _prepare_endpoint_rules(self, options=None): return [rec._make_routing_rule(options=options) for rec in self] def _registered_endpoint_rule_keys(self): res = [] for rec in self: routing = rec._get_routing_info() res.append(rec._endpoint_registry_route_unique_key(routing)) 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", "save_http_session") # # end of endpoint.route.sync.mixin methods implementation # def write(self, vals): res = super().write(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)]) if refresh_fastapi_app: self._reset_app() if "user_id" in vals: self.get_uid.clear_cache(self) return False @api.model def _fastapi_app_fields(self) -> List[str]: """The list of fields requiring to refresh the fastapi app if modified""" return [] def _make_routing_rule(self, options=None): """Generator of rule""" self.ensure_one() routing = self._get_routing_info() options = options or self._default_endpoint_options() 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()} 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", "PATCH", "DELETE", "OPTIONS", "HEAD"], "routes": [ f"{self.root_path}/", f"{self.root_path}/", ], "save_session": self.save_http_session, # csrf ????? } 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}" def _reset_app(self): self._get_id_by_root_path_map.clear_cache(self) self._get_id_for_path.clear_cache(self) self._reset_app_cache_marker.clear_cache(self) @tools.ormcache() def _reset_app_cache_marker(self): """This methos is used to get a way to mark the orm cache as dirty when the app is reset. By marking the cache as dirty, the system will signal to others instances that the cache is not up to date and that they should invalidate their cache as well. This is required to ensure that any change requiring a reset of the app is propagated to all the running instances. """ @api.model def _normalize_url_path(self, path) -> str: """ Normalize a URL path: * Remove redundant slashes, * Remove trailing slash (unless it's the root), * Lowercase for case-insensitive matching """ parts = [part.lower() for part in path.strip().split("/") if part] return "/" + "/".join(parts) @api.model def _is_suburl(self, path, prefix) -> bool: """ Check if 'path' is a subpath of 'prefix' in URL logic: * Must start with the prefix followed by a slash This will ensure that the matching is done one the path parts and ensures that e.g. /a/b is not prefix of /a/bc. """ path = self._normalize_url_path(path) prefix = self._normalize_url_path(prefix) if path == prefix: return True if path.startswith(prefix + "/"): return True return False @api.model def _find_first_matching_url_path(self, paths, prefix) -> str | None: """ Return the first path that is a subpath of 'prefix', ordered by longest URL path first (most number of segments). """ # Sort by number of segments (shallowest first) sorted_paths = sorted( paths, key=lambda p: len(self._normalize_url_path(p).split("/")), reverse=True, ) for path in sorted_paths: if self._is_suburl(prefix, path): return path return None @api.model @tools.ormcache() def _get_id_by_root_path_map(self): return {r.root_path: r.id for r in self.search([])} @api.model @tools.ormcache("path") def _get_id_for_path(self, path): id_by_path = self._get_id_by_root_path_map() root_path = self._find_first_matching_url_path(id_by_path.keys(), path) return id_by_path.get(root_path) @api.model def _get_endpoint(self, path): id_ = self._get_id_for_path(path) return self.browse(id_) if id_ else None @api.model def get_app(self, path): record = self._get_endpoint(path) if not record: return None app = FastAPI() app.mount(record.root_path, record._get_app()) self._clear_fastapi_exception_handlers(app) return ASGIMiddleware(app) def _clear_fastapi_exception_handlers(self, app: FastAPI) -> None: """ Clear the exception handlers of the given fastapi app. This method is used to ensure that the exception handlers are handled by odoo and not by fastapi. We therefore need to remove all the handlers added by default when instantiating a FastAPI app. Since apps can be mounted recursively, we need to apply this method to all the apps in the mounted tree. """ app.exception_handlers = {} for route in app.routes: if isinstance(route, Mount): self._clear_fastapi_exception_handlers(route.app) @api.model @tools.ormcache("path") def get_uid(self, path): record = self._get_endpoint(path) if not record: return None return record.user_id.id def _get_app(self) -> FastAPI: app = FastAPI(**self._prepare_fastapi_app_params()) for router in self._get_fastapi_routers(): app.include_router(router=router) app.dependency_overrides.update(self._get_app_dependencies_overrides()) return app def _get_app_dependencies_overrides(self) -> Dict[Callable, Callable]: return { dependencies.fastapi_endpoint_id: partial(lambda a: a, self.id), dependencies.company_id: partial(lambda a: a, self.company_id.id), } def _prepare_fastapi_app_params(self) -> Dict[str, Any]: """Return the params to pass to the Fast API app constructor""" return { "title": self.name, "description": self.description, "middleware": self._get_fastapi_app_middlewares(), "dependencies": self._get_fastapi_app_dependencies(), } def _get_fastapi_routers(self) -> List[APIRouter]: """Return the api routers to use for the instance. 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 [] def _get_fastapi_app_dependencies(self) -> List[Depends]: """Return the dependencies to use for the fastapi app.""" return [Depends(dependencies.accept_language)]