Skip to content

Commit cf4216e

Browse files
[ADD] fastapi
1 parent 63eb387 commit cf4216e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+7639
-0
lines changed

fastapi/README.rst

Lines changed: 1675 additions & 0 deletions
Large diffs are not rendered by default.

fastapi/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import models
2+
from . import fastapi_dispatcher
3+
from . import error_handlers

fastapi/__manifest__.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Copyright 2022 ACSONE SA/NV
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
3+
4+
{
5+
"name": "Odoo FastAPI",
6+
"summary": """
7+
Odoo FastAPI endpoint""",
8+
"version": "18.0.1.0.0",
9+
"license": "LGPL-3",
10+
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
11+
"maintainers": ["lmignon"],
12+
"website": "https://github.com/OCA/rest-framework",
13+
"depends": ["endpoint_route_handler"],
14+
"data": [
15+
"security/res_groups.xml",
16+
"security/fastapi_endpoint.xml",
17+
"security/ir_rule+acl.xml",
18+
"views/fastapi_menu.xml",
19+
"views/fastapi_endpoint.xml",
20+
"views/fastapi_endpoint_demo.xml",
21+
],
22+
"demo": ["demo/fastapi_endpoint_demo.xml"],
23+
"external_dependencies": {
24+
"python": [
25+
"fastapi>=0.110.0",
26+
"python-multipart",
27+
"ujson",
28+
"a2wsgi>=1.10.6",
29+
"parse-accept-language",
30+
]
31+
},
32+
"development_status": "Beta",
33+
"installable": True,
34+
}

fastapi/context.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Copyright 2022 ACSONE SA/NV
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
3+
4+
# define context vars to hold the odoo env
5+
6+
from contextvars import ContextVar
7+
8+
from odoo.api import Environment
9+
10+
odoo_env_ctx: ContextVar[Environment] = ContextVar("odoo_env_ctx")
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<!-- Copyright 2022 ACSONE SA/NV
3+
License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). -->
4+
<odoo>
5+
<!-- This is the user that will be used to run the demo app -->
6+
<record
7+
id="my_demo_app_user"
8+
model="res.users"
9+
context="{'no_reset_password': True, 'no_reset_password': True}"
10+
>
11+
<field name="name">My Demo Endpoint User</field>
12+
<field name="login">my_demo_app_user</field>
13+
<field name="groups_id" eval="[(6, 0, [])]" />
14+
</record>
15+
16+
<!-- This is the group that will be used to run the demo app
17+
This group will only depend on the "group_fastapi_endpoint_runner" group
18+
that provides the minimal access rights to retrieve the user running the
19+
endpoint handlers and performs authentication.
20+
-->
21+
<record id="my_demo_app_group" model="res.groups">
22+
<field name="name">My Demo Endpoint Group</field>
23+
<field name="users" eval="[(4, ref('my_demo_app_user'))]" />
24+
<field name="implied_ids" eval="[(4, ref('group_fastapi_endpoint_runner'))]" />
25+
</record>
26+
27+
<!-- This is the endpoint that will be used to run the demo app
28+
This endpoint will be registered on the "/fastapi_demo" path
29+
-->
30+
31+
<record model="fastapi.endpoint" id="fastapi_endpoint_demo">
32+
<field name="name">Fastapi Demo Endpoint</field>
33+
<field
34+
name="description"
35+
><![CDATA[
36+
# A Dummy FastApi Demo
37+
38+
This demo endpoint has been created by inhering from "fastapi.endpoint", registering
39+
a new app into the app selection field and implementing the `_get_fastapi_routers`
40+
methods. See documentation to learn more about how to create a new app.
41+
]]></field>
42+
<field name="app">demo</field>
43+
<field name="root_path">/fastapi_demo</field>
44+
<field name="demo_auth_method">http_basic</field>
45+
<field name="user_id" ref="my_demo_app_user" />
46+
</record>
47+
</odoo>

