Skip to content

Commit 61f2dc3

Browse files
authored
Feat/swagger execute (#25)
* chore: update openapi utils for not depending on FastAPI version * feat: add rpc execute utils
1 parent 21c2a20 commit 61f2dc3

File tree

9 files changed

+801
-665
lines changed

9 files changed

+801
-665
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Execute RPC method endpoint."""
2+
from json import JSONDecodeError
3+
4+
from fastapi import APIRouter, Depends, Request
5+
from fastapi.responses import JSONResponse
6+
from pybotx import Bot
7+
from pybotx_smartapp_rpc import RPCErrorResponse, SmartApp
8+
from pybotx_smartapp_rpc.models.request import RPCRequest
9+
from starlette.status import HTTP_200_OK, HTTP_400_BAD_REQUEST
10+
11+
from app.api.dependencies.bot import bot_dependency
12+
from app.services.execute_rpc import (
13+
RPCAuthConfig,
14+
event_factory,
15+
expand_config,
16+
security,
17+
)
18+
from app.smartapp.smartapp import smartapp as smartapp_rpc
19+
20+
router = APIRouter(include_in_schema=False)
21+
22+
23+
@router.post("/{method:str}", response_class=JSONResponse)
24+
async def rpc_execute(
25+
method: str,
26+
request: Request,
27+
credentials: RPCAuthConfig = Depends(security),
28+
bot: Bot = bot_dependency,
29+
) -> JSONResponse:
30+
"""Execute RPC method."""
31+
bot_account, user_info = await expand_config(credentials, bot)
32+
33+
try:
34+
method_payload = await request.json()
35+
except JSONDecodeError:
36+
method_payload = {}
37+
38+
event = event_factory(
39+
method,
40+
method_payload,
41+
bot_account,
42+
user_info,
43+
credentials.sender_udid,
44+
credentials.chat_id,
45+
)
46+
47+
smartapp = SmartApp(bot, event.bot.id, event.chat.id, event)
48+
rpc_request = RPCRequest(method=method, type="smartapp_rpc", params=method_payload)
49+
50+
rpc_response = await smartapp_rpc._router.perform_rpc_request( # noqa: WPS437
51+
smartapp, rpc_request
52+
)
53+
if isinstance(rpc_response, RPCErrorResponse):
54+
return JSONResponse(
55+
status_code=HTTP_400_BAD_REQUEST,
56+
content=rpc_response.jsonable_dict()["errors"],
57+
)
58+
59+
return JSONResponse(
60+
status_code=HTTP_200_OK, content=rpc_response.jsonable_dict().get("result")
61+
)

app/api/routers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33

44
from app.api.endpoints.botx import router as bot_router
55
from app.api.endpoints.healthcheck import router as healthcheck_router
6+
from app.api.endpoints.swagger_rpc_execute import router as swagger_rpc_execute_router
67

78
router = APIRouter(include_in_schema=False)
89

910
router.include_router(healthcheck_router)
1011
router.include_router(bot_router)
12+
13+
router.include_router(swagger_rpc_execute_router)

app/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def get_custom_openapi() -> Dict[str, Any]: # noqa: WPS430
7676
version="0.1.0",
7777
fastapi_routes=application.routes,
7878
rpc_router=smartapp.router,
79+
openapi_version="3.0.2",
7980
)
8081

8182
application.openapi = get_custom_openapi # type: ignore

app/services/execute_rpc.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""RPC execution service."""
2+
import re
3+
from typing import Any, Dict, Tuple
4+
from uuid import UUID, uuid4
5+
6+
from fastapi import HTTPException
7+
from fastapi.security import APIKeyHeader
8+
from pybotx import ( # noqa: WPS235
9+
Bot,
10+
BotAccount,
11+
Chat,
12+
ChatTypes,
13+
SmartAppEvent,
14+
UserDevice,
15+
UserFromSearch,
16+
UserKinds,
17+
UserNotFoundError,
18+
UserSender,
19+
)
20+
from pydantic import BaseModel, ValidationError
21+
from starlette.requests import Request
22+
from starlette.status import HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN
23+
24+
from app.settings import settings
25+
26+
DOCS = """Установка параметров для выполнение RPC методов.
27+
28+
* `bot_id` - huid бота. Необязательное поле.
29+
* `sender_huid` - huid пользователя. Необязательное поле.
30+
* `sender_udid` - udid пользователя. Необязательное поле.
31+
* `chat_id` - id чата. Необязательное поле.
32+
33+
**Example**: `bot_id=UUID&sender_huid=UUID&sender_udid=UUID&chat_id=UUID`"""
34+
35+
36+
class RPCAuthConfig(BaseModel):
37+
bot_id: UUID = settings.BOT_CREDENTIALS[0].id
38+
sender_huid: UUID = uuid4()
39+
sender_udid: UUID = uuid4()
40+
chat_id: UUID = uuid4()
41+
42+
43+
async def expand_config(
44+
config: RPCAuthConfig, bot: Bot
45+
) -> Tuple[BotAccount, UserFromSearch]:
46+
bot_account = [bot for bot in bot.bot_accounts if bot.id == config.bot_id]
47+
if not bot_account:
48+
raise HTTPException(
49+
status_code=HTTP_400_BAD_REQUEST,
50+
detail=f"Bot with id {config.bot_id} not found",
51+
)
52+
try:
53+
user_info = await bot.search_user_by_huid(
54+
bot_id=bot_account[0].id, huid=config.sender_huid
55+
)
56+
except UserNotFoundError:
57+
user_info = UserFromSearch(
58+
huid=config.sender_huid,
59+
ad_login=None,
60+
ad_domain=None,
61+
username="Username",
62+
company=None,
63+
company_position=None,
64+
department=None,
65+
emails=[],
66+
other_id=None,
67+
user_kind=UserKinds.CTS_USER,
68+
)
69+
70+
return bot_account[0], user_info
71+
72+
73+
def event_factory(
74+
method_name: str,
75+
payload: Dict[str, Any],
76+
bot_account: BotAccount,
77+
user_info: UserFromSearch,
78+
user_udid: UUID,
79+
chat_id: UUID,
80+
) -> SmartAppEvent:
81+
return SmartAppEvent(
82+
bot=BotAccount(
83+
id=bot_account.id,
84+
host=bot_account.host,
85+
),
86+
ref=uuid4(),
87+
smartapp_id=bot_account.id,
88+
data={"method": method_name, "params": payload, "type": "smartapp_rpc"},
89+
opts={},
90+
smartapp_api_version=1,
91+
files=[],
92+
sender=UserSender(
93+
huid=user_info.huid,
94+
udid=user_udid,
95+
ad_login=user_info.ad_login,
96+
ad_domain=user_info.ad_domain,
97+
username=user_info.username,
98+
is_chat_admin=True,
99+
is_chat_creator=True,
100+
device=UserDevice(
101+
manufacturer=None,
102+
device_name=None,
103+
os=None,
104+
pushes=None,
105+
timezone=None,
106+
permissions=None,
107+
platform=None,
108+
platform_package_id=None,
109+
app_version=None,
110+
locale=None,
111+
),
112+
),
113+
chat=Chat(
114+
id=chat_id,
115+
type=ChatTypes.PERSONAL_CHAT,
116+
),
117+
raw_command=None,
118+
)
119+
120+
121+
class RPCAuth(APIKeyHeader):
122+
PATTERN = "([^?=&]+)=([^&]*)"
123+
124+
async def __call__(self, request: Request) -> RPCAuthConfig: # type: ignore
125+
api_key = request.headers.get(self.model.name)
126+
if not api_key:
127+
return RPCAuthConfig()
128+
129+
params = re.findall(self.PATTERN, api_key) # noqa: WPS110
130+
if not params:
131+
raise HTTPException(
132+
status_code=HTTP_403_FORBIDDEN, detail="Invalid RPC Auth format"
133+
)
134+
try:
135+
config = RPCAuthConfig(**dict(params))
136+
except ValidationError as ex:
137+
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail=str(ex))
138+
139+
return config
140+
141+
142+
security = RPCAuth(
143+
scheme_name="RPC Auth",
144+
name="X-RPC-AUTH",
145+
description=DOCS,
146+
)

app/services/openapi.py

Lines changed: 43 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,83 @@
11
"""OpenAPI utils."""
2-
from typing import Any, Dict, List, Optional, Sequence, Union
2+
from typing import Any, Dict, Sequence, Tuple
33

4-
from fastapi import routing
54
from fastapi.encoders import jsonable_encoder
65
from fastapi.openapi.models import OpenAPI
7-
from fastapi.openapi.utils import get_flat_models_from_routes, get_openapi_path
8-
from fastapi.utils import get_model_definitions
6+
from fastapi.openapi.utils import get_openapi
7+
from fastapi.security.base import SecurityBase
98
from pybotx_smartapp_rpc import RPCRouter
109
from pybotx_smartapp_rpc.openapi_utils import (
1110
get_rpc_flat_models_from_routes,
11+
get_rpc_model_definitions,
1212
get_rpc_openapi_path,
1313
)
1414
from pydantic.schema import get_model_name_map
1515
from starlette.routing import BaseRoute
1616

17+
from app.services.execute_rpc import security
18+
19+
20+
def get_openapi_security_definitions(
21+
security_component: SecurityBase,
22+
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
23+
security_definition = jsonable_encoder(
24+
security_component.model,
25+
by_alias=True,
26+
exclude_none=True,
27+
)
28+
security_name = security_component.scheme_name
29+
security_definitions = {security_name: security_definition}
30+
operation_security = {security_name: []} # type: ignore
31+
return security_definitions, operation_security
32+
1733

1834
def custom_openapi(
1935
*,
2036
title: str,
2137
version: str,
22-
openapi_version: str = "3.0.2",
23-
description: Optional[str] = None,
2438
fastapi_routes: Sequence[BaseRoute],
2539
rpc_router: RPCRouter,
26-
tags: Optional[List[Dict[str, Any]]] = None,
27-
servers: Optional[List[Dict[str, Union[str, Any]]]] = None,
28-
terms_of_service: Optional[str] = None,
29-
contact: Optional[Dict[str, Union[str, Any]]] = None,
30-
license_info: Optional[Dict[str, Union[str, Any]]] = None,
40+
**kwargs: Any,
3141
) -> Dict[str, Any]:
32-
info: Dict[str, Any] = {"title": title, "version": version}
33-
if description:
34-
info["description"] = description
35-
if terms_of_service:
36-
info["termsOfService"] = terms_of_service
37-
if contact:
38-
info["contact"] = contact
39-
if license_info:
40-
info["license"] = license_info
41-
output: Dict[str, Any] = {"openapi": openapi_version, "info": info}
42-
if servers:
43-
output["servers"] = servers
44-
components: Dict[str, Dict[str, Any]] = {}
45-
paths: Dict[str, Dict[str, Any]] = {}
46-
# FastAPI
47-
flat_fastapi_models = get_flat_models_from_routes(fastapi_routes)
48-
fastapi_model_name_map = get_model_name_map(flat_fastapi_models)
49-
fast_api_definitions = get_model_definitions(
50-
flat_models=flat_fastapi_models, model_name_map=fastapi_model_name_map
42+
openapi_dict = get_openapi(
43+
title=title,
44+
version=version,
45+
routes=fastapi_routes,
46+
**kwargs,
5147
)
5248

53-
# pybotx RPC
49+
paths: Dict[str, Dict[str, Any]] = {}
50+
5451
flat_rpc_models = get_rpc_flat_models_from_routes(rpc_router)
5552
rpc_model_name_map = get_model_name_map(flat_rpc_models)
56-
rpc_definitions = get_model_definitions(
53+
rpc_definitions = get_rpc_model_definitions(
5754
flat_models=flat_rpc_models, model_name_map=rpc_model_name_map
5855
)
59-
60-
for route in fastapi_routes:
61-
if isinstance(route, routing.APIRoute):
62-
result = get_openapi_path(
63-
route=route, model_name_map=fastapi_model_name_map
64-
)
65-
if result:
66-
path, security_schemes, path_definitions = result
67-
if path:
68-
paths.setdefault(route.path_format, {}).update(path)
69-
if security_schemes:
70-
components.setdefault("securitySchemes", {}).update(
71-
security_schemes
72-
)
73-
if path_definitions:
74-
fast_api_definitions.update(path_definitions)
56+
security_definitions, operation_security = get_openapi_security_definitions(
57+
security_component=security
58+
)
7559

7660
for method_name in rpc_router.rpc_methods.keys():
7761
if not rpc_router.rpc_methods[method_name].include_in_schema:
7862
continue
7963

80-
result = get_rpc_openapi_path( # type: ignore
64+
path = get_rpc_openapi_path( # type: ignore
8165
method_name=method_name,
8266
route=rpc_router.rpc_methods[method_name],
8367
model_name_map=rpc_model_name_map,
68+
security_scheme=operation_security,
8469
)
85-
if result:
86-
path, path_definitions = result # type: ignore
87-
if path:
88-
paths.setdefault(method_name, {}).update(path)
70+
if path:
71+
paths.setdefault(f"/{method_name}", {}).update(path)
8972

90-
if path_definitions:
91-
rpc_definitions.update(path_definitions)
92-
93-
if fast_api_definitions:
94-
components["schemas"] = {
95-
k: fast_api_definitions[k] for k in sorted(fast_api_definitions)
96-
}
9773
if rpc_definitions:
98-
components.setdefault("schemas", {}).update(
74+
openapi_dict.setdefault("components", {}).setdefault("schemas", {}).update(
9975
{k: rpc_definitions[k] for k in sorted(rpc_definitions)}
10076
)
101-
if components:
102-
output["components"] = components
103-
output["paths"] = paths
104-
if tags:
105-
output["tags"] = tags
10677

107-
return jsonable_encoder(OpenAPI(**output), by_alias=True, exclude_none=True)
78+
openapi_dict.setdefault("components", {}).setdefault("securitySchemes", {}).update(
79+
security_definitions
80+
)
81+
openapi_dict.setdefault("paths", {}).update(paths)
82+
83+
return jsonable_encoder(OpenAPI(**openapi_dict), by_alias=True, exclude_none=True)

0 commit comments

Comments
 (0)