Skip to content

Commit fdb8a04

Browse files
authored
✨ Is560/aiohttp request validations (ITISFoundation#3048)
1 parent 54ef915 commit fdb8a04

39 files changed

+1068
-393
lines changed

api/specs/webserver/openapi-projects.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ paths:
199199
tags:
200200
- project
201201
summary: returns the state of a project
202-
operationId: state_project
202+
operationId: get_project_state
203203
responses:
204204
"200":
205205
description: returns the project current state
@@ -450,9 +450,9 @@ paths:
450450
default:
451451
$ref: "#/components/responses/DefaultErrorResponse"
452452

453-
/projects/{project_uuid}/nodes/{node_id}/restart:
453+
/projects/{project_id}/nodes/{node_id}/restart:
454454
parameters:
455-
- name: project_uuid
455+
- name: project_id
456456
in: path
457457
required: true
458458
schema:

api/specs/webserver/openapi.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,8 @@ paths:
202202
/projects/{project_id}/nodes/{node_id}:retrieve:
203203
$ref: "./openapi-projects.yaml#/paths/~1projects~1{project_id}~1nodes~1{node_id}~1retrieve"
204204

205-
/projects/{project_uuid}/nodes/{node_id}:restart:
206-
$ref: "./openapi-projects.yaml#/paths/~1projects~1{project_uuid}~1nodes~1{node_id}~1restart"
205+
/projects/{project_id}/nodes/{node_id}:restart:
206+
$ref: "./openapi-projects.yaml#/paths/~1projects~1{project_id}~1nodes~1{node_id}~1restart"
207207

208208
/projects/{project_id}/nodes/{node_id}/resources:
209209
$ref: "./openapi-projects.yaml#/paths/~1projects~1{project_id}~1nodes~1{node_id}~1resources"

ci/github/integration-testing/webserver.bash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ test() {
2525
--cov=simcore_service_webserver --durations=10 --cov-append \
2626
--color=yes --cov-report=term-missing --cov-report=xml --cov-config=.coveragerc \
2727
--asyncio-mode=auto \
28-
-v -m "not travis" "services/web/server/tests/integration/$1" --log-level=DEBUG
28+
-v -m "not travis" "services/web/server/tests/integration/$1"
2929
}
3030

3131
clean_up() {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from pydantic import conint, constr
44

5-
from .basic_regex import VERSION_RE
5+
from .basic_regex import UUID_RE, VERSION_RE
66

77
# port number range
88
PortInt = conint(gt=0, lt=65535)
@@ -20,6 +20,9 @@
2020
# env var
2121
EnvVarKey = constr(regex=r"[a-zA-Z][a-azA-Z0-9_]*")
2222

23+
# e.g. '5c833a78-1af3-43a7-9ed7-6a63b188f4d8'
24+
UUIDStr = constr(regex=UUID_RE)
25+
2326

2427
class LogLevel(str, Enum):
2528
DEBUG = "DEBUG"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
""" String convesion
2+
3+
4+
Example of usage in pydantic:
5+
6+
[...]
7+
class Config:
8+
extra = Extra.forbid
9+
alias_generator = snake_to_camel # <--------
10+
json_loads = orjson.loads
11+
json_dumps = json_dumps
12+
13+
"""
14+
# Partially taken from https://github.com/autoferrit/python-change-case/blob/master/change_case/change_case.py#L131
15+
import re
16+
17+
_underscorer1 = re.compile(r"(.)([A-Z][a-z]+)")
18+
_underscorer2 = re.compile(r"([a-z0-9])([A-Z])")
19+
20+
21+
def snake_to_camel(subject: str) -> str:
22+
"""
23+
WARNING: assumes 'subject' is snake!
24+
The algorithm does not check if the subject is already camelcase.
25+
Make sure that is the case, otherwise you might get conversions like "camelAlready" -> "camelalready"
26+
27+
SEE test_utils_change_case.py
28+
"""
29+
parts = subject.lower().split("_")
30+
return parts[0] + "".join(word.title() for word in parts[1:])
31+
32+
33+
def snake_to_upper_camel(subject: str) -> str:
34+
"""
35+
WARNING: assumes 'subject' is snake! See details on the implications above.
36+
"""
37+
parts = subject.lower().split("_")
38+
return "".join(word.title() for word in parts)
39+
40+
41+
def camel_to_snake(subject: str) -> str:
42+
subbed = _underscorer1.sub(r"\1_\2", subject)
43+
return _underscorer2.sub(r"\1_\2", subbed).lower()
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import pytest
2+
from faker import Faker
3+
from models_library.basic_types import UUIDStr
4+
from pydantic import ValidationError
5+
from pydantic.tools import parse_obj_as
6+
7+
8+
@pytest.mark.skip(reason="DEV: testing parse_obj_as")
9+
def test_parse_uuid_as_a_string(faker: Faker):
10+
11+
expected_uuid = faker.uuid4()
12+
got_uuid = parse_obj_as(UUIDStr, expected_uuid)
13+
14+
assert isinstance(got_uuid, str)
15+
assert got_uuid == expected_uuid
16+
17+
with pytest.raises(ValidationError):
18+
parse_obj_as(UUIDStr, "123456-is-not-an-uuid")
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# pylint: disable=redefined-outer-name
2+
# pylint: disable=unused-argument
3+
# pylint: disable=unused-variable
4+
5+
6+
import pytest
7+
from models_library.utils.change_case import (
8+
camel_to_snake,
9+
snake_to_camel,
10+
snake_to_upper_camel,
11+
)
12+
13+
14+
@pytest.mark.parametrize(
15+
"subject,expected",
16+
[
17+
("snAke_Fun", "snakeFun"),
18+
("", ""),
19+
# WARNING: the algorithm does not check if the subject is already camelcase.
20+
# It will flatten the output like you can see in these examples.
21+
("camelAlready", "camelalready"),
22+
("AlmostCamel", "almostcamel"),
23+
("_S", "S"),
24+
# NOTE : that conversion is not always reversable (non-injective)
25+
("snakes_on_a_plane", "snakesOnAPlane"),
26+
("Snakes_On_A_Plane", "snakesOnAPlane"),
27+
("snakes_On_a_Plane", "snakesOnAPlane"),
28+
("snakes_on_A_plane", "snakesOnAPlane"),
29+
("i_phone_hysteria", "iPhoneHysteria"),
30+
("i_Phone_Hysteria", "iPhoneHysteria"),
31+
],
32+
)
33+
def test_snake_to_camel(subject, expected):
34+
assert snake_to_camel(subject) == expected
35+
36+
37+
@pytest.mark.parametrize(
38+
"subject,expected",
39+
[
40+
("already_snake", "already_snake"),
41+
# NOTE : that conversion is not always reversable (non-injective)
42+
("snakesOnAPlane", "snakes_on_a_plane"),
43+
("SnakesOnAPlane", "snakes_on_a_plane"),
44+
("IPhoneHysteria", "i_phone_hysteria"),
45+
("iPhoneHysteria", "i_phone_hysteria"),
46+
],
47+
)
48+
def test_camel_to_snake(subject, expected):
49+
assert camel_to_snake(subject) == expected
50+
51+
52+
@pytest.mark.parametrize(
53+
"subject,expected",
54+
[
55+
# NOTE : that conversion is not always reversable (non-injective)
56+
("snakes_on_a_plane", "SnakesOnAPlane"),
57+
("Snakes_On_A_Plane", "SnakesOnAPlane"),
58+
("snakes_On_a_Plane", "SnakesOnAPlane"),
59+
("snakes_on_A_plane", "SnakesOnAPlane"),
60+
("i_phone_hysteria", "IPhoneHysteria"),
61+
("i_Phone_Hysteria", "IPhoneHysteria"),
62+
],
63+
)
64+
def test_snake_to_upper_camel(subject, expected):
65+
assert snake_to_upper_camel(subject) == expected

packages/service-library/requirements/_aiohttp.in

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,12 @@
55

66
--constraint ./_base.in
77

8-
openapi-core==0.12.0 # frozen until https://github.com/ITISFoundation/osparc-simcore/pull/1396 is CLOSED
9-
lazy-object-proxy~=1.4.3 # cannot upgrade due to contraints in openapi-core
10-
aiozipkin
11-
128
aiohttp
139
aiopg[sa]
10+
aiozipkin
11+
attrs
1412
jsonschema
13+
lazy-object-proxy~=1.4.3 # cannot upgrade due to contraints in openapi-core
14+
openapi-core==0.12.0 # frozen until https://github.com/ITISFoundation/osparc-simcore/pull/1396 is CLOSED
1515
prometheus_client
16-
attrs
17-
1816
werkzeug

packages/service-library/requirements/_base.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
--constraint ./constraints.txt
66

77
aiodebug
8+
aiofiles
89
pydantic
910
pyinstrument
1011
pyyaml
1112
tenacity
12-
aiofiles
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
""" Parses and validation aiohttp requests against pydantic models
2+
3+
These functions are analogous to `pydantic.tools.parse_obj_as(model_class, obj)` for aiohttp's requests
4+
"""
5+
6+
from contextlib import contextmanager
7+
from typing import Iterator, Type, TypeVar
8+
9+
from aiohttp import web
10+
from pydantic import BaseModel, ValidationError
11+
12+
from ..json_serialization import json_dumps
13+
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
14+
15+
ModelType = TypeVar("ModelType", bound=BaseModel)
16+
17+
18+
@contextmanager
19+
def handle_validation_as_http_error(
20+
*, error_msg_template: str, resource_name: str, use_error_v1: bool
21+
) -> Iterator[None]:
22+
"""
23+
Transforms ValidationError into HTTP error
24+
"""
25+
try:
26+
27+
yield
28+
29+
except ValidationError as err:
30+
details = [
31+
{
32+
"loc": ".".join(map(str, e["loc"])),
33+
"msg": e["msg"],
34+
"type": e["type"],
35+
}
36+
for e in err.errors()
37+
]
38+
reason_msg = error_msg_template.format(
39+
failed=", ".join(d["loc"] for d in details)
40+
)
41+
42+
if use_error_v1:
43+
# NOTE: keeps backwards compatibility until ligher error response is implemented in the entire API
44+
# Implements servicelib.aiohttp.rest_responses.ErrorItemType
45+
errors = [
46+
{
47+
"code": e["type"],
48+
"message": e["msg"],
49+
"resource": resource_name,
50+
"field": e["loc"],
51+
}
52+
for e in details
53+
]
54+
error_str = json_dumps(
55+
{"error": {"status": web.HTTPBadRequest.status_code, "errors": errors}}
56+
)
57+
else:
58+
# NEW proposed error for https://github.com/ITISFoundation/osparc-simcore/issues/443
59+
error_str = json_dumps(
60+
{
61+
"error": {
62+
"msg": reason_msg,
63+
"resource": resource_name, # optional
64+
"details": details, # optional
65+
}
66+
}
67+
)
68+
69+
raise web.HTTPBadRequest(
70+
reason=reason_msg,
71+
text=error_str,
72+
content_type=MIMETYPE_APPLICATION_JSON,
73+
)
74+
75+
76+
# NOTE:
77+
#
78+
# - parameters in the path are part of the resource name and therefore are required
79+
# - parameters in the query are typically extra options like flags or filter options
80+
# - body holds the request data
81+
#
82+
83+
84+
def parse_request_path_parameters_as(
85+
parameters_schema: Type[ModelType],
86+
request: web.Request,
87+
*,
88+
use_enveloped_error_v1: bool = True,
89+
) -> ModelType:
90+
"""Parses path parameters from 'request' and validates against 'parameters_schema'
91+
92+
:raises HTTPBadRequest if validation of parameters fail
93+
"""
94+
with handle_validation_as_http_error(
95+
error_msg_template="Invalid parameter/s '{failed}' in request path",
96+
resource_name=request.rel_url.path,
97+
use_error_v1=use_enveloped_error_v1,
98+
):
99+
data = dict(request.match_info)
100+
return parameters_schema.parse_obj(data)
101+
102+
103+
def parse_request_query_parameters_as(
104+
parameters_schema: Type[ModelType],
105+
request: web.Request,
106+
*,
107+
use_enveloped_error_v1: bool = True,
108+
) -> ModelType:
109+
"""Parses query parameters from 'request' and validates against 'parameters_schema'
110+
111+
:raises HTTPBadRequest if validation of queries fail
112+
"""
113+
114+
with handle_validation_as_http_error(
115+
error_msg_template="Invalid parameter/s '{failed}' in request query",
116+
resource_name=request.rel_url.path,
117+
use_error_v1=use_enveloped_error_v1,
118+
):
119+
data = dict(request.query)
120+
return parameters_schema.parse_obj(data)
121+
122+
123+
async def parse_request_body_as(
124+
model_schema: Type[ModelType],
125+
request: web.Request,
126+
*,
127+
use_enveloped_error_v1: bool = True,
128+
) -> ModelType:
129+
"""Parses and validates request body against schema
130+
131+
:raises HTTPBadRequest
132+
"""
133+
with handle_validation_as_http_error(
134+
error_msg_template="Invalid field/s '{failed}' in request body",
135+
resource_name=request.rel_url.path,
136+
use_error_v1=use_enveloped_error_v1,
137+
):
138+
body = await request.json()
139+
return model_schema.parse_obj(body)

0 commit comments

Comments
 (0)