fastapi/dependencies.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Copyright 2022 ACSONE SA/NV
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
3+
4+
from typing import TYPE_CHECKING, Annotated
5+
6+
from odoo.api import Environment
7+
from odoo.exceptions import AccessDenied
8+
9+
from odoo.addons.base.models.res_partner import Partner
10+
from odoo.addons.base.models.res_users import Users
11+
12+
from fastapi import Depends, Header, HTTPException, Query, status
13+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
14+
15+
from .context import odoo_env_ctx
16+
from .schemas import Paging
17+
18+
if TYPE_CHECKING:
19+
from .models.fastapi_endpoint import FastapiEndpoint
20+
21+
22+
def company_id() -> int | None:
23+
"""This method may be overriden by the FastAPI app to set the allowed company
24+
in the Odoo env of the endpoint. By default, the company defined on the
25+
endpoint record is used.
26+
"""
27+
return None
28+
29+
30+
def odoo_env(company_id: Annotated[int | None, Depends(company_id)]) -> Environment:
31+
env = odoo_env_ctx.get()
32+
if company_id is not None:
33+
env = env(context=dict(env.context, allowed_company_ids=[company_id]))
34+
35+
yield env
36+
37+
38+
def authenticated_partner_impl() -> Partner:
39+
"""This method has to be overriden when you create your fastapi app
40+
to declare the way your partner will be provided. In some case, this
41+
partner will come from the authentication mechanism (ex jwt token) in other cases
42+
it could comme from a lookup on an email received into an HTTP header ...
43+
See the fastapi_endpoint_demo for an example"""
44+
45+
46+
def optionally_authenticated_partner_impl() -> Partner | None:
47+
"""This method has to be overriden when you create your fastapi app
48+
and you need to get an optional authenticated partner into your endpoint.
49+
"""
50+
51+
52+
def authenticated_partner_env(
53+
partner: Annotated[Partner, Depends(authenticated_partner_impl)],
54+
) -> Environment:
55+
"""Return an environment with the authenticated partner id in the context"""
56+
return partner.with_context(authenticated_partner_id=partner.id).env
57+
58+
59+
def optionally_authenticated_partner_env(
60+
partner: Annotated[Partner | None, Depends(optionally_authenticated_partner_impl)],
61+
env: Annotated[Environment, Depends(odoo_env)],
62+
) -> Environment:
63+
"""Return an environment with the authenticated partner id in the context if
64+
the partner is not None
65+
"""
66+
if partner:
67+
return partner.with_context(authenticated_partner_id=partner.id).env
68+
return env
69+
70+
71+
def authenticated_partner(
72+
partner: Annotated[Partner, Depends(authenticated_partner_impl)],
73+
partner_env: Annotated[Environment, Depends(authenticated_partner_env)],
74+
) -> Partner:
75+
"""If you need to get access to the authenticated partner into your
76+
endpoint, you can add a dependency into the endpoint definition on this
77+
method.
78+
This method is a safe way to declare a dependency without requiring a
79+
specific implementation. It depends on `authenticated_partner_impl`. The
80+
concrete implementation of authenticated_partner_impl has to be provided
81+
when the FastAPI app is created.
82+
This method return a partner into the authenticated_partner_env
83+
"""
84+
return partner_env["res.partner"].browse(partner.id)
85+
86+
87+
def optionally_authenticated_partner(
88+
partner: Annotated[Partner | None, Depends(optionally_authenticated_partner_impl)],
89+
partner_env: Annotated[Environment, Depends(optionally_authenticated_partner_env)],
90+
) -> Partner | None:
91+
"""If you need to get access to the authenticated partner if the call is
92+
authenticated, you can add a dependency into the endpoint definition on this
93+
method.
94+
95+
This method defer from authenticated_partner by the fact that it returns
96+
None if the partner is not authenticated .
97+
"""
98+
if partner:
99+
return partner_env["res.partner"].browse(partner.id)
100+
return None
101+
102+
103+
def paging(
104+
page: Annotated[int, Query(ge=1)] = 1, page_size: Annotated[int, Query(ge=1)] = 80
105+
) -> Paging:
106+
"""Return a Paging object from the page and page_size parameters"""
107+
return Paging(limit=page_size, offset=(page - 1) * page_size)
108+
109+
110+
def basic_auth_user(
111+
credential: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
112+
env: Annotated[Environment, Depends(odoo_env)],
113+
) -> Users:
114+
username = credential.username
115+
password = credential.password
116+
try:
117+
response = (
118+
env["res.users"]
119+
.sudo()
120+
.authenticate(
121+
db=env.cr.dbname,
122+
credential={
123+
"type": "password",
124+
"login": username,
125+
"password": password,
126+
},
127+
user_agent_env=None,
128+
)
129+
)
130+
return env["res.users"].browse(response.get("uid"))
131+
except AccessDenied as ad:
132+
raise HTTPException(
133+
status_code=status.HTTP_401_UNAUTHORIZED,
134+
detail="Incorrect username or password",
135+
headers={"WWW-Authenticate": "Basic"},
136+
) from ad
137+
138+
139+
def authenticated_partner_from_basic_auth_user(
140+
user: Annotated[Users, Depends(basic_auth_user)],
141+
env: Annotated[Environment, Depends(odoo_env)],
142+
) -> Partner:
143+
return env["res.partner"].browse(user.sudo().partner_id.id)
144+
145+
146+
def fastapi_endpoint_id() -> int:
147+
"""This method is overriden by the FastAPI app to make the fastapi.endpoint record
148+
available for your endpoint method. To get the fastapi.endpoint record
149+
in your method, you just need to add a dependency on the fastapi_endpoint method
150+
defined below
151+
"""
152+
153+
154+
def fastapi_endpoint(
155+
_id: Annotated[int, Depends(fastapi_endpoint_id)],
156+
env: Annotated[Environment, Depends(odoo_env)],
157+
) -> "FastapiEndpoint":
158+
"""Return the fastapi.endpoint record"""
159+
return env["fastapi.endpoint"].browse(_id)
160+
161+
162+
def accept_language(
163+
accept_language: Annotated[
164+
str | None,
165+
Header(
166+
alias="Accept-Language",
167+
description="The Accept-Language header is used to specify the language "
168+
"of the content to be returned. If a language is not available, the "
169+
"server will return the content in the default language.",
170+
),
171+
] = None,
172+
) -> str:
173+
"""This dependency is used at application level to document the way the language
174+
to use for the response is specified. The header is processed outside of the
175+
fastapi app to initialize the odoo environment with the right language.
176+
"""
177+
return accept_language

