Skip to content

Commit b5d683a

Browse files
t-perssonTobias Persson
andauthored
Add test suite validation (#11)
Co-authored-by: Tobias Persson <tobias.persson@axis.com>
1 parent 42c4982 commit b5d683a

File tree

5 files changed

+565
-5
lines changed

5 files changed

+565
-5
lines changed

src/etos_api/lib/validator.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Copyright 2020 Axis Communications AB.
2+
#
3+
# For a full list of individual contributors, please see the commit history.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
"""ETOS API suite validator module."""
17+
import logging
18+
from uuid import UUID
19+
from typing import Union, List
20+
from pydantic import BaseModel, validator, ValidationError, constr, conlist
21+
import requests
22+
23+
24+
class Environment(BaseModel):
25+
"""ETOS suite definion 'ENVIRONMENT' constraint."""
26+
27+
key: str
28+
value: dict
29+
30+
31+
class Command(BaseModel):
32+
"""ETOS suite definion 'COMMAND' constraint."""
33+
34+
key: str
35+
value: constr(min_length=1)
36+
37+
38+
class Checkout(BaseModel):
39+
"""ETOS suite definion 'CHECKOUT' constraint."""
40+
41+
key: str
42+
value: conlist(str, min_items=1)
43+
44+
45+
class Parameters(BaseModel):
46+
"""ETOS suite definion 'PARAMETERS' constraint."""
47+
48+
key: str
49+
value: dict
50+
51+
52+
class Execute(BaseModel):
53+
"""ETOS suite definion 'EXECUTE' constraint."""
54+
55+
key: str
56+
value: List[str]
57+
58+
59+
class TestRunner(BaseModel):
60+
"""ETOS suite definion 'TEST_RUNNER' constraint."""
61+
62+
key: str
63+
value: constr(min_length=1)
64+
65+
66+
class TestCase(BaseModel):
67+
"""ETOS suite definion 'testCase' field."""
68+
69+
id: str
70+
tracker: str
71+
url: str
72+
73+
74+
class Constraint(BaseModel):
75+
"""ETOS suite definion 'constraints' field."""
76+
77+
key: str
78+
value: Union[str, list, dict] # pylint:disable=unsubscriptable-object
79+
80+
81+
class Recipe(BaseModel):
82+
"""ETOS suite definion 'recipes' field."""
83+
84+
constraints: List[Constraint]
85+
id: UUID
86+
testCase: TestCase
87+
88+
__constraint_models = {
89+
"ENVIRONMENT": Environment,
90+
"COMMAND": Command,
91+
"CHECKOUT": Checkout,
92+
"PARAMETERS": Parameters,
93+
"EXECUTE": Execute,
94+
"TEST_RUNNER": TestRunner,
95+
}
96+
97+
@validator("constraints")
98+
def validate_constraints(
99+
cls, value
100+
): # Pydantic requires cls. pylint:disable=no-self-argument
101+
"""Validate the constraints fields for each recipe.
102+
103+
Validation is done manually because error messages from pydantic
104+
are not clear enough when using a Union check on the models.
105+
Pydantic does not check the number of unions either, which is something
106+
that is required for ETOS.
107+
108+
:raises ValueError: if there are too many or too few constraints.
109+
:raises TypeError: If an unknown constraint is detected.
110+
:raises ValidationError: If constraint model does not validate.
111+
112+
:param value: The current constraint that is being validated.
113+
:type value: Any
114+
:return: Same as value, if validated.
115+
:rtype: Any
116+
"""
117+
count = dict.fromkeys(cls.__constraint_models.keys(), 0)
118+
for constraint in value:
119+
model = cls.__constraint_models.get(constraint.key)
120+
if model is None:
121+
raise TypeError(
122+
"Unknown key %r, valid keys: %r"
123+
% (constraint.key, tuple(cls.__constraint_models.keys()))
124+
)
125+
try:
126+
model(**constraint.dict())
127+
except ValidationError as exception:
128+
raise ValueError(str(exception)) from exception
129+
count[constraint.key] += 1
130+
more_than_one = [key for key, number in count.items() if number > 1]
131+
if more_than_one:
132+
raise ValueError(
133+
"Too many instances of keys %r. Only 1 allowed." % more_than_one
134+
)
135+
missing = [key for key, number in count.items() if number == 0]
136+
if missing:
137+
raise ValueError(
138+
"Too few instances of keys %r. At least 1 required." % missing
139+
)
140+
return value
141+
142+
143+
class Suite(BaseModel):
144+
"""ETOS base suite definition."""
145+
146+
name: str
147+
priority: int
148+
recipes: List[Recipe]
149+
150+
151+
class SuiteValidator: # pylint:disable=too-few-public-methods
152+
"""Validate ETOS suite definitions to make sure they are executable."""
153+
154+
logger = logging.getLogger(__name__)
155+
156+
def __init__(self, params):
157+
"""Initialize validator.
158+
159+
:param params: Parameter instance.
160+
:type params: :obj:`etos_api.lib.params.Params`
161+
"""
162+
self.params = params
163+
164+
def _download_suite(self):
165+
"""Attempt to download suite."""
166+
try:
167+
suite = requests.get(self.params.test_suite)
168+
suite.raise_for_status()
169+
except Exception as exception: # pylint:disable=broad-except
170+
raise AssertionError(
171+
"Unable to download suite from %r" % self.params.test_suite
172+
) from exception
173+
return suite.json()
174+
175+
def validate(self):
176+
"""Validate the ETOS suite definition.
177+
178+
:raises ValidationError: If the suite did not validate.
179+
"""
180+
downloaded_suite = self._download_suite()
181+
assert Suite(**downloaded_suite)

src/etos_api/webserver.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from etos_api.middleware import RequireJSON, JSONTranslator
2323
from etos_api.lib.params import Params
24+
from etos_api.lib.validator import SuiteValidator, ValidationError
2425

2526

2627
_LOGGER = logging.getLogger(__name__)
@@ -37,6 +38,16 @@ class Webserver:
3738
def on_post(request, response):
3839
"""Handle POST requests. Generate and execute an ETOS test suite."""
3940
params = Params(request)
41+
validator = SuiteValidator(params)
42+
try:
43+
validator.validate()
44+
except (ValidationError, AssertionError) as exception:
45+
response.status = falcon.HTTP_400
46+
response.media = {
47+
"error": "Not a valid suite definition provided",
48+
"details": traceback.format_exc(),
49+
}
50+
return
4051
try:
4152
result = params.tester.handle()
4253
except Exception as exc: # pylint: disable=broad-except

tests/lib/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright 2020 Axis Communications AB.
2+
#
3+
# For a full list of individual contributors, please see the commit history.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
"""Tests for the library module."""

0 commit comments

Comments
 (0)