Skip to content

Commit 625d2cb

Browse files
authored
🎨 web-server api: ordering parameters and simplified openapi specs for complex query parameters (#6737)
1 parent 5f70a0d commit 625d2cb

File tree

43 files changed

+906
-872
lines changed

Some content is hidden

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

43 files changed

+906
-872
lines changed

api/specs/web-server/_common.py

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,68 @@
88
from typing import Any, ClassVar, NamedTuple
99

1010
import yaml
11-
from fastapi import FastAPI
11+
from fastapi import FastAPI, Query
1212
from models_library.basic_types import LogLevel
13-
from pydantic import BaseModel, Field
13+
from models_library.utils.json_serialization import json_dumps
14+
from pydantic import BaseModel, Field, create_model
1415
from pydantic.fields import FieldInfo
1516
from servicelib.fastapi.openapi import override_fastapi_openapi_method
1617

1718
CURRENT_DIR = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent
1819

1920

21+
def _create_json_type(**schema_extras):
22+
class _Json(str):
23+
__slots__ = ()
24+
25+
@classmethod
26+
def __modify_schema__(cls, field_schema: dict[str, Any]) -> None:
27+
# openapi.json schema is corrected here
28+
field_schema.update(
29+
type="string",
30+
# format="json-string" NOTE: we need to get rid of openapi-core in web-server before using this!
31+
)
32+
if schema_extras:
33+
field_schema.update(schema_extras)
34+
35+
return _Json
36+
37+
38+
def as_query(model_class: type[BaseModel]) -> type[BaseModel]:
39+
fields = {}
40+
for field_name, model_field in model_class.__fields__.items():
41+
42+
field_type = model_field.type_
43+
default_value = model_field.default
44+
45+
kwargs = {
46+
"alias": model_field.field_info.alias,
47+
"title": model_field.field_info.title,
48+
"description": model_field.field_info.description,
49+
"gt": model_field.field_info.gt,
50+
"ge": model_field.field_info.ge,
51+
"lt": model_field.field_info.lt,
52+
"le": model_field.field_info.le,
53+
"min_length": model_field.field_info.min_length,
54+
"max_length": model_field.field_info.max_length,
55+
"regex": model_field.field_info.regex,
56+
**model_field.field_info.extra,
57+
}
58+
59+
if issubclass(field_type, BaseModel):
60+
# Complex fields
61+
field_type = _create_json_type(
62+
description=kwargs["description"],
63+
example=kwargs.get("example_json"),
64+
)
65+
default_value = json_dumps(default_value) if default_value else None
66+
67+
fields[field_name] = (field_type, Query(default=default_value, **kwargs))
68+
69+
new_model_name = f"{model_class.__name__}Query"
70+
return create_model(new_model_name, **fields)
71+
72+
2073
class Log(BaseModel):
2174
level: LogLevel | None = Field("INFO", description="log level")
2275
message: str = Field(
@@ -120,6 +173,9 @@ def assert_handler_signature_against_model(
120173
for field in model_cls.__fields__.values()
121174
]
122175

123-
assert {p.name for p in implemented_params}.issubset( # nosec
124-
{p.name for p in specs_params}
125-
), f"Entrypoint {handler} does not implement OAS"
176+
implemented_names = {p.name for p in implemented_params}
177+
specified_names = {p.name for p in specs_params}
178+
179+
if not implemented_names.issubset(specified_names):
180+
msg = f"Entrypoint {handler} does not implement OAS: {implemented_names} not in {specified_names}"
181+
raise AssertionError(msg)

api/specs/web-server/_folders.py

Lines changed: 20 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,20 @@
99

1010
from typing import Annotated
1111

12-
from fastapi import APIRouter, Depends, Query, status
12+
from _common import as_query
13+
from fastapi import APIRouter, Depends, status
1314
from models_library.api_schemas_webserver.folders_v2 import (
1415
CreateFolderBodyParams,
1516
FolderGet,
1617
PutFolderBodyParams,
1718
)
18-
from models_library.folders import FolderID
1919
from models_library.generics import Envelope
20-
from models_library.rest_pagination import PageQueryParameters
21-
from models_library.workspaces import WorkspaceID
22-
from pydantic import Json
2320
from simcore_service_webserver._meta import API_VTAG
24-
from simcore_service_webserver.folders._models import FolderFilters, FoldersPathParams
21+
from simcore_service_webserver.folders._models import (
22+
FolderSearchQueryParams,
23+
FoldersListQueryParams,
24+
FoldersPathParams,
25+
)
2526

2627
router = APIRouter(
2728
prefix=f"/{API_VTAG}",
@@ -36,7 +37,9 @@
3637
response_model=Envelope[FolderGet],
3738
status_code=status.HTTP_201_CREATED,
3839
)
39-
async def create_folder(_body: CreateFolderBodyParams):
40+
async def create_folder(
41+
_body: CreateFolderBodyParams,
42+
):
4043
...
4144

4245

@@ -45,20 +48,7 @@ async def create_folder(_body: CreateFolderBodyParams):
4548
response_model=Envelope[list[FolderGet]],
4649
)
4750
async def list_folders(
48-
params: Annotated[PageQueryParameters, Depends()],
49-
folder_id: FolderID | None = None,
50-
workspace_id: WorkspaceID | None = None,
51-
order_by: Annotated[
52-
Json,
53-
Query(
54-
description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.",
55-
example='{"field": "name", "direction": "desc"}',
56-
),
57-
] = '{"field": "modified_at", "direction": "desc"}',
58-
filters: Annotated[
59-
Json | None,
60-
Query(description=FolderFilters.schema_json(indent=1)),
61-
] = None,
51+
_query: Annotated[as_query(FoldersListQueryParams), Depends()],
6252
):
6353
...
6454

@@ -68,19 +58,7 @@ async def list_folders(
6858
response_model=Envelope[list[FolderGet]],
6959
)
7060
async def list_folders_full_search(
71-
params: Annotated[PageQueryParameters, Depends()],
72-
text: str | None = None,
73-
order_by: Annotated[
74-
Json,
75-
Query(
76-
description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.",
77-
example='{"field": "name", "direction": "desc"}',
78-
),
79-
] = '{"field": "modified_at", "direction": "desc"}',
80-
filters: Annotated[
81-
Json | None,
82-
Query(description=FolderFilters.schema_json(indent=1)),
83-
] = None,
61+
_query: Annotated[as_query(FolderSearchQueryParams), Depends()],
8462
):
8563
...
8664

@@ -89,7 +67,9 @@ async def list_folders_full_search(
8967
"/folders/{folder_id}",
9068
response_model=Envelope[FolderGet],
9169
)
92-
async def get_folder(_path: Annotated[FoldersPathParams, Depends()]):
70+
async def get_folder(
71+
_path: Annotated[FoldersPathParams, Depends()],
72+
):
9373
...
9474

9575

@@ -98,7 +78,8 @@ async def get_folder(_path: Annotated[FoldersPathParams, Depends()]):
9878
response_model=Envelope[FolderGet],
9979
)
10080
async def replace_folder(
101-
_path: Annotated[FoldersPathParams, Depends()], _body: PutFolderBodyParams
81+
_path: Annotated[FoldersPathParams, Depends()],
82+
_body: PutFolderBodyParams,
10283
):
10384
...
10485

@@ -107,5 +88,7 @@ async def replace_folder(
10788
"/folders/{folder_id}",
10889
status_code=status.HTTP_204_NO_CONTENT,
10990
)
110-
async def delete_folder(_path: Annotated[FoldersPathParams, Depends()]):
91+
async def delete_folder(
92+
_path: Annotated[FoldersPathParams, Depends()],
93+
):
11194
...

api/specs/web-server/_groups.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ async def list_groups():
4848
response_model=Envelope[GroupGet],
4949
status_code=status.HTTP_201_CREATED,
5050
)
51-
async def create_group(_b: GroupCreate):
51+
async def create_group(_body: GroupCreate):
5252
"""
5353
Creates an organization group
5454
"""
@@ -58,7 +58,7 @@ async def create_group(_b: GroupCreate):
5858
"/groups/{gid}",
5959
response_model=Envelope[GroupGet],
6060
)
61-
async def get_group(_p: Annotated[_GroupPathParams, Depends()]):
61+
async def get_group(_path: Annotated[_GroupPathParams, Depends()]):
6262
"""
6363
Get an organization group
6464
"""
@@ -69,8 +69,8 @@ async def get_group(_p: Annotated[_GroupPathParams, Depends()]):
6969
response_model=Envelope[GroupGet],
7070
)
7171
async def update_group(
72-
_p: Annotated[_GroupPathParams, Depends()],
73-
_b: GroupUpdate,
72+
_path: Annotated[_GroupPathParams, Depends()],
73+
_body: GroupUpdate,
7474
):
7575
"""
7676
Updates organization groups
@@ -81,7 +81,7 @@ async def update_group(
8181
"/groups/{gid}",
8282
status_code=status.HTTP_204_NO_CONTENT,
8383
)
84-
async def delete_group(_p: Annotated[_GroupPathParams, Depends()]):
84+
async def delete_group(_path: Annotated[_GroupPathParams, Depends()]):
8585
"""
8686
Deletes organization groups
8787
"""
@@ -91,7 +91,7 @@ async def delete_group(_p: Annotated[_GroupPathParams, Depends()]):
9191
"/groups/{gid}/users",
9292
response_model=Envelope[list[GroupUserGet]],
9393
)
94-
async def get_all_group_users(_p: Annotated[_GroupPathParams, Depends()]):
94+
async def get_all_group_users(_path: Annotated[_GroupPathParams, Depends()]):
9595
"""
9696
Gets users in organization groups
9797
"""
@@ -102,8 +102,8 @@ async def get_all_group_users(_p: Annotated[_GroupPathParams, Depends()]):
102102
status_code=status.HTTP_204_NO_CONTENT,
103103
)
104104
async def add_group_user(
105-
_p: Annotated[_GroupPathParams, Depends()],
106-
_b: GroupUserAdd,
105+
_path: Annotated[_GroupPathParams, Depends()],
106+
_body: GroupUserAdd,
107107
):
108108
"""
109109
Adds a user to an organization group
@@ -115,7 +115,7 @@ async def add_group_user(
115115
response_model=Envelope[GroupUserGet],
116116
)
117117
async def get_group_user(
118-
_p: Annotated[_GroupUserPathParams, Depends()],
118+
_path: Annotated[_GroupUserPathParams, Depends()],
119119
):
120120
"""
121121
Gets specific user in an organization group
@@ -127,8 +127,8 @@ async def get_group_user(
127127
response_model=Envelope[GroupUserGet],
128128
)
129129
async def update_group_user(
130-
_p: Annotated[_GroupUserPathParams, Depends()],
131-
_b: GroupUserUpdate,
130+
_path: Annotated[_GroupUserPathParams, Depends()],
131+
_body: GroupUserUpdate,
132132
):
133133
"""
134134
Updates user (access-rights) to an organization group
@@ -140,7 +140,7 @@ async def update_group_user(
140140
status_code=status.HTTP_204_NO_CONTENT,
141141
)
142142
async def delete_group_user(
143-
_p: Annotated[_GroupUserPathParams, Depends()],
143+
_path: Annotated[_GroupUserPathParams, Depends()],
144144
):
145145
"""
146146
Removes a user from an organization group
@@ -157,8 +157,8 @@ async def delete_group_user(
157157
response_model=Envelope[dict[str, Any]],
158158
)
159159
async def get_group_classifiers(
160-
_p: Annotated[_GroupPathParams, Depends()],
161-
_q: Annotated[_ClassifiersQuery, Depends()],
160+
_path: Annotated[_GroupPathParams, Depends()],
161+
_query: Annotated[_ClassifiersQuery, Depends()],
162162
):
163163
...
164164

0 commit comments

Comments
 (0)