Skip to content

Commit 28dd617

Browse files
authored
✨ feat(phone): Add Pydantic phone number validation to reduce SMS waste in input schemas (ITISFoundation#8115)
1 parent 49fe1e2 commit 28dd617

File tree

36 files changed

+404
-324
lines changed

36 files changed

+404
-324
lines changed

api/specs/web-server/_users.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010
from models_library.api_schemas_webserver.users import (
1111
MyFunctionPermissionsGet,
1212
MyPermissionGet,
13-
MyPhoneConfirm,
14-
MyPhoneRegister,
1513
MyProfileRestGet,
1614
MyProfileRestPatch,
1715
MyTokenCreate,
@@ -32,6 +30,10 @@
3230
UserNotificationCreate,
3331
UserNotificationPatch,
3432
)
33+
from simcore_service_webserver.users._controller.rest._rest_schemas import (
34+
MyPhoneConfirm,
35+
MyPhoneRegister,
36+
)
3537

3638
router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"])
3739

api/specs/web-server/_users_admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from models_library.generics import Envelope
2020
from models_library.rest_pagination import Page
2121
from simcore_service_webserver._meta import API_VTAG
22-
from simcore_service_webserver.users.schemas import PreRegisteredUserGet
22+
from simcore_service_webserver.users.schemas import UserAccountRestPreRegister
2323

2424
router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"])
2525

@@ -69,4 +69,4 @@ async def search_user_accounts(
6969
response_model=Envelope[UserAccountGet],
7070
tags=_extra_tags,
7171
)
72-
async def pre_register_user_account(_body: PreRegisteredUserGet): ...
72+
async def pre_register_user_account(_body: UserAccountRestPreRegister): ...

packages/models-library/src/models_library/api_schemas_webserver/auth.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from models_library.rest_base import RequestParameters
66
from pydantic import AliasGenerator, ConfigDict, Field, HttpUrl, SecretStr
77
from pydantic.alias_generators import to_camel
8+
from pydantic.config import JsonDict
89

910
from ..emails import LowerCaseEmailStr
1011
from ._base import InputSchema, OutputSchema
@@ -14,33 +15,39 @@ class AccountRequestInfo(InputSchema):
1415
form: dict[str, Any]
1516
captcha: str
1617

18+
@staticmethod
19+
def _update_json_schema_extra(schema: JsonDict) -> None:
20+
schema.update(
21+
{
22+
"example": {
23+
"form": {
24+
"firstName": "James",
25+
"lastName": "Maxwel",
26+
"email": "[email protected]",
27+
"phone": "+41 44 245 96 96",
28+
"company": "EM Com",
29+
"address": "Infinite Loop",
30+
"city": "Washington",
31+
"postalCode": "98001",
32+
"country": "Switzerland",
33+
"application": "Antenna_Design",
34+
"description": "Description of something",
35+
"hear": "Search_Engine",
36+
"privacyPolicy": True,
37+
"eula": True,
38+
},
39+
"captcha": "A12B34",
40+
}
41+
}
42+
)
43+
1744
model_config = ConfigDict(
1845
str_strip_whitespace=True,
1946
str_max_length=200,
2047
# NOTE: this is just informative. The format of the form is defined
2148
# currently in the front-end and it might change
2249
# SEE image in https://github.com/ITISFoundation/osparc-simcore/pull/5378
23-
json_schema_extra={
24-
"example": {
25-
"form": {
26-
"firstName": "James",
27-
"lastName": "Maxwel",
28-
"email": "[email protected]",
29-
"phone": "+1 123456789",
30-
"company": "EM Com",
31-
"address": "Infinite Loop",
32-
"city": "Washington",
33-
"postalCode": "98001",
34-
"country": "USA",
35-
"application": "Antenna_Design",
36-
"description": "Description of something",
37-
"hear": "Search_Engine",
38-
"privacyPolicy": True,
39-
"eula": True,
40-
},
41-
"captcha": "A12B34",
42-
}
43-
},
50+
json_schema_extra=_update_json_schema_extra,
4451
)
4552

4653

packages/models-library/src/models_library/api_schemas_webserver/users.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -207,27 +207,6 @@ def _validate_user_name(cls, value: str):
207207
return value
208208

209209

210-
#
211-
# PHONE REGISTRATION
212-
#
213-
214-
215-
class MyPhoneRegister(InputSchema):
216-
phone: Annotated[
217-
str,
218-
StringConstraints(strip_whitespace=True, min_length=1),
219-
Field(description="Phone number to register"),
220-
]
221-
222-
223-
class MyPhoneConfirm(InputSchema):
224-
code: Annotated[
225-
str,
226-
StringConstraints(strip_whitespace=True, pattern=r"^[A-Za-z0-9]+$"),
227-
Field(description="Alphanumeric confirmation code"),
228-
]
229-
230-
231210
#
232211
# USER
233212
#

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def _update_json_schema_extra(schema: JsonDict) -> None:
126126

127127

128128
class EnvelopedError(Envelope[None]):
129+
# SEE https://github.com/ITISFoundation/osparc-simcore/issues/443
129130
error: ErrorGet
130131

131132
model_config = ConfigDict(

packages/models-library/tests/test_users.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from models_library.api_schemas_webserver.users import MyProfileRestGet
1+
from models_library.api_schemas_webserver.users import (
2+
MyProfileRestGet,
3+
)
24
from models_library.api_schemas_webserver.users_preferences import Preference
35
from models_library.groups import AccessRightsDict, Group, GroupsByTypeTuple
46
from models_library.users import MyProfile

packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,9 @@
2020
from uuid import uuid4
2121

2222
import arrow
23-
import faker
2423
from faker import Faker
2524

26-
DEFAULT_FAKER: Final = faker.Faker()
25+
DEFAULT_FAKER: Final = Faker()
2726

2827

2928
def random_icon_url(fake: Faker):
@@ -34,6 +33,15 @@ def random_thumbnail_url(fake: Faker):
3433
return fake.image_url(width=32, height=32)
3534

3635

36+
def random_phone_number(fake: Faker = DEFAULT_FAKER) -> str:
37+
# NOTE: faker.phone_number() does not validate with `phonenumbers` library.
38+
phone = fake.random_element(
39+
["+41763456789", "+19104630364", "+13013044567", "+34 950 453 837"]
40+
)
41+
tail = f"{fake.pyint(100, 999)}"
42+
return phone[: -len(tail)] + tail # ensure phone keeps its length
43+
44+
3745
def _compute_hash(password: str) -> str:
3846
try:
3947
# 'passlib' will be used only if already installed.
@@ -105,7 +113,7 @@ def random_pre_registration_details(
105113
"pre_first_name": fake.first_name(),
106114
"pre_last_name": fake.last_name(),
107115
"pre_email": fake.email(),
108-
"pre_phone": fake.phone_number(),
116+
"pre_phone": random_phone_number(fake),
109117
"institution": fake.company(),
110118
"address": fake.address().replace("\n", ", "),
111119
"city": fake.city(),

packages/service-library/src/servicelib/aiohttp/requests_validation.py

Lines changed: 37 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
from typing import TypeVar
1414

1515
from aiohttp import web
16-
from common_library.json_serialization import json_dumps
16+
from common_library.user_messages import user_message
17+
from models_library.rest_error import EnvelopedError
1718
from pydantic import BaseModel, TypeAdapter, ValidationError
1819

1920
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
@@ -25,14 +26,13 @@
2526

2627
@contextmanager
2728
def handle_validation_as_http_error(
28-
*, error_msg_template: str, resource_name: str, use_error_v1: bool
29+
*, error_msg_template: str, resource_name: str
2930
) -> Iterator[None]:
3031
"""Context manager to handle ValidationError and reraise them as HTTPUnprocessableEntity error
3132
3233
Arguments:
3334
error_msg_template -- _description_
3435
resource_name --
35-
use_error_v1 -- If True, it uses new error response
3636
3737
Raises:
3838
web.HTTPUnprocessableEntity: (422) raised from a ValidationError
@@ -43,49 +43,37 @@ def handle_validation_as_http_error(
4343
yield
4444

4545
except ValidationError as err:
46-
details = [
46+
# SEE https://github.com/ITISFoundation/osparc-simcore/issues/443
47+
_details = [
4748
{
48-
"loc": ".".join(map(str, e["loc"])),
49+
"loc": ".".join(map(str, e["loc"])), # e.g. "body.name"
4950
"msg": e["msg"],
5051
"type": e["type"],
5152
}
5253
for e in err.errors()
5354
]
54-
user_error_message = error_msg_template.format(
55-
failed=", ".join(d["loc"] for d in details)
56-
)
57-
58-
if use_error_v1:
59-
# NOTE: keeps backwards compatibility until ligher error response is implemented in the entire API
60-
# Implements servicelib.aiohttp.rest_responses.ErrorItemType
61-
errors = [
62-
{
63-
"code": e["type"],
64-
"message": e["msg"],
65-
"resource": resource_name,
66-
"field": e["loc"],
67-
}
68-
for e in details
69-
]
70-
error_json_str = json_dumps(
71-
{
72-
"error": {
73-
"status": status.HTTP_422_UNPROCESSABLE_ENTITY,
74-
"errors": errors,
75-
}
76-
}
77-
)
78-
else:
79-
# NEW proposed error for https://github.com/ITISFoundation/osparc-simcore/issues/443
80-
error_json_str = json_dumps(
81-
{
82-
"error": {
83-
"msg": user_error_message,
84-
"resource": resource_name, # optional
85-
"details": details, # optional
86-
}
55+
56+
errors_details = [
57+
{
58+
"code": e["type"],
59+
"message": e["msg"],
60+
"resource": resource_name,
61+
"field": e["loc"],
62+
}
63+
for e in _details
64+
]
65+
66+
error_json_str = EnvelopedError.model_validate(
67+
{
68+
"error": {
69+
"message": error_msg_template.format(
70+
failed=", ".join(e["field"] for e in errors_details)
71+
),
72+
"status": status.HTTP_422_UNPROCESSABLE_ENTITY,
73+
"errors": errors_details,
8774
}
88-
)
75+
}
76+
).model_dump_json(exclude_unset=True, exclude_none=True)
8977

9078
raise web.HTTPUnprocessableEntity( # 422
9179
text=error_json_str,
@@ -104,15 +92,10 @@ def handle_validation_as_http_error(
10492
def parse_request_path_parameters_as(
10593
parameters_schema_cls: type[ModelClass],
10694
request: web.Request,
107-
*,
108-
use_enveloped_error_v1: bool = True,
10995
) -> ModelClass:
11096
"""Parses path parameters from 'request' and validates against 'parameters_schema'
11197
11298
113-
Keyword Arguments:
114-
use_enveloped_error_v1 -- new enveloped error model (default: {True})
115-
11699
Raises:
117100
web.HTTPUnprocessableEntity: (422) if validation of parameters fail
118101
@@ -121,9 +104,10 @@ def parse_request_path_parameters_as(
121104
"""
122105

123106
with handle_validation_as_http_error(
124-
error_msg_template="Invalid parameter/s '{failed}' in request path",
107+
error_msg_template=user_message(
108+
"Invalid parameter/s '{failed}' in request path"
109+
),
125110
resource_name=request.rel_url.path,
126-
use_error_v1=use_enveloped_error_v1,
127111
):
128112
data = dict(request.match_info)
129113
return parameters_schema_cls.model_validate(data)
@@ -132,15 +116,10 @@ def parse_request_path_parameters_as(
132116
def parse_request_query_parameters_as(
133117
parameters_schema_cls: type[ModelClass],
134118
request: web.Request,
135-
*,
136-
use_enveloped_error_v1: bool = True,
137119
) -> ModelClass:
138120
"""Parses query parameters from 'request' and validates against 'parameters_schema'
139121
140122
141-
Keyword Arguments:
142-
use_enveloped_error_v1 -- new enveloped error model (default: {True})
143-
144123
Raises:
145124
web.HTTPUnprocessableEntity: (422) if validation of parameters fail
146125
@@ -149,9 +128,10 @@ def parse_request_query_parameters_as(
149128
"""
150129

151130
with handle_validation_as_http_error(
152-
error_msg_template="Invalid parameter/s '{failed}' in request query",
131+
error_msg_template=user_message(
132+
"Invalid parameter/s '{failed}' in request query"
133+
),
153134
resource_name=request.rel_url.path,
154-
use_error_v1=use_enveloped_error_v1,
155135
):
156136
# NOTE: Currently, this does not take into consideration cases where there are multiple
157137
# query parameters with the same key. However, we are not using such cases anywhere at the moment.
@@ -166,13 +146,12 @@ def parse_request_query_parameters_as(
166146
def parse_request_headers_as(
167147
parameters_schema_cls: type[ModelClass],
168148
request: web.Request,
169-
*,
170-
use_enveloped_error_v1: bool = True,
171149
) -> ModelClass:
172150
with handle_validation_as_http_error(
173-
error_msg_template="Invalid parameter/s '{failed}' in request headers",
151+
error_msg_template=user_message(
152+
"Invalid parameter/s '{failed}' in request headers"
153+
),
174154
resource_name=request.rel_url.path,
175-
use_error_v1=use_enveloped_error_v1,
176155
):
177156
data = dict(request.headers)
178157
return parameters_schema_cls.model_validate(data)
@@ -181,8 +160,6 @@ def parse_request_headers_as(
181160
async def parse_request_body_as(
182161
model_schema_cls: type[ModelOrListOrDictType],
183162
request: web.Request,
184-
*,
185-
use_enveloped_error_v1: bool = True,
186163
) -> ModelOrListOrDictType:
187164
"""Parses and validates request body against schema
188165
@@ -197,9 +174,8 @@ async def parse_request_body_as(
197174
Validated model of request body
198175
"""
199176
with handle_validation_as_http_error(
200-
error_msg_template="Invalid field/s '{failed}' in request body",
177+
error_msg_template=user_message("Invalid field/s '{failed}' in request body"),
201178
resource_name=request.rel_url.path,
202-
use_error_v1=use_enveloped_error_v1,
203179
):
204180
if not request.can_read_body:
205181
# requests w/o body e.g. when model-schema is fully optional

0 commit comments

Comments
 (0)