fastapi/depends.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright 2023 ACSONE SA/NV
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
3+
4+
import warnings
5+
6+
warnings.warn(
7+
"The 'depends' package is deprecated. Please use 'dependencies' instead.",
8+
DeprecationWarning,
9+
stacklevel=2,
10+
)
11+
12+
from .dependencies import * # noqa: F403, F401, E402

fastapi/error_handlers.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Copyright 2022 ACSONE SA/NV
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL).
3+
4+
from starlette import status
5+
from starlette.exceptions import HTTPException, WebSocketException
6+
from starlette.middleware.errors import ServerErrorMiddleware
7+
from starlette.middleware.exceptions import ExceptionMiddleware
8+
from starlette.responses import JSONResponse
9+
from starlette.websockets import WebSocket
10+
from werkzeug.exceptions import HTTPException as WerkzeugHTTPException
11+
12+
from odoo.exceptions import AccessDenied, AccessError, MissingError, UserError
13+
14+
from fastapi import Request
15+
from fastapi.encoders import jsonable_encoder
16+
from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError
17+
from fastapi.utils import is_body_allowed_for_status_code
18+
19+
20+
def convert_exception_to_status_body(exc: Exception) -> tuple[int, dict]:
21+
body = {}
22+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
23+
details = "Internal Server Error"
24+
25+
if isinstance(exc, WerkzeugHTTPException):
26+
status_code = exc.code
27+
details = exc.description
28+
elif isinstance(exc, HTTPException):
29+
status_code = exc.status_code
30+
details = exc.detail
31+
elif isinstance(exc, RequestValidationError):
32+
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
33+
details = jsonable_encoder(exc.errors())
34+
elif isinstance(exc, WebSocketRequestValidationError):
35+
status_code = status.WS_1008_POLICY_VIOLATION
36+
details = jsonable_encoder(exc.errors())
37+
elif isinstance(exc, AccessDenied | AccessError):
38+
status_code = status.HTTP_403_FORBIDDEN
39+
details = "AccessError"
40+
elif isinstance(exc, MissingError):
41+
status_code = status.HTTP_404_NOT_FOUND
42+
details = "MissingError"
43+
elif isinstance(exc, UserError):
44+
status_code = status.HTTP_400_BAD_REQUEST
45+
details = exc.args[0]
46+
47+
if is_body_allowed_for_status_code(status_code):
48+
# use the same format as in
49+
# fastapi.exception_handlers.http_exception_handler
50+
body = {"detail": details}
51+
return status_code, body
52+
53+
54+
# we need to monkey patch the ServerErrorMiddleware and ExceptionMiddleware classes
55+
# to ensure that all the exceptions that are handled by these specific
56+
# middlewares are let to bubble up to the retrying mechanism and the
57+
# dispatcher error handler to ensure that appropriate action are taken
58+
# regarding the transaction, environment, and registry. These middlewares
59+
# are added by default by FastAPI when creating an application and it's not
60+
# possible to remove them. So we need to monkey patch them.
61+
62+
63+
def pass_through_exception_handler(
64+
self, request: Request, exc: Exception
65+
) -> JSONResponse:
66+
raise exc
67+
68+
69+
def pass_through_websocket_exception_handler(
70+
self, websocket: WebSocket, exc: WebSocketException
71+
) -> None:
72+
raise exc
73+
74+
75+
ServerErrorMiddleware.error_response = pass_through_exception_handler
76+
ExceptionMiddleware.http_exception = pass_through_exception_handler
77+
ExceptionMiddleware.websocket_exception = pass_through_websocket_exception_handler

0 commit comments

Comments
 (0)