vws-python-mock 2026.8.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mock_vws/__init__.py +9 -0
- mock_vws/_base64_decoding.py +35 -0
- mock_vws/_constants.py +84 -0
- mock_vws/_database_matchers.py +107 -0
- mock_vws/_flask_server/Dockerfile +32 -0
- mock_vws/_flask_server/__init__.py +1 -0
- mock_vws/_flask_server/healthcheck.py +31 -0
- mock_vws/_flask_server/target_manager.py +447 -0
- mock_vws/_flask_server/vwq.py +173 -0
- mock_vws/_flask_server/vws.py +954 -0
- mock_vws/_mock_common.py +75 -0
- mock_vws/_model_target_web_api.py +486 -0
- mock_vws/_query_tools.py +136 -0
- mock_vws/_query_validators/__init__.py +128 -0
- mock_vws/_query_validators/accept_header_validators.py +31 -0
- mock_vws/_query_validators/auth_validators.py +143 -0
- mock_vws/_query_validators/content_length_validators.py +90 -0
- mock_vws/_query_validators/content_type_validators.py +65 -0
- mock_vws/_query_validators/date_validators.py +110 -0
- mock_vws/_query_validators/exceptions.py +769 -0
- mock_vws/_query_validators/fields_validators.py +47 -0
- mock_vws/_query_validators/image_validators.py +197 -0
- mock_vws/_query_validators/include_target_data_validators.py +51 -0
- mock_vws/_query_validators/num_results_validators.py +64 -0
- mock_vws/_query_validators/project_state_validators.py +49 -0
- mock_vws/_requests_mock_server/__init__.py +1 -0
- mock_vws/_requests_mock_server/decorators.py +275 -0
- mock_vws/_requests_mock_server/mock_web_query_api.py +139 -0
- mock_vws/_requests_mock_server/mock_web_services_api.py +954 -0
- mock_vws/_respx_mock_server/__init__.py +1 -0
- mock_vws/_respx_mock_server/decorators.py +186 -0
- mock_vws/_services_validators/__init__.py +186 -0
- mock_vws/_services_validators/active_flag_validators.py +43 -0
- mock_vws/_services_validators/auth_validators.py +124 -0
- mock_vws/_services_validators/content_length_validators.py +102 -0
- mock_vws/_services_validators/content_type_validators.py +42 -0
- mock_vws/_services_validators/date_validators.py +78 -0
- mock_vws/_services_validators/exceptions.py +858 -0
- mock_vws/_services_validators/image_validators.py +220 -0
- mock_vws/_services_validators/json_validators.py +69 -0
- mock_vws/_services_validators/key_validators.py +171 -0
- mock_vws/_services_validators/metadata_validators.py +106 -0
- mock_vws/_services_validators/name_validators.py +235 -0
- mock_vws/_services_validators/project_state_validators.py +76 -0
- mock_vws/_services_validators/request_quota_validators.py +42 -0
- mock_vws/_services_validators/target_quota_validators.py +41 -0
- mock_vws/_services_validators/target_validators.py +69 -0
- mock_vws/_services_validators/width_validators.py +38 -0
- mock_vws/database.py +257 -0
- mock_vws/database_type.py +13 -0
- mock_vws/image_matchers.py +124 -0
- mock_vws/model_target.py +79 -0
- mock_vws/py.typed +0 -0
- mock_vws/states.py +20 -0
- mock_vws/target.py +285 -0
- mock_vws/target_manager.py +178 -0
- mock_vws/target_raters.py +109 -0
- vws_python_mock-2026.8.4.dist-info/METADATA +177 -0
- vws_python_mock-2026.8.4.dist-info/RECORD +62 -0
- vws_python_mock-2026.8.4.dist-info/WHEEL +5 -0
- vws_python_mock-2026.8.4.dist-info/licenses/LICENSE +21 -0
- vws_python_mock-2026.8.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""A fake implementation of Vuforia Web Services for use with respx."""
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Helpers for mocking Vuforia with httpx via respx."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import respx
|
|
10
|
+
|
|
11
|
+
from mock_vws._mock_common import RequestData, Route
|
|
12
|
+
|
|
13
|
+
_ResponseType = tuple[int, Mapping[str, str], str | bytes]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _APIHandler(Protocol):
|
|
17
|
+
"""An API handler with mock routes."""
|
|
18
|
+
|
|
19
|
+
routes: set[Route]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _to_request_data(
|
|
23
|
+
request: httpx.Request,
|
|
24
|
+
*,
|
|
25
|
+
base_path: str,
|
|
26
|
+
) -> RequestData:
|
|
27
|
+
"""Convert an httpx.Request to a RequestData.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
request: The httpx request to convert.
|
|
31
|
+
base_path: The base path prefix to strip from the request path.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A RequestData with method, path, headers, and body set.
|
|
35
|
+
"""
|
|
36
|
+
path = request.url.raw_path.decode(encoding="ascii")
|
|
37
|
+
if base_path and path.startswith(base_path):
|
|
38
|
+
path = path[len(base_path) :]
|
|
39
|
+
return RequestData(
|
|
40
|
+
method=request.method,
|
|
41
|
+
path=path,
|
|
42
|
+
headers={k.title(): v for k, v in request.headers.items()},
|
|
43
|
+
body=request.content,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _block_unmatched(request: httpx.Request) -> httpx.Response:
|
|
48
|
+
"""Raise ConnectError for unmatched requests when real_http=False.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
request: The unmatched httpx request.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
Exception: A connection error is always raised to block
|
|
55
|
+
unmatched requests.
|
|
56
|
+
"""
|
|
57
|
+
raise httpx.ConnectError(
|
|
58
|
+
message="Connection refused by mock",
|
|
59
|
+
request=request,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _make_respx_callback(
|
|
64
|
+
*,
|
|
65
|
+
handler: Callable[[RequestData], _ResponseType],
|
|
66
|
+
base_path: str,
|
|
67
|
+
delay_seconds: float,
|
|
68
|
+
sleep_fn: Callable[[float], None],
|
|
69
|
+
) -> Callable[[httpx.Request], httpx.Response]:
|
|
70
|
+
"""Create a respx-compatible callback from a handler.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
handler: A handler that takes a RequestData and returns a
|
|
74
|
+
response tuple.
|
|
75
|
+
base_path: The base path prefix to strip from the request path.
|
|
76
|
+
delay_seconds: The number of seconds to delay the response by.
|
|
77
|
+
sleep_fn: The function to use for sleeping during delays.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A callback that takes an httpx.Request and returns an
|
|
81
|
+
httpx.Response.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def callback(request: httpx.Request) -> httpx.Response:
|
|
85
|
+
"""Handle an httpx request by converting it and calling the
|
|
86
|
+
handler.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
request: The httpx request to handle.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
An httpx.Response built from the handler's return value.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
Exception: A timeout error is raised when the response
|
|
96
|
+
delay exceeds the read timeout.
|
|
97
|
+
"""
|
|
98
|
+
request_data = _to_request_data(
|
|
99
|
+
request=request,
|
|
100
|
+
base_path=base_path,
|
|
101
|
+
)
|
|
102
|
+
timeout_info: dict[str, float | None] = request.extensions.get(
|
|
103
|
+
"timeout", {}
|
|
104
|
+
)
|
|
105
|
+
read_timeout = timeout_info.get("read")
|
|
106
|
+
if read_timeout is not None and delay_seconds > read_timeout:
|
|
107
|
+
sleep_fn(read_timeout)
|
|
108
|
+
raise httpx.ReadTimeout(
|
|
109
|
+
message="Response delay exceeded read timeout",
|
|
110
|
+
request=request,
|
|
111
|
+
)
|
|
112
|
+
status_code, headers, body = handler(request_data)
|
|
113
|
+
sleep_fn(delay_seconds)
|
|
114
|
+
if isinstance(body, str):
|
|
115
|
+
body = body.encode()
|
|
116
|
+
return httpx.Response(
|
|
117
|
+
status_code=status_code,
|
|
118
|
+
headers=headers,
|
|
119
|
+
content=body,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return callback
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def start_respx_router(
|
|
126
|
+
*,
|
|
127
|
+
mock_vws_api: _APIHandler,
|
|
128
|
+
mock_vwq_api: _APIHandler,
|
|
129
|
+
base_vws_url: str,
|
|
130
|
+
base_vwq_url: str,
|
|
131
|
+
response_delay_seconds: float,
|
|
132
|
+
sleep_fn: Callable[[float], None],
|
|
133
|
+
real_http: bool,
|
|
134
|
+
) -> respx.MockRouter:
|
|
135
|
+
"""Configure and start a respx router with Vuforia routes.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
mock_vws_api: The VWS API handler.
|
|
139
|
+
mock_vwq_api: The VWQ API handler.
|
|
140
|
+
base_vws_url: The base URL for the VWS API.
|
|
141
|
+
base_vwq_url: The base URL for the VWQ API.
|
|
142
|
+
response_delay_seconds: The number of seconds to delay responses.
|
|
143
|
+
sleep_fn: The function to use for sleeping during delays.
|
|
144
|
+
real_http: Whether to pass through unmatched requests.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
A started respx router.
|
|
148
|
+
"""
|
|
149
|
+
router = respx.MockRouter(
|
|
150
|
+
assert_all_called=False,
|
|
151
|
+
assert_all_mocked=False,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
for api, base_url in (
|
|
155
|
+
(mock_vws_api, base_vws_url),
|
|
156
|
+
(mock_vwq_api, base_vwq_url),
|
|
157
|
+
):
|
|
158
|
+
base_path = urlparse(url=base_url).path.rstrip("/")
|
|
159
|
+
for route in api.routes:
|
|
160
|
+
url_pattern = base_url.rstrip("/") + route.path_pattern + "$"
|
|
161
|
+
compiled_url_pattern = re.compile(pattern=url_pattern)
|
|
162
|
+
|
|
163
|
+
for http_method in route.http_methods:
|
|
164
|
+
original_callback = getattr( # pylint: disable=bad-builtin
|
|
165
|
+
api,
|
|
166
|
+
route.route_name,
|
|
167
|
+
)
|
|
168
|
+
router.route(
|
|
169
|
+
method=http_method,
|
|
170
|
+
url=compiled_url_pattern,
|
|
171
|
+
).mock(
|
|
172
|
+
side_effect=_make_respx_callback(
|
|
173
|
+
handler=original_callback,
|
|
174
|
+
base_path=base_path,
|
|
175
|
+
delay_seconds=response_delay_seconds,
|
|
176
|
+
sleep_fn=sleep_fn,
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if real_http:
|
|
181
|
+
router.route().pass_through()
|
|
182
|
+
else:
|
|
183
|
+
router.route().mock(side_effect=_block_unmatched)
|
|
184
|
+
|
|
185
|
+
router.start()
|
|
186
|
+
return router
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Input validators to use in the mock."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
|
|
5
|
+
from beartype import beartype
|
|
6
|
+
|
|
7
|
+
from mock_vws._database_matchers import AnyDatabase
|
|
8
|
+
|
|
9
|
+
from .active_flag_validators import validate_active_flag
|
|
10
|
+
from .auth_validators import (
|
|
11
|
+
validate_access_key_exists,
|
|
12
|
+
validate_auth_header_exists,
|
|
13
|
+
validate_auth_header_has_signature,
|
|
14
|
+
validate_authorization,
|
|
15
|
+
)
|
|
16
|
+
from .content_length_validators import (
|
|
17
|
+
validate_content_length_header_is_int,
|
|
18
|
+
validate_content_length_header_not_too_large,
|
|
19
|
+
validate_content_length_header_not_too_small,
|
|
20
|
+
)
|
|
21
|
+
from .content_type_validators import validate_content_type_header_given
|
|
22
|
+
from .date_validators import (
|
|
23
|
+
validate_date_format,
|
|
24
|
+
validate_date_header_given,
|
|
25
|
+
validate_date_in_range,
|
|
26
|
+
)
|
|
27
|
+
from .image_validators import (
|
|
28
|
+
validate_image_color_space,
|
|
29
|
+
validate_image_data_type,
|
|
30
|
+
validate_image_encoding,
|
|
31
|
+
validate_image_format,
|
|
32
|
+
validate_image_integrity,
|
|
33
|
+
validate_image_is_image,
|
|
34
|
+
validate_image_size,
|
|
35
|
+
)
|
|
36
|
+
from .json_validators import validate_body_given, validate_json
|
|
37
|
+
from .key_validators import validate_keys
|
|
38
|
+
from .metadata_validators import (
|
|
39
|
+
validate_metadata_encoding,
|
|
40
|
+
validate_metadata_size,
|
|
41
|
+
validate_metadata_type,
|
|
42
|
+
)
|
|
43
|
+
from .name_validators import (
|
|
44
|
+
validate_name_characters_in_range,
|
|
45
|
+
validate_name_does_not_exist_existing_target,
|
|
46
|
+
validate_name_does_not_exist_new_target,
|
|
47
|
+
validate_name_length,
|
|
48
|
+
validate_name_type,
|
|
49
|
+
)
|
|
50
|
+
from .project_state_validators import validate_project_state
|
|
51
|
+
from .request_quota_validators import validate_request_quota
|
|
52
|
+
from .target_quota_validators import validate_target_quota
|
|
53
|
+
from .target_validators import validate_target_id_exists
|
|
54
|
+
from .width_validators import validate_width
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@beartype
|
|
58
|
+
def run_services_validators(
|
|
59
|
+
*,
|
|
60
|
+
request_path: str,
|
|
61
|
+
request_headers: Mapping[str, str],
|
|
62
|
+
request_body: bytes,
|
|
63
|
+
request_method: str,
|
|
64
|
+
databases: Iterable[AnyDatabase],
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Run all validators.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
request_path: The path of the request.
|
|
70
|
+
request_headers: The headers sent with the request.
|
|
71
|
+
request_body: The body of the request.
|
|
72
|
+
request_method: The HTTP method of the request.
|
|
73
|
+
databases: All Vuforia databases.
|
|
74
|
+
"""
|
|
75
|
+
validate_auth_header_exists(request_headers=request_headers)
|
|
76
|
+
validate_auth_header_has_signature(request_headers=request_headers)
|
|
77
|
+
validate_access_key_exists(
|
|
78
|
+
request_headers=request_headers,
|
|
79
|
+
databases=databases,
|
|
80
|
+
)
|
|
81
|
+
validate_authorization(
|
|
82
|
+
request_headers=request_headers,
|
|
83
|
+
request_body=request_body,
|
|
84
|
+
request_method=request_method,
|
|
85
|
+
request_path=request_path,
|
|
86
|
+
databases=databases,
|
|
87
|
+
)
|
|
88
|
+
validate_request_quota(
|
|
89
|
+
request_headers=request_headers,
|
|
90
|
+
request_body=request_body,
|
|
91
|
+
request_method=request_method,
|
|
92
|
+
request_path=request_path,
|
|
93
|
+
databases=databases,
|
|
94
|
+
)
|
|
95
|
+
validate_project_state(
|
|
96
|
+
request_headers=request_headers,
|
|
97
|
+
request_body=request_body,
|
|
98
|
+
request_method=request_method,
|
|
99
|
+
request_path=request_path,
|
|
100
|
+
databases=databases,
|
|
101
|
+
)
|
|
102
|
+
validate_target_quota(
|
|
103
|
+
request_headers=request_headers,
|
|
104
|
+
request_body=request_body,
|
|
105
|
+
request_method=request_method,
|
|
106
|
+
request_path=request_path,
|
|
107
|
+
databases=databases,
|
|
108
|
+
)
|
|
109
|
+
validate_target_id_exists(
|
|
110
|
+
request_headers=request_headers,
|
|
111
|
+
request_body=request_body,
|
|
112
|
+
request_method=request_method,
|
|
113
|
+
request_path=request_path,
|
|
114
|
+
databases=databases,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
validate_body_given(
|
|
118
|
+
request_body=request_body,
|
|
119
|
+
request_method=request_method,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
validate_date_header_given(request_headers=request_headers)
|
|
123
|
+
validate_date_format(request_headers=request_headers)
|
|
124
|
+
validate_date_in_range(request_headers=request_headers)
|
|
125
|
+
|
|
126
|
+
validate_json(request_body=request_body, request_path=request_path)
|
|
127
|
+
|
|
128
|
+
validate_keys(
|
|
129
|
+
request_body=request_body,
|
|
130
|
+
request_path=request_path,
|
|
131
|
+
request_method=request_method,
|
|
132
|
+
)
|
|
133
|
+
validate_metadata_type(request_body=request_body)
|
|
134
|
+
validate_metadata_encoding(request_body=request_body)
|
|
135
|
+
validate_metadata_size(request_body=request_body)
|
|
136
|
+
validate_active_flag(request_body=request_body)
|
|
137
|
+
|
|
138
|
+
validate_image_data_type(request_body=request_body)
|
|
139
|
+
validate_image_encoding(request_body=request_body)
|
|
140
|
+
validate_image_is_image(request_body=request_body)
|
|
141
|
+
validate_image_format(request_body=request_body)
|
|
142
|
+
validate_image_color_space(request_body=request_body)
|
|
143
|
+
validate_image_size(request_body=request_body)
|
|
144
|
+
validate_image_integrity(request_body=request_body)
|
|
145
|
+
|
|
146
|
+
validate_name_type(request_body=request_body)
|
|
147
|
+
validate_name_length(request_body=request_body)
|
|
148
|
+
validate_name_characters_in_range(
|
|
149
|
+
request_body=request_body,
|
|
150
|
+
request_method=request_method,
|
|
151
|
+
request_path=request_path,
|
|
152
|
+
)
|
|
153
|
+
validate_name_does_not_exist_new_target(
|
|
154
|
+
request_headers=request_headers,
|
|
155
|
+
request_body=request_body,
|
|
156
|
+
request_method=request_method,
|
|
157
|
+
request_path=request_path,
|
|
158
|
+
databases=databases,
|
|
159
|
+
)
|
|
160
|
+
validate_name_does_not_exist_existing_target(
|
|
161
|
+
request_headers=request_headers,
|
|
162
|
+
request_body=request_body,
|
|
163
|
+
request_method=request_method,
|
|
164
|
+
request_path=request_path,
|
|
165
|
+
databases=databases,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
validate_width(request_body=request_body)
|
|
169
|
+
validate_content_type_header_given(
|
|
170
|
+
request_headers=request_headers,
|
|
171
|
+
request_method=request_method,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
validate_content_length_header_is_int(
|
|
175
|
+
request_headers=request_headers,
|
|
176
|
+
request_body=request_body,
|
|
177
|
+
)
|
|
178
|
+
validate_content_length_header_not_too_large(
|
|
179
|
+
request_headers=request_headers,
|
|
180
|
+
request_body=request_body,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
validate_content_length_header_not_too_small(
|
|
184
|
+
request_headers=request_headers,
|
|
185
|
+
request_body=request_body,
|
|
186
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Validators for the active flag."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
|
|
7
|
+
from beartype import beartype
|
|
8
|
+
|
|
9
|
+
from mock_vws._services_validators.exceptions import FailError
|
|
10
|
+
|
|
11
|
+
_LOGGER = logging.getLogger(name=__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@beartype
|
|
15
|
+
def validate_active_flag(*, request_body: bytes) -> None:
|
|
16
|
+
"""Validate the active flag data given to the endpoint.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
request_body: The body of the request.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
FailError: There is active flag data given to the endpoint which is not
|
|
23
|
+
either a Boolean or NULL.
|
|
24
|
+
"""
|
|
25
|
+
if not request_body:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
request_text = request_body.decode()
|
|
29
|
+
if "active_flag" not in json.loads(s=request_text):
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
active_flag = json.loads(s=request_text).get("active_flag")
|
|
33
|
+
|
|
34
|
+
if active_flag in {True, False, None}:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
_LOGGER.warning(
|
|
38
|
+
msg=(
|
|
39
|
+
'The value of "active_flag" is not a Boolean or NULL. '
|
|
40
|
+
"This is not allowed."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
raise FailError(status_code=HTTPStatus.BAD_REQUEST)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Authorization header validators to use in the mock."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterable, Mapping
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
|
|
7
|
+
from beartype import beartype
|
|
8
|
+
|
|
9
|
+
from mock_vws._database_matchers import (
|
|
10
|
+
AnyDatabase,
|
|
11
|
+
get_database_matching_server_keys,
|
|
12
|
+
)
|
|
13
|
+
from mock_vws._services_validators.exceptions import (
|
|
14
|
+
AuthenticationFailureError,
|
|
15
|
+
FailError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_LOGGER = logging.getLogger(name=__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@beartype
|
|
22
|
+
def validate_auth_header_exists(*, request_headers: Mapping[str, str]) -> None:
|
|
23
|
+
"""Validate that there is an authorization header given to a VWS
|
|
24
|
+
endpoint.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
request_headers: The headers sent with the request.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
AuthenticationFailureError: There is no "Authorization" header.
|
|
31
|
+
"""
|
|
32
|
+
if "Authorization" not in request_headers:
|
|
33
|
+
_LOGGER.warning(msg="There is no authorization header.")
|
|
34
|
+
raise AuthenticationFailureError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@beartype
|
|
38
|
+
def validate_access_key_exists(
|
|
39
|
+
*,
|
|
40
|
+
request_headers: Mapping[str, str],
|
|
41
|
+
databases: Iterable[AnyDatabase],
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Validate the authorization header includes an access key for a
|
|
44
|
+
database.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
request_headers: The headers sent with the request.
|
|
48
|
+
databases: All Vuforia databases.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
FailError: The access key does not match a given database.
|
|
52
|
+
"""
|
|
53
|
+
header = request_headers["Authorization"]
|
|
54
|
+
first_part, _ = header.split(sep=":")
|
|
55
|
+
_, access_key = first_part.split(sep=" ")
|
|
56
|
+
for database in databases:
|
|
57
|
+
if access_key == database.server_access_key:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
_LOGGER.warning(
|
|
61
|
+
'The access key "%s" does not match a known database.',
|
|
62
|
+
access_key,
|
|
63
|
+
)
|
|
64
|
+
raise FailError(status_code=HTTPStatus.BAD_REQUEST)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@beartype
|
|
68
|
+
def validate_auth_header_has_signature(
|
|
69
|
+
*,
|
|
70
|
+
request_headers: Mapping[str, str],
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Validate the authorization header includes a signature.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
request_headers: The headers sent with the request.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
FailError: The "Authorization" header does not include a signature.
|
|
79
|
+
"""
|
|
80
|
+
header = request_headers["Authorization"]
|
|
81
|
+
if header.count(":") == 1 and header.split(sep=":")[1]:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
_LOGGER.warning(
|
|
85
|
+
msg="The authorization header does not include a signature.",
|
|
86
|
+
)
|
|
87
|
+
raise FailError(status_code=HTTPStatus.BAD_REQUEST)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@beartype
|
|
91
|
+
def validate_authorization(
|
|
92
|
+
*,
|
|
93
|
+
request_path: str,
|
|
94
|
+
request_headers: Mapping[str, str],
|
|
95
|
+
request_body: bytes,
|
|
96
|
+
request_method: str,
|
|
97
|
+
databases: Iterable[AnyDatabase],
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Validate the authorization header given to a VWS endpoint.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
request_path: The path of the request.
|
|
103
|
+
request_headers: The headers sent with the request.
|
|
104
|
+
request_body: The body of the request.
|
|
105
|
+
request_method: The HTTP method of the request.
|
|
106
|
+
databases: All Vuforia databases.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
AuthenticationFailureError: No database matches the given authorization
|
|
110
|
+
header.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
get_database_matching_server_keys(
|
|
114
|
+
request_headers=request_headers,
|
|
115
|
+
request_body=request_body,
|
|
116
|
+
request_method=request_method,
|
|
117
|
+
request_path=request_path,
|
|
118
|
+
databases=databases,
|
|
119
|
+
)
|
|
120
|
+
except ValueError as exc:
|
|
121
|
+
_LOGGER.warning(
|
|
122
|
+
msg="No database matches the given authorization header.",
|
|
123
|
+
)
|
|
124
|
+
raise AuthenticationFailureError from exc
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Content-Length header validators to use in the mock."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
from beartype import beartype
|
|
7
|
+
|
|
8
|
+
from mock_vws._services_validators.exceptions import (
|
|
9
|
+
AuthenticationFailureError,
|
|
10
|
+
ContentLengthHeaderNotIntError,
|
|
11
|
+
ContentLengthHeaderTooLargeError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_LOGGER = logging.getLogger(name=__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@beartype
|
|
18
|
+
def validate_content_length_header_is_int(
|
|
19
|
+
*,
|
|
20
|
+
request_headers: Mapping[str, str],
|
|
21
|
+
request_body: bytes,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Validate the ``Content-Length`` header is an integer.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
request_headers: The headers sent with the request.
|
|
27
|
+
request_body: The body of the request.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ContentLengthHeaderNotIntError: The content length header is not an
|
|
31
|
+
integer
|
|
32
|
+
"""
|
|
33
|
+
body_length = len(request_body)
|
|
34
|
+
request_headers_dict = dict(request_headers)
|
|
35
|
+
given_content_length = request_headers_dict.get(
|
|
36
|
+
"Content-Length",
|
|
37
|
+
body_length,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
int(given_content_length)
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
_LOGGER.warning(msg="The Content-Length header is not an integer.")
|
|
44
|
+
raise ContentLengthHeaderNotIntError from exc
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@beartype
|
|
48
|
+
def validate_content_length_header_not_too_large(
|
|
49
|
+
*,
|
|
50
|
+
request_headers: Mapping[str, str],
|
|
51
|
+
request_body: bytes,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Validate the ``Content-Length`` header is not too large.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
request_headers: The headers sent with the request.
|
|
57
|
+
request_body: The body of the request.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ContentLengthHeaderTooLargeError: The given content length header says
|
|
61
|
+
that the content length is greater than the body length.
|
|
62
|
+
"""
|
|
63
|
+
body_length = len(request_body)
|
|
64
|
+
request_headers_dict = dict(request_headers)
|
|
65
|
+
given_content_length = request_headers_dict.get(
|
|
66
|
+
"Content-Length",
|
|
67
|
+
body_length,
|
|
68
|
+
)
|
|
69
|
+
given_content_length_value = int(given_content_length)
|
|
70
|
+
# We skip coverage here as running a test to cover this is very slow.
|
|
71
|
+
if given_content_length_value > body_length: # pragma: no cover
|
|
72
|
+
_LOGGER.warning(msg="The Content-Length header is too large.")
|
|
73
|
+
raise ContentLengthHeaderTooLargeError
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@beartype
|
|
77
|
+
def validate_content_length_header_not_too_small(
|
|
78
|
+
*,
|
|
79
|
+
request_headers: Mapping[str, str],
|
|
80
|
+
request_body: bytes,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Validate the ``Content-Length`` header is not too small.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
request_headers: The headers sent with the request.
|
|
86
|
+
request_body: The body of the request.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
AuthenticationFailureError: The given content length header says that
|
|
90
|
+
the content length is smaller than the body length.
|
|
91
|
+
"""
|
|
92
|
+
body_length = len(request_body)
|
|
93
|
+
request_headers_dict = dict(request_headers)
|
|
94
|
+
given_content_length = request_headers_dict.get(
|
|
95
|
+
"Content-Length",
|
|
96
|
+
body_length,
|
|
97
|
+
)
|
|
98
|
+
given_content_length_value = int(given_content_length)
|
|
99
|
+
|
|
100
|
+
if given_content_length_value < body_length:
|
|
101
|
+
_LOGGER.warning(msg="The Content-Length header is too small.")
|
|
102
|
+
raise AuthenticationFailureError
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Content-Type header validators to use in the mock."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from http import HTTPMethod
|
|
6
|
+
|
|
7
|
+
from beartype import beartype
|
|
8
|
+
|
|
9
|
+
from mock_vws._services_validators.exceptions import AuthenticationFailureError
|
|
10
|
+
|
|
11
|
+
_LOGGER = logging.getLogger(name=__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@beartype
|
|
15
|
+
def validate_content_type_header_given(
|
|
16
|
+
*,
|
|
17
|
+
request_headers: Mapping[str, str],
|
|
18
|
+
request_method: str,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Validate that there is a non-empty content type header given if
|
|
21
|
+
required.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
request_headers: The headers sent with the request.
|
|
25
|
+
request_method: The HTTP method of the request.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
AuthenticationFailureError: No ``Content-Type`` header is given and the
|
|
29
|
+
request requires one.
|
|
30
|
+
"""
|
|
31
|
+
request_headers_dict = dict(request_headers)
|
|
32
|
+
request_needs_content_type = bool(
|
|
33
|
+
request_method in {HTTPMethod.POST, HTTPMethod.PUT},
|
|
34
|
+
)
|
|
35
|
+
if (
|
|
36
|
+
request_headers_dict.get("Content-Type")
|
|
37
|
+
or not request_needs_content_type
|
|
38
|
+
):
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
_LOGGER.warning(msg="No Content-Type header is given.")
|
|
42
|
+
raise AuthenticationFailureError
|