mirror of
https://github.com/bringout/oca-technical.git
synced 2026-04-18 12:32:01 +02:00
346 lines
12 KiB
Python
346 lines
12 KiB
Python
# 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}/<path:application_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)]
|