Skip to content

Commit 62a56d9

Browse files
committed
rest ordering
1 parent 5fd4b06 commit 62a56d9

File tree

3 files changed

+73
-32
lines changed

3 files changed

+73
-32
lines changed

packages/models-library/src/models_library/rest_ordering.py

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from enum import Enum
22
from typing import Any, ClassVar
33

4-
from models_library.utils.json_serialization import json_dumps
4+
from models_library.utils.json_serialization import json_dumps, json_loads
55
from pydantic import BaseModel, Field, Json, validator
66

77
from .basic_types import IDStr
@@ -17,7 +17,10 @@ class OrderBy(BaseModel):
1717
field: IDStr = Field(..., description="field name identifier")
1818
direction: OrderDirection = Field(
1919
default=OrderDirection.ASC,
20-
description="As [A,B,C,...] if ASC or [Z,Y,X, ...] if DESC",
20+
description=(
21+
f"As [A,B,C,...] if `{OrderDirection.ASC.value}`"
22+
f" or [Z,Y,X, ...] if `{OrderDirection.DESC.value}`"
23+
),
2124
)
2225

2326
class Config:
@@ -45,46 +48,61 @@ def create_order_by_query_model_classes(
4548

4649
assert default_order_by.field in sortable_fields # nosec
4750

48-
field_options_msg = "|".join(sorted(sortable_fields))
49-
direction_options_msg = "|".join(sorted(OrderDirection))
51+
msg_field_options = "|".join(sorted(sortable_fields))
52+
msg_direction_options = "|".join(sorted(OrderDirection))
53+
order_by_example: dict[str, Any] = OrderBy.Config.schema_extra["example"]
5054

51-
class _OrderBy(OrderBy):
52-
field: IDStr = Field(
53-
..., description=OrderBy.__fields__["field"].field_info.description
54-
)
55+
class _JsonOrderBy(OrderBy):
5556
direction: OrderDirection = Field(
5657
default=default_order_by.direction
5758
if override_direction_default
5859
else OrderBy.__fields__["direction"].default,
5960
description=OrderBy.__fields__["direction"].field_info.description,
6061
)
6162

63+
@classmethod
64+
def __modify_schema__(cls, field_schema: dict[str, Any]) -> None:
65+
# openapi.json schema is corrected here
66+
field_schema.update(
67+
type="string",
68+
format="json-string",
69+
default=json_dumps(default_order_by),
70+
example=json_dumps(order_by_example),
71+
title="Order By",
72+
)
73+
6274
@validator("field", allow_reuse=True)
6375
@classmethod
6476
def _check_if_sortable_field(cls, v):
6577
if v not in sortable_fields:
6678
msg = (
6779
f"We do not support ordering by provided field '{v}'. "
68-
f"Fields supported are {field_options_msg}."
80+
f"Fields supported are {msg_field_options}."
6981
)
7082
raise ValueError(msg)
7183
return v
7284

7385
description = (
74-
f"Order by field ({field_options_msg}) and direction ({direction_options_msg}). "
86+
f"Order by field ({msg_field_options}) and direction ({msg_direction_options}). "
7587
f"The default sorting order is '{default_order_by.direction.value}' on '{default_order_by.field}'."
7688
)
77-
example: dict[str, Any] = OrderBy.Config.schema_extra["example"]
7889

79-
class _OrderByQueryParams(BaseOrderByQueryParams):
90+
class _RequestValidatorModel(BaseOrderByQueryParams):
8091
# Used in rest handler for verification
81-
order_by: _OrderBy = Field(
92+
order_by: _JsonOrderBy = Field(
8293
default=default_order_by,
8394
description=description,
84-
example=example,
8595
)
8696

87-
class _OrderByQueryJsonParams(BaseModel):
97+
@validator("order_by", allow_reuse=True, pre=True)
98+
@classmethod
99+
def _pre_parse_if_json(cls, v):
100+
if isinstance(v, str):
101+
# can raise a JsonEncoderError(TypeError)
102+
return json_loads(v)
103+
return v
104+
105+
class _OpenapiModel(BaseModel):
88106
# Used to produce nice openapi.json specs
89107
order_by: Json = Field(
90108
default=json_dumps(default_order_by),
@@ -95,7 +113,10 @@ class _OrderByQueryJsonParams(BaseModel):
95113
@classmethod
96114
def _validate_json_content(cls, v):
97115
if v:
98-
_OrderByQueryParams(order_by=v)
116+
_RequestValidatorModel(order_by=v)
99117
return v
100118

101-
return _OrderByQueryParams, _OrderByQueryJsonParams
119+
class Config:
120+
schema_extra: ClassVar[dict[str, Any]] = {"title": "Order By Parameters"}
121+
122+
return _RequestValidatorModel, _OpenapiModel

packages/models-library/tests/test_rest_ordering.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def test_order_by_query_model_class__openapi():
159159
print(OrderByQueryParamsModelOAS.schema_json(indent=1))
160160

161161
assert OrderByQueryParamsModelOAS.schema() == {
162-
"title": "_OrderByQueryJsonParams",
162+
"title": "Order By Parameters",
163163
"type": "object",
164164
"properties": {
165165
"order_by": {

packages/service-library/tests/aiohttp/test_requests_validation.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,30 @@
33
# pylint: disable=unused-variable
44

55
import json
6-
from typing import Callable
6+
from collections.abc import Callable
77
from uuid import UUID
88

99
import pytest
1010
from aiohttp import web
11-
from aiohttp.test_utils import TestClient
11+
from aiohttp.test_utils import TestClient, make_mocked_request
1212
from faker import Faker
13+
from models_library.rest_ordering import (
14+
OrderBy,
15+
OrderDirection,
16+
create_order_by_query_model_classes,
17+
)
1318
from models_library.utils.json_serialization import json_dumps
14-
from pydantic import BaseModel, Extra, Field
19+
from pydantic import BaseModel, Field
1520
from servicelib.aiohttp import status
1621
from servicelib.aiohttp.requests_validation import (
22+
RequestParams,
23+
StrictRequestParams,
1724
parse_request_body_as,
1825
parse_request_headers_as,
1926
parse_request_path_parameters_as,
2027
parse_request_query_parameters_as,
2128
)
29+
from yarl import URL
2230

2331
RQT_USERID_KEY = f"{__name__}.user_id"
2432
APP_SECRET_KEY = f"{__name__}.secret"
@@ -30,7 +38,7 @@ def jsonable_encoder(data):
3038
return json.loads(json_dumps(data))
3139

3240

33-
class MyRequestContext(BaseModel):
41+
class MyRequestContext(RequestParams):
3442
user_id: int = Field(alias=RQT_USERID_KEY)
3543
secret: str = Field(alias=APP_SECRET_KEY)
3644

@@ -39,31 +47,24 @@ def create_fake(cls, faker: Faker):
3947
return cls(user_id=faker.pyint(), secret=faker.password())
4048

4149

42-
class MyRequestPathParams(BaseModel):
50+
class MyRequestPathParams(StrictRequestParams):
4351
project_uuid: UUID
4452

45-
class Config:
46-
extra = Extra.forbid
47-
4853
@classmethod
4954
def create_fake(cls, faker: Faker):
5055
return cls(project_uuid=faker.uuid4())
5156

5257

53-
class MyRequestQueryParams(BaseModel):
58+
class MyRequestQueryParams(RequestParams):
5459
is_ok: bool = True
5560
label: str
5661

57-
def as_params(self, **kwargs) -> dict[str, str]:
58-
data = self.dict(**kwargs)
59-
return {k: f"{v}" for k, v in data.items()}
60-
6162
@classmethod
6263
def create_fake(cls, faker: Faker):
6364
return cls(is_ok=faker.pybool(), label=faker.word())
6465

6566

66-
class MyRequestHeadersParams(BaseModel):
67+
class MyRequestHeadersParams(RequestParams):
6768
user_agent: str = Field(alias="X-Simcore-User-Agent")
6869
optional_header: str | None = Field(default=None, alias="X-Simcore-Optional-Header")
6970

@@ -359,3 +360,22 @@ async def test_parse_request_with_invalid_headers_params(
359360
],
360361
}
361362
}
363+
364+
365+
def test_parse_request_query_parameters_as_with_order_by_query_models():
366+
367+
OrderByModel, _ = create_order_by_query_model_classes(
368+
sortable_fields={"modified", "name"}, default_order_by=OrderBy(field="name")
369+
)
370+
371+
expected = OrderBy(field="name", direction=OrderDirection.ASC)
372+
373+
url = URL("/test").with_query(order_by=expected.json())
374+
375+
request = make_mocked_request("GET", path=f"{url}")
376+
377+
query_params = parse_request_query_parameters_as(OrderByModel, request)
378+
assert query_params.order_by == expected
379+
380+
assert OrderByModel.schema()["properties"]["order_by"]["type"] == "string"
381+
assert OrderByModel.schema()["properties"]["order_by"]["format"] == "json-string"

0 commit comments

Comments
 (0)