From 52d60c5617b562147d4d82eef2a924864da65f8e Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 6 Sep 2026 19:12:47 +0100 Subject: [PATCH] Build the services validator chains once, per route Every VWS request ran around thirty validators in sequence, and each one worked out for itself whether it applied by re-reading the body or by re-checking the path and the method. Vuforia's error precedence -- which of several simultaneous problems with a request it reports -- was therefore encoded implicitly as the order of the calls in one function, and nothing stated it. Declare the validators which apply to each route, in the order in which they apply, in a route table built once at import time. Each validator now takes a ValidatorContext which carries the route's own facts, so the applicability guards, the per-request route table in the key validator and the validators which could not apply are all gone. Refs #3371 Co-Authored-By: Claude Opus 5 (1M context) --- newsfragments/3371.change | 1 + src/mock_vws/_flask_server/vws.py | 2 +- src/mock_vws/_services_validators/__init__.py | 161 +------- .../active_flag_validators.py | 14 +- .../content_length_validators.py | 65 ++- .../content_type_validators.py | 27 +- src/mock_vws/_services_validators/context.py | 59 +++ .../database_id_validators.py | 27 +- .../_services_validators/date_validators.py | 27 +- .../_services_validators/image_validators.py | 241 +++++------ .../instance_id_validators.py | 27 +- .../_services_validators/json_validators.py | 96 +++-- .../_services_validators/key_validators.py | 151 +------ .../metadata_validators.py | 36 +- .../_services_validators/name_validators.py | 221 +++++----- .../project_state_validators.py | 25 +- .../request_quota_validators.py | 15 +- .../request_rate_limiter.py | 100 +++++ .../request_rate_validators.py | 139 +------ src/mock_vws/_services_validators/routes.py | 390 ++++++++++++++++++ .../target_quota_validators.py | 27 +- .../_services_validators/target_validators.py | 53 ++- .../_services_validators/width_validators.py | 14 +- src/mock_vws/target_manager.py | 2 +- tests/mock_vws/test_requests_mock_usage.py | 2 +- tests/mock_vws/test_target_validators.py | 39 +- 26 files changed, 1029 insertions(+), 932 deletions(-) create mode 100644 newsfragments/3371.change create mode 100644 src/mock_vws/_services_validators/context.py create mode 100644 src/mock_vws/_services_validators/request_rate_limiter.py create mode 100644 src/mock_vws/_services_validators/routes.py diff --git a/newsfragments/3371.change b/newsfragments/3371.change new file mode 100644 index 000000000..fc06d1cb9 --- /dev/null +++ b/newsfragments/3371.change @@ -0,0 +1 @@ +A request body which is empty is now rejected with the same error as a request body which is not valid JSON, on every VWS endpoint which takes one. Previously the mock raised an unhandled error. diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index c12fb3aae..5ad802dcb 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -67,7 +67,7 @@ TargetStatusProcessingError, ValidatorError, ) -from mock_vws._services_validators.request_rate_validators import ( +from mock_vws._services_validators.request_rate_limiter import ( RequestRateLimiter, ) from mock_vws.database import CloudDatabase, VuMarkDatabase diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index f26d95e54..474569255 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -6,62 +6,15 @@ from mock_vws._database_matchers import AnyDatabase -from .active_flag_validators import validate_active_flag from .auth_validators import ( validate_access_key_exists, validate_auth_header_exists, validate_auth_header_has_signature, validate_authorization, ) -from .content_length_validators import ( - validate_content_length_header_is_int, - validate_content_length_header_not_too_large, - validate_content_length_header_not_too_small, -) -from .content_type_validators import validate_content_type_header_given -from .database_id_validators import validate_database_id_matches_keys -from .date_validators import ( - validate_date_format, - validate_date_header_given, - validate_date_in_range, -) -from .image_validators import ( - validate_image_color_space, - validate_image_data_type, - validate_image_encoding, - validate_image_format, - validate_image_integrity, - validate_image_is_image, - validate_image_pixel_count, - validate_image_size, -) -from .instance_id_validators import ( - validate_instance_id_not_empty, - validate_instance_id_type, -) -from .json_validators import validate_body_given, validate_json -from .key_validators import validate_keys -from .metadata_validators import ( - validate_metadata_encoding, - validate_metadata_size, - validate_metadata_type, -) -from .name_validators import ( - validate_name_characters_in_range, - validate_name_does_not_exist_existing_target, - validate_name_does_not_exist_new_target, - validate_name_length, - validate_name_type, -) -from .project_state_validators import validate_project_state -from .request_quota_validators import validate_request_quota -from .request_rate_validators import ( - RequestRateLimiter, - validate_request_rate, -) -from .target_quota_validators import validate_target_quota -from .target_validators import validate_target_id_exists -from .width_validators import validate_width +from .context import ValidatorContext +from .request_rate_limiter import RequestRateLimiter +from .routes import match_route @beartype @@ -74,7 +27,12 @@ def run_services_validators[DatabaseT: AnyDatabase]( databases: Iterable[DatabaseT], request_rate_limiter: RequestRateLimiter, ) -> DatabaseT: - """Run all validators. + """Run the validators which apply to the request. + + Every request is authorized first, because the validators which follow + are given the database which the request's server keys belong to. Which + validators follow, and in which order, is decided by the route the + request was made to. See :py:mod:`mock_vws._services_validators.routes`. Args: request_path: The path of the request. @@ -100,100 +58,25 @@ def run_services_validators[DatabaseT: AnyDatabase]( request_path=request_path, databases=databases, ) - validate_database_id_matches_keys( - request_path=request_path, - database=database, - ) - validate_request_quota(database=database) - validate_request_rate( - request_method=request_method, - request_path=request_path, - database=database, - request_rate_limiter=request_rate_limiter, - ) - validate_project_state( - request_method=request_method, - request_path=request_path, - database=database, - ) - validate_target_quota( - request_method=request_method, - request_path=request_path, - database=database, - ) - validate_target_id_exists( - request_path=request_path, - database=database, - ) - - validate_body_given( - request_body=request_body, - request_method=request_method, - ) - validate_date_header_given(request_headers=request_headers) - validate_date_format(request_headers=request_headers) - validate_date_in_range(request_headers=request_headers) - - validate_json(request_body=request_body, request_path=request_path) - - validate_keys( - request_body=request_body, + route = match_route( request_path=request_path, request_method=request_method, ) - validate_metadata_type(request_body=request_body) - validate_metadata_encoding(request_body=request_body) - validate_metadata_size(request_body=request_body) - validate_active_flag(request_body=request_body) - validate_instance_id_type(request_body=request_body) - validate_instance_id_not_empty(request_body=request_body) - - validate_image_data_type(request_body=request_body) - validate_image_encoding(request_body=request_body) - validate_image_is_image(request_body=request_body) - validate_image_format(request_body=request_body) - validate_image_color_space(request_body=request_body) - validate_image_size(request_body=request_body) - validate_image_pixel_count(request_body=request_body) - validate_image_integrity(request_body=request_body) - - validate_name_type(request_body=request_body) - validate_name_length(request_body=request_body) - validate_name_characters_in_range( - request_body=request_body, - request_method=request_method, - request_path=request_path, - ) - validate_name_does_not_exist_new_target( - request_body=request_body, - request_path=request_path, - database=database, - ) - validate_name_does_not_exist_existing_target( - request_body=request_body, + context = ValidatorContext( request_path=request_path, - database=database, - ) - - validate_width(request_body=request_body) - validate_content_type_header_given( - request_headers=request_headers, - request_method=request_method, - ) - - validate_content_length_header_is_int( - request_headers=request_headers, - request_body=request_body, - ) - validate_content_length_header_not_too_large( request_headers=request_headers, request_body=request_body, - ) - - validate_content_length_header_not_too_small( - request_headers=request_headers, - request_body=request_body, - ) + database=database, + request_rate_limiter=request_rate_limiter, + mandatory_keys=route.mandatory_keys, + optional_keys=route.optional_keys, + rate_limited_endpoint=route.rate_limited_endpoint, + allowed_for_inactive_cloud_project=( + route.allowed_for_inactive_cloud_project + ), + ) + for validator in route.validators: + validator(context=context) return database diff --git a/src/mock_vws/_services_validators/active_flag_validators.py b/src/mock_vws/_services_validators/active_flag_validators.py index 66a446945..d2d8921a2 100644 --- a/src/mock_vws/_services_validators/active_flag_validators.py +++ b/src/mock_vws/_services_validators/active_flag_validators.py @@ -6,30 +6,28 @@ from beartype import beartype +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import FailError _LOGGER = logging.getLogger(name=__name__) @beartype -def validate_active_flag(*, request_body: bytes) -> None: +def validate_active_flag(*, context: ValidatorContext) -> None: """Validate the active flag data given to the endpoint. Args: - request_body: The body of the request. + context: The context of the request. Raises: FailError: There is active flag data given to the endpoint which is not either a Boolean or NULL. """ - if not request_body: + request_json = json.loads(s=context.request_body.decode()) + if "active_flag" not in request_json: return - request_text = request_body.decode() - if "active_flag" not in json.loads(s=request_text): - return - - active_flag = json.loads(s=request_text).get("active_flag") + active_flag = request_json["active_flag"] if active_flag in {True, False, None}: return diff --git a/src/mock_vws/_services_validators/content_length_validators.py b/src/mock_vws/_services_validators/content_length_validators.py index eaa41b2af..925c74fb5 100644 --- a/src/mock_vws/_services_validators/content_length_validators.py +++ b/src/mock_vws/_services_validators/content_length_validators.py @@ -1,10 +1,10 @@ """Content-Length header validators to use in the mock.""" import logging -from collections.abc import Mapping from beartype import beartype +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import ( AuthenticationFailureError, ContentLengthHeaderNotIntError, @@ -14,31 +14,39 @@ _LOGGER = logging.getLogger(name=__name__) +@beartype +def _given_content_length(*, context: ValidatorContext) -> str | int: + """Return the given ``Content-Length``, or the real body length. + + Args: + context: The context of the request. + + Returns: + The value of the ``Content-Length`` header, or the length of the + request body if no such header was given. + """ + return dict(context.request_headers).get( + "Content-Length", + len(context.request_body), + ) + + @beartype def validate_content_length_header_is_int( *, - request_headers: Mapping[str, str], - request_body: bytes, + context: ValidatorContext, ) -> None: """Validate the ``Content-Length`` header is an integer. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + context: The context of the request. Raises: ContentLengthHeaderNotIntError: The content length header is not an integer """ - body_length = len(request_body) - request_headers_dict = dict(request_headers) - given_content_length = request_headers_dict.get( - "Content-Length", - body_length, - ) - try: - int(given_content_length) + int(_given_content_length(context=context)) except ValueError as exc: _LOGGER.warning(msg="The Content-Length header is not an integer.") raise ContentLengthHeaderNotIntError from exc @@ -47,26 +55,19 @@ def validate_content_length_header_is_int( @beartype def validate_content_length_header_not_too_large( *, - request_headers: Mapping[str, str], - request_body: bytes, + context: ValidatorContext, ) -> None: """Validate the ``Content-Length`` header is not too large. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + context: The context of the request. Raises: ContentLengthHeaderTooLargeError: The given content length header says that the content length is greater than the body length. """ - body_length = len(request_body) - request_headers_dict = dict(request_headers) - given_content_length = request_headers_dict.get( - "Content-Length", - body_length, - ) - given_content_length_value = int(given_content_length) + given_content_length_value = int(_given_content_length(context=context)) + body_length = len(context.request_body) # We skip coverage here as running a test to cover this is very slow. if given_content_length_value > body_length: # pragma: no cover _LOGGER.warning(msg="The Content-Length header is too large.") @@ -76,27 +77,19 @@ def validate_content_length_header_not_too_large( @beartype def validate_content_length_header_not_too_small( *, - request_headers: Mapping[str, str], - request_body: bytes, + context: ValidatorContext, ) -> None: """Validate the ``Content-Length`` header is not too small. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + context: The context of the request. Raises: AuthenticationFailureError: The given content length header says that the content length is smaller than the body length. """ - body_length = len(request_body) - request_headers_dict = dict(request_headers) - given_content_length = request_headers_dict.get( - "Content-Length", - body_length, - ) - given_content_length_value = int(given_content_length) + given_content_length_value = int(_given_content_length(context=context)) - if given_content_length_value < body_length: + if given_content_length_value < len(context.request_body): _LOGGER.warning(msg="The Content-Length header is too small.") raise AuthenticationFailureError diff --git a/src/mock_vws/_services_validators/content_type_validators.py b/src/mock_vws/_services_validators/content_type_validators.py index 12913fa6f..cbaba695f 100644 --- a/src/mock_vws/_services_validators/content_type_validators.py +++ b/src/mock_vws/_services_validators/content_type_validators.py @@ -1,41 +1,26 @@ """Content-Type header validators to use in the mock.""" import logging -from collections.abc import Mapping -from http import HTTPMethod from beartype import beartype +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import AuthenticationFailureError _LOGGER = logging.getLogger(name=__name__) @beartype -def validate_content_type_header_given( - *, - request_headers: Mapping[str, str], - request_method: str, -) -> None: - """Validate that there is a non-empty content type header given if - required. +def validate_content_type_header_given(*, context: ValidatorContext) -> None: + """Validate that there is a non-empty content type header given. Args: - request_headers: The headers sent with the request. - request_method: The HTTP method of the request. + context: The context of the request. Raises: - AuthenticationFailureError: No ``Content-Type`` header is given and the - request requires one. + AuthenticationFailureError: No ``Content-Type`` header is given. """ - request_headers_dict = dict(request_headers) - request_needs_content_type = bool( - request_method in {HTTPMethod.POST, HTTPMethod.PUT}, - ) - if ( - request_headers_dict.get("Content-Type") - or not request_needs_content_type - ): + if dict(context.request_headers).get("Content-Type"): return _LOGGER.warning(msg="No Content-Type header is given.") diff --git a/src/mock_vws/_services_validators/context.py b/src/mock_vws/_services_validators/context.py new file mode 100644 index 000000000..9fbf5c7af --- /dev/null +++ b/src/mock_vws/_services_validators/context.py @@ -0,0 +1,59 @@ +"""The request context which every services validator is given.""" + +from collections.abc import Mapping +from dataclasses import dataclass + +from beartype import beartype + +from mock_vws._database_matchers import AnyDatabase +from mock_vws.request_rate_limits import RateLimitedEndpoint + +from .request_rate_limiter import RequestRateLimiter + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ValidatorContext: + """Everything which a services validator is given. + + A validator is chosen by the route it belongs to, so it never has to + work out whether it applies to the request. The route's own facts are + copied onto the context rather than being looked up again from the + path and the method. + + Args: + request_path: The path of the request. + request_headers: The headers sent with the request. + request_body: The body of the request. + database: The database which the request's server keys belong to. + request_rate_limiter: The rate limiter tracking recent requests. + mandatory_keys: Keys which the route requires in the request body. + optional_keys: Keys which the route allows in the request body. + rate_limited_endpoint: The group of endpoints which the route shares + a request rate limit with. + allowed_for_inactive_cloud_project: Whether the route works against + an inactive cloud database. + + Attributes: + request_path: The path of the request. + request_headers: The headers sent with the request. + request_body: The body of the request. + database: The database which the request's server keys belong to. + request_rate_limiter: The rate limiter tracking recent requests. + mandatory_keys: Keys which the route requires in the request body. + optional_keys: Keys which the route allows in the request body. + rate_limited_endpoint: The group of endpoints which the route shares + a request rate limit with. + allowed_for_inactive_cloud_project: Whether the route works against + an inactive cloud database. + """ + + request_path: str + request_headers: Mapping[str, str] + request_body: bytes + database: AnyDatabase + request_rate_limiter: RequestRateLimiter + mandatory_keys: frozenset[str] + optional_keys: frozenset[str] + rate_limited_endpoint: RateLimitedEndpoint + allowed_for_inactive_cloud_project: bool diff --git a/src/mock_vws/_services_validators/database_id_validators.py b/src/mock_vws/_services_validators/database_id_validators.py index d3a2a25e0..6730202b8 100644 --- a/src/mock_vws/_services_validators/database_id_validators.py +++ b/src/mock_vws/_services_validators/database_id_validators.py @@ -1,12 +1,10 @@ """Validators for database IDs given in request paths.""" import logging -import re from beartype import beartype -from mock_vws._database_matchers import AnyDatabase -from mock_vws._mock_common import RECO_COUNTS_REPORT_PATH_PATTERN +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import ( AuthenticationFailureError, ) @@ -19,34 +17,25 @@ @beartype -def validate_database_id_matches_keys( - *, - request_path: str, - database: AnyDatabase, -) -> None: +def validate_database_id_matches_keys(*, context: ValidatorContext) -> None: """Validate a database ID given in the request path. The ID must be the ID of the database which the request's server keys belong to. Args: - request_path: The path of the request. - database: The database which the request's server keys belong to. + context: The context of the request. Raises: AuthenticationFailureError: The request path names a database other than the one which the request's server keys belong to. """ - if not re.fullmatch( - pattern=RECO_COUNTS_REPORT_PATH_PATTERN, - string=request_path, - ): - return - - given_database_id = request_path.split(sep="/")[_DATABASE_ID_PATH_INDEX] + given_database_id = context.request_path.split(sep="/")[ + _DATABASE_ID_PATH_INDEX + ] if ( - isinstance(database, CloudDatabase) - and database.database_id == given_database_id + isinstance(context.database, CloudDatabase) + and context.database.database_id == given_database_id ): return diff --git a/src/mock_vws/_services_validators/date_validators.py b/src/mock_vws/_services_validators/date_validators.py index f5f773d97..0677d804c 100644 --- a/src/mock_vws/_services_validators/date_validators.py +++ b/src/mock_vws/_services_validators/date_validators.py @@ -2,12 +2,12 @@ import datetime import logging -from collections.abc import Mapping from http import HTTPStatus from zoneinfo import ZoneInfo from beartype import beartype +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import ( FailError, RequestTimeTooSkewedError, @@ -15,18 +15,20 @@ _LOGGER = logging.getLogger(name=__name__) +_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" + @beartype -def validate_date_header_given(*, request_headers: Mapping[str, str]) -> None: +def validate_date_header_given(*, context: ValidatorContext) -> None: """Validate the date header is given to a VWS endpoint. Args: - request_headers: The headers sent with the request. + context: The context of the request. Raises: FailError: The date is not given. """ - if "Date" in request_headers: + if "Date" in context.request_headers: return _LOGGER.warning(msg="The date header is not given.") @@ -34,38 +36,37 @@ def validate_date_header_given(*, request_headers: Mapping[str, str]) -> None: @beartype -def validate_date_format(*, request_headers: Mapping[str, str]) -> None: +def validate_date_format(*, context: ValidatorContext) -> None: """Validate the format of the date header given to a VWS endpoint. Args: - request_headers: The headers sent with the request. + context: The context of the request. Raises: FailError: The date is in the wrong format. """ - date_header = request_headers["Date"] - date_format = "%a, %d %b %Y %H:%M:%S GMT" + date_header = context.request_headers["Date"] try: - datetime.datetime.strptime(date_header, date_format).astimezone() + datetime.datetime.strptime(date_header, _DATE_FORMAT).astimezone() except ValueError as exc: _LOGGER.warning(msg="The date header is in the wrong format.") raise FailError(status_code=HTTPStatus.BAD_REQUEST) from exc @beartype -def validate_date_in_range(*, request_headers: Mapping[str, str]) -> None: +def validate_date_in_range(*, context: ValidatorContext) -> None: """Validate the date header given to a VWS endpoint is in range. Args: - request_headers: The headers sent with the request. + context: The context of the request. Raises: RequestTimeTooSkewedError: The date is out of range. """ gmt = ZoneInfo(key="GMT") date_from_header = datetime.datetime.strptime( - request_headers["Date"], - "%a, %d %b %Y %H:%M:%S GMT", + context.request_headers["Date"], + _DATE_FORMAT, ).replace(tzinfo=gmt) now = datetime.datetime.now(tz=gmt) diff --git a/src/mock_vws/_services_validators/image_validators.py b/src/mock_vws/_services_validators/image_validators.py index 1d693732c..b1e68ac4f 100644 --- a/src/mock_vws/_services_validators/image_validators.py +++ b/src/mock_vws/_services_validators/image_validators.py @@ -10,6 +10,7 @@ from mock_vws._base64_decoding import decode_base64 from mock_vws._image_opening import open_image +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import ( BadImageError, FailError, @@ -20,60 +21,106 @@ @beartype -def validate_image_integrity(*, request_body: bytes) -> None: - """Validate the integrity of the image given to a VWS endpoint. +def _decoded_image(*, context: ValidatorContext) -> bytes | None: + """Return the base64 decoded image given in the request body. + + Args: + context: The context of the request. + + Returns: + The decoded image data, or ``None`` if no image was given. The data + has already been checked to be a decodable string by + :py:func:`validate_image_data_type` and + :py:func:`validate_image_encoding`. + """ + image = json.loads(s=context.request_body.decode()).get("image") + if image is None: + return None + return decode_base64(encoded_data=image) + + +@beartype +def validate_image_data_type(*, context: ValidatorContext) -> None: + """Validate that the given image data is a string. Args: - request_body: The body of the request. + context: The context of the request. Raises: - BadImageError: The image is given and is not a valid image file. + FailError: Image data is given and it is not a string. """ - if not request_body: + request_json = json.loads(s=context.request_body.decode()) + if "image" not in request_json: return - request_text = request_body.decode() - image = json.loads(s=request_text).get("image") - if image is None: + image = request_json["image"] + + if isinstance(image, str): return - decoded = decode_base64(encoded_data=image) + _LOGGER.warning('Image data is not a string: "%s"', image) + raise FailError(status_code=HTTPStatus.BAD_REQUEST) - image_file = io.BytesIO(initial_bytes=decoded) - with open_image(fp=image_file) as pil_image: - try: - pil_image.verify() - except (OSError, SyntaxError) as exc: - # ``verify`` raises ``SyntaxError`` for a damaged header and - # ``OSError`` for damaged image data, such as a PNG which is - # truncated before its ``IEND`` chunk. - # ``open_image`` runs outside this ``try``, so anything which - # cannot be opened at all is already rejected by - # ``validate_image_is_image``. - _LOGGER.warning(msg="The image is not a valid image file.") - raise BadImageError from exc + +@beartype +def validate_image_encoding(*, context: ValidatorContext) -> None: + """Validate that the given image data can be base64 decoded. + + Args: + context: The context of the request. + + Raises: + FailError: Image data is given and it cannot be base64 decoded. + """ + request_json = json.loads(s=context.request_body.decode()) + if "image" not in request_json: + return + + try: + decode_base64(encoded_data=request_json["image"]) + except binascii.Error as exc: + _LOGGER.warning('Image data cannot be base64 decoded: "%s"', exc) + raise FailError(status_code=HTTPStatus.UNPROCESSABLE_ENTITY) from exc @beartype -def validate_image_format(*, request_body: bytes) -> None: - """Validate the format of the image given to a VWS endpoint. +def validate_image_is_image(*, context: ValidatorContext) -> None: + """Validate that the given image data is actually an image file. Args: - request_body: The body of the request. + context: The context of the request. Raises: - BadImageError: The image is given and is not either a PNG or a JPEG. + BadImageError: Image data is given and it is not an image file. """ - if not request_body: + decoded = _decoded_image(context=context) + if decoded is None: return - request_text = request_body.decode() - image = json.loads(s=request_text).get("image") + image_file = io.BytesIO(initial_bytes=decoded) - if image is None: + try: + with open_image(fp=image_file) as _: + pass + except OSError as exc: + _LOGGER.warning(msg="The image is not an image file.") + raise BadImageError from exc + + +@beartype +def validate_image_format(*, context: ValidatorContext) -> None: + """Validate the format of the image given to a VWS endpoint. + + Args: + context: The context of the request. + + Raises: + BadImageError: The image is given and is not either a PNG or a JPEG. + """ + decoded = _decoded_image(context=context) + if decoded is None: return - decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) with open_image(fp=image_file) as pil_image: if pil_image.format in {"PNG", "JPEG"}: @@ -84,26 +131,20 @@ def validate_image_format(*, request_body: bytes) -> None: @beartype -def validate_image_color_space(*, request_body: bytes) -> None: +def validate_image_color_space(*, context: ValidatorContext) -> None: """Validate the color space of the image given to a VWS endpoint. Args: - request_body: The body of the request. + context: The context of the request. Raises: BadImageError: The image is given and is not in either the RGB or greyscale color space. """ - if not request_body: + decoded = _decoded_image(context=context) + if decoded is None: return - request_text = request_body.decode() - image = json.loads(s=request_text).get("image") - - if image is None: - return - - decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) with open_image(fp=image_file) as pil_image: if pil_image.mode in {"L", "RGB"}: @@ -116,27 +157,20 @@ def validate_image_color_space(*, request_body: bytes) -> None: @beartype -def validate_image_size(*, request_body: bytes) -> None: +def validate_image_size(*, context: ValidatorContext) -> None: """Validate the file size of the image given to a VWS endpoint. Args: - request_body: The body of the request. + context: The context of the request. Raises: ImageTooLargeError: The image is given and is not under a certain file size threshold. """ - if not request_body: - return - - request_text = request_body.decode() - image = json.loads(s=request_text).get("image") - - if image is None: + decoded = _decoded_image(context=context) + if decoded is None: return - decoded = decode_base64(encoded_data=image) - max_allowed_size = 2_359_293 if len(decoded) <= max_allowed_size: return @@ -146,29 +180,23 @@ def validate_image_size(*, request_body: bytes) -> None: @beartype -def validate_image_pixel_count(*, request_body: bytes) -> None: +def validate_image_pixel_count(*, context: ValidatorContext) -> None: """Validate the number of pixels of the image given to a VWS endpoint. A small file can decode to a very large number of pixels, so this is not covered by the file size limit. Args: - request_body: The body of the request. + context: The context of the request. Raises: ImageTooLargeError: The image is given and it has more than the maximum number of pixels. """ - if not request_body: - return - - request_text = request_body.decode() - image = json.loads(s=request_text).get("image") - - if image is None: + decoded = _decoded_image(context=context) + if decoded is None: return - decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) # This limit is not documented. @@ -184,82 +212,29 @@ def validate_image_pixel_count(*, request_body: bytes) -> None: @beartype -def validate_image_is_image(*, request_body: bytes) -> None: - """Validate that the given image data is actually an image file. +def validate_image_integrity(*, context: ValidatorContext) -> None: + """Validate the integrity of the image given to a VWS endpoint. Args: - request_body: The body of the request. + context: The context of the request. Raises: - BadImageError: Image data is given and it is not an image file. + BadImageError: The image is given and is not a valid image file. """ - if not request_body: - return - - request_text = request_body.decode() - image = json.loads(s=request_text).get("image") - - if image is None: + decoded = _decoded_image(context=context) + if decoded is None: return - decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) - - try: - with open_image(fp=image_file) as _: - pass - except OSError as exc: - _LOGGER.warning(msg="The image is not an image file.") - raise BadImageError from exc - - -@beartype -def validate_image_encoding(*, request_body: bytes) -> None: - """Validate that the given image data can be base64 decoded. - - Args: - request_body: The body of the request. - - Raises: - FailError: Image data is given and it cannot be base64 decoded. - """ - if not request_body: - return - - request_text = request_body.decode() - if "image" not in json.loads(s=request_text): - return - - image = json.loads(s=request_text).get("image") - - try: - decode_base64(encoded_data=image) - except binascii.Error as exc: - _LOGGER.warning('Image data cannot be base64 decoded: "%s"', exc) - raise FailError(status_code=HTTPStatus.UNPROCESSABLE_ENTITY) from exc - - -@beartype -def validate_image_data_type(*, request_body: bytes) -> None: - """Validate that the given image data is a string. - - Args: - request_body: The body of the request. - - Raises: - FailError: Image data is given and it is not a string. - """ - if not request_body: - return - - request_text = request_body.decode() - if "image" not in json.loads(s=request_text): - return - - image = json.loads(s=request_text).get("image") - - if isinstance(image, str): - return - - _LOGGER.warning('Image data is not a string: "%s"', image) - raise FailError(status_code=HTTPStatus.BAD_REQUEST) + with open_image(fp=image_file) as pil_image: + try: + pil_image.verify() + except (OSError, SyntaxError) as exc: + # ``verify`` raises ``SyntaxError`` for a damaged header and + # ``OSError`` for damaged image data, such as a PNG which is + # truncated before its ``IEND`` chunk. + # ``open_image`` runs outside this ``try``, so anything which + # cannot be opened at all is already rejected by + # ``validate_image_is_image``. + _LOGGER.warning(msg="The image is not a valid image file.") + raise BadImageError from exc diff --git a/src/mock_vws/_services_validators/instance_id_validators.py b/src/mock_vws/_services_validators/instance_id_validators.py index 9001e5f28..42b1ea0e9 100644 --- a/src/mock_vws/_services_validators/instance_id_validators.py +++ b/src/mock_vws/_services_validators/instance_id_validators.py @@ -5,6 +5,7 @@ from beartype import beartype +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import ( BadRequestError, InvalidInstanceIdError, @@ -14,25 +15,18 @@ @beartype -def validate_instance_id_type(*, request_body: bytes) -> None: +def validate_instance_id_type(*, context: ValidatorContext) -> None: """Validate the type of the instance_id data given to the VuMark instance generation endpoint. Args: - request_body: The body of the request. + context: The context of the request. Raises: BadRequestError: There is instance_id data given to the endpoint which is not a string. """ - if not request_body: - return - - request_text = request_body.decode() - if "instance_id" not in json.loads(s=request_text): - return - - instance_id = json.loads(s=request_text)["instance_id"] + instance_id = json.loads(s=context.request_body.decode())["instance_id"] if isinstance(instance_id, str): return @@ -44,25 +38,18 @@ def validate_instance_id_type(*, request_body: bytes) -> None: @beartype -def validate_instance_id_not_empty(*, request_body: bytes) -> None: +def validate_instance_id_not_empty(*, context: ValidatorContext) -> None: """Validate that the instance_id data given to the VuMark instance generation endpoint is not empty. Args: - request_body: The body of the request. + context: The context of the request. Raises: InvalidInstanceIdError: There is instance_id data given to the endpoint which is an empty string. """ - if not request_body: - return - - request_text = request_body.decode() - if "instance_id" not in json.loads(s=request_text): - return - - instance_id = json.loads(s=request_text)["instance_id"] + instance_id = json.loads(s=context.request_body.decode())["instance_id"] if instance_id: return diff --git a/src/mock_vws/_services_validators/json_validators.py b/src/mock_vws/_services_validators/json_validators.py index 5477eb52e..1afb7c385 100644 --- a/src/mock_vws/_services_validators/json_validators.py +++ b/src/mock_vws/_services_validators/json_validators.py @@ -2,77 +2,101 @@ import json import logging -from http import HTTPMethod, HTTPStatus +from collections.abc import Callable +from http import HTTPStatus from json.decoder import JSONDecodeError from beartype import beartype +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import ( BadRequestError, FailError, UnnecessaryRequestBodyError, + ValidatorError, ) _LOGGER = logging.getLogger(name=__name__) @beartype -def validate_body_given(*, request_body: bytes, request_method: str) -> None: - """Validate that no JSON is given for requests other than ``POST`` and - ``PUT`` requests. +def validate_no_body_given(*, context: ValidatorContext) -> None: + """Validate that no body is given to an endpoint which does not take + one. Args: - request_body: The body of the request. - request_method: The HTTP method of the request. + context: The context of the request. Raises: - UnnecessaryRequestBodyError: A request body was given for an endpoint - which does not require one. - FailError: The request body includes invalid JSON. + UnnecessaryRequestBodyError: A request body was given. """ - if not request_body: + if not context.request_body: return - if request_method not in {HTTPMethod.POST, HTTPMethod.PUT}: - _LOGGER.warning( - msg=( - "A request body was given for an endpoint which does not " - "require one." - ), - ) - raise UnnecessaryRequestBodyError + _LOGGER.warning( + msg=( + "A request body was given for an endpoint which does not " + "require one." + ), + ) + raise UnnecessaryRequestBodyError @beartype -def validate_json(*, request_body: bytes, request_path: str) -> None: - """Validate that any given body is valid JSON. +def _validate_json( + *, + context: ValidatorContext, + make_error: Callable[[], ValidatorError], +) -> None: + """Validate that the given body is a JSON object. Args: - request_body: The body of the request. - request_path: The path of the request. + context: The context of the request. + make_error: Create the error to raise if the body is not a JSON + object. Raises: - BadRequestError: The request body is not valid UTF-8, or includes - invalid JSON, for the VuMark instance generation endpoint. - FailError: The request body is not valid UTF-8, or includes invalid - JSON, for other endpoints. + ValidatorError: The request body is not valid UTF-8, or is not a JSON + object. """ - if not request_body: - return - try: # Vuforia gives the same response for a body which is not UTF-8, such # as JSON encoded as latin-1, as it gives for a body which is not # valid JSON. - request_json = json.loads(s=request_body.decode(encoding="utf-8")) + request_json = json.loads( + s=context.request_body.decode(encoding="utf-8"), + ) except (JSONDecodeError, UnicodeDecodeError) as exc: _LOGGER.warning(msg="The request body is not valid JSON.") - if request_path.endswith("/instances"): - raise BadRequestError from exc - raise FailError(status_code=HTTPStatus.BAD_REQUEST) from exc + raise make_error() from exc if not isinstance(request_json, dict): _LOGGER.warning(msg="The request body is not a JSON object.") - if request_path.endswith("/instances"): - raise BadRequestError - raise FailError(status_code=HTTPStatus.BAD_REQUEST) + raise make_error() + + +@beartype +def validate_json(*, context: ValidatorContext) -> None: + """Validate that the given body is a JSON object. + + Args: + context: The context of the request. + """ + _validate_json( + context=context, + make_error=lambda: FailError(status_code=HTTPStatus.BAD_REQUEST), + ) + + +@beartype +def validate_vumark_instance_json(*, context: ValidatorContext) -> None: + """Validate that the given body is a JSON object, for the VuMark + instance generation endpoint. + + That endpoint gives a different error from every other VWS endpoint for + a body which is not a JSON object. + + Args: + context: The context of the request. + """ + _validate_json(context=context, make_error=BadRequestError) diff --git a/src/mock_vws/_services_validators/key_validators.py b/src/mock_vws/_services_validators/key_validators.py index b198d6aa7..84b7adff4 100644 --- a/src/mock_vws/_services_validators/key_validators.py +++ b/src/mock_vws/_services_validators/key_validators.py @@ -2,14 +2,11 @@ import json import logging -import re -from collections.abc import Iterable -from dataclasses import dataclass -from http import HTTPMethod, HTTPStatus +from http import HTTPStatus from beartype import beartype -from mock_vws._mock_common import RECO_COUNTS_REPORT_PATH_PATTERN +from mock_vws._services_validators.context import ValidatorContext from .exceptions import FailError @@ -17,155 +14,21 @@ @beartype -@dataclass(frozen=True, kw_only=True) -class _Route: - """A representation of a VWS route. - - Args: - path_pattern: The end part of a URL pattern. E.g. `/targets` or - `/targets/.+`. - http_methods: HTTP methods that map to the route function. - mandatory_keys: Keys required by the endpoint. - optional_keys: Keys which are not required by the endpoint but which - are allowed. - """ - - path_pattern: str - http_methods: Iterable[HTTPMethod] - mandatory_keys: Iterable[str] - optional_keys: Iterable[str] - - -@beartype -def validate_keys( - *, - request_body: bytes, - request_path: str, - request_method: str, -) -> None: +def validate_keys(*, context: ValidatorContext) -> None: """Validate the request keys given to a VWS endpoint. Args: - request_body: The body of the request. - request_path: The path of the request. - request_method: The HTTP method of the request. + context: The context of the request. Raises: FailError: Any given keys are not allowed, or if any required keys are missing. """ - target_id_pattern = "[A-Za-z0-9]+" - add_target = _Route( - path_pattern="/targets", - http_methods={HTTPMethod.POST}, - mandatory_keys={"image", "width", "name"}, - optional_keys={"active_flag", "application_metadata"}, - ) - - delete_target = _Route( - path_pattern=f"/targets/{target_id_pattern}", - http_methods={HTTPMethod.DELETE}, - mandatory_keys=set(), - optional_keys=set(), - ) - - database_summary = _Route( - path_pattern="/summary", - http_methods={HTTPMethod.GET}, - mandatory_keys=set(), - optional_keys=set(), - ) - - target_list = _Route( - path_pattern="/targets", - http_methods={HTTPMethod.GET}, - mandatory_keys=set(), - optional_keys=set(), - ) - - get_target = _Route( - path_pattern=f"/targets/{target_id_pattern}", - http_methods={HTTPMethod.GET}, - mandatory_keys=set(), - optional_keys=set(), - ) - - target_summary = _Route( - path_pattern=f"/summary/{target_id_pattern}", - http_methods={HTTPMethod.GET}, - mandatory_keys=set(), - optional_keys=set(), - ) - - get_duplicates = _Route( - path_pattern=f"/duplicates/{target_id_pattern}", - http_methods={HTTPMethod.GET}, - mandatory_keys=set(), - optional_keys=set(), - ) - - update_target = _Route( - path_pattern=f"/targets/{target_id_pattern}", - http_methods={HTTPMethod.PUT}, - mandatory_keys=set(), - optional_keys={ - "active_flag", - "application_metadata", - "image", - "name", - "width", - }, - ) - - generate_instance = _Route( - path_pattern=f"/targets/{target_id_pattern}/instances", - http_methods={HTTPMethod.POST}, - mandatory_keys={"instance_id"}, - optional_keys=set(), - ) - - reco_counts_report = _Route( - path_pattern=RECO_COUNTS_REPORT_PATH_PATTERN, - http_methods={HTTPMethod.POST}, - mandatory_keys={"month"}, - optional_keys=set(), - ) - - routes = ( - add_target, - reco_counts_report, - delete_target, - database_summary, - target_list, - get_target, - get_duplicates, - update_target, - generate_instance, - target_summary, - ) - - (matching_route,) = ( - route - for route in routes - if re.match( - pattern=re.compile(pattern=f"{route.path_pattern}$"), - string=request_path, - ) - and request_method in set(route.http_methods) - ) - - mandatory_keys = matching_route.mandatory_keys - optional_keys = matching_route.optional_keys - allowed_keys = {*mandatory_keys, *optional_keys} - - if not request_body and not allowed_keys: - return - - request_text = request_body.decode() - request_json = json.loads(s=request_text) + allowed_keys = context.mandatory_keys | context.optional_keys + request_json = json.loads(s=context.request_body.decode()) given_keys = set(request_json.keys()) all_given_keys_allowed = given_keys.issubset(allowed_keys) - all_mandatory_keys_given = set(mandatory_keys).issubset(set(given_keys)) + all_mandatory_keys_given = context.mandatory_keys.issubset(given_keys) if all_given_keys_allowed and all_mandatory_keys_given: return diff --git a/src/mock_vws/_services_validators/metadata_validators.py b/src/mock_vws/_services_validators/metadata_validators.py index 0695de3db..ce9d42c69 100644 --- a/src/mock_vws/_services_validators/metadata_validators.py +++ b/src/mock_vws/_services_validators/metadata_validators.py @@ -8,6 +8,7 @@ from beartype import beartype from mock_vws._base64_decoding import decode_base64 +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import ( FailError, MetadataTooLargeError, @@ -17,23 +18,19 @@ @beartype -def validate_metadata_size(*, request_body: bytes) -> None: +def validate_metadata_size(*, context: ValidatorContext) -> None: """Validate that the given application metadata is a string or 1024 * 1024 bytes or fewer. Args: - request_body: The body of the request. + context: The context of the request. Raises: MetadataTooLargeError: Application metadata is given and it is too large. """ - if not request_body: - return - - request_text = request_body.decode() - request_json = json.loads(s=request_text) + request_json = json.loads(s=context.request_body.decode()) application_metadata = request_json.get("application_metadata") if application_metadata is None: return @@ -48,24 +45,17 @@ def validate_metadata_size(*, request_body: bytes) -> None: @beartype -def validate_metadata_encoding(*, request_body: bytes) -> None: +def validate_metadata_encoding(*, context: ValidatorContext) -> None: """Validate that the given application metadata can be base64 decoded. Args: - request_body: The body of the request. + context: The context of the request. Raises: FailError: Application metadata is given and it cannot be base64 decoded. """ - if not request_body: - return - - request_text = request_body.decode() - request_json = json.loads(s=request_text) - if "application_metadata" not in request_json: - return - + request_json = json.loads(s=context.request_body.decode()) application_metadata = request_json.get("application_metadata") if application_metadata is None: @@ -79,25 +69,21 @@ def validate_metadata_encoding(*, request_body: bytes) -> None: @beartype -def validate_metadata_type(*, request_body: bytes) -> None: +def validate_metadata_type(*, context: ValidatorContext) -> None: """Validate that the given application metadata is a string or NULL. Args: - request_body: The body of the request. + context: The context of the request. Raises: FailError: Application metadata is given and it is not a string or NULL. """ - if not request_body: - return - - request_text = request_body.decode() - request_json = json.loads(s=request_text) + request_json = json.loads(s=context.request_body.decode()) if "application_metadata" not in request_json: return - application_metadata = request_json.get("application_metadata") + application_metadata = request_json["application_metadata"] if application_metadata is None or isinstance(application_metadata, str): return diff --git a/src/mock_vws/_services_validators/name_validators.py b/src/mock_vws/_services_validators/name_validators.py index 0b9a06ea5..377574f2f 100644 --- a/src/mock_vws/_services_validators/name_validators.py +++ b/src/mock_vws/_services_validators/name_validators.py @@ -2,81 +2,150 @@ import json import logging -from http import HTTPMethod, HTTPStatus +from http import HTTPStatus from beartype import beartype -from mock_vws._database_matchers import AnyDatabase +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import ( FailError, TargetNameExistError, ) +from mock_vws._services_validators.target_validators import ( + target_id_from_path, +) +from mock_vws.target import ImageTarget, VuMarkTarget _LOGGER = logging.getLogger(name=__name__) +_MAX_CHARACTER_ORD = 65535 + + +@beartype +def _given_name(*, context: ValidatorContext) -> str | None: + """Return the name given in the request body. + + Args: + context: The context of the request. + + Returns: + The value of the ``name`` field, or ``None`` if no name was given. + The value has already been checked to be a string by + :py:func:`validate_name_type`. + """ + request_json = json.loads(s=context.request_body.decode()) + name: str | None = request_json.get("name") + return name + @beartype -def validate_name_characters_in_range( +def _name_characters_in_range(*, name: str) -> bool: + """Whether every character in a name is in the range Vuforia accepts. + + Args: + name: The name given in the request body. + + Returns: + Whether every character in the name is in range. + """ + return all(ord(character) <= _MAX_CHARACTER_ORD for character in name) + + +@beartype +def _new_target_name(*, context: ValidatorContext) -> str: + """Return the name given when adding a target. + + Args: + context: The context of the request. + + Returns: + The value of the ``name`` field. ``name`` is a mandatory key on the + add target endpoint, so :py:func:`validate_keys` has already rejected + a request which does not give one, and :py:func:`validate_name_type` + has already rejected one which is not a string. + """ + name: str = json.loads(s=context.request_body.decode())["name"] + return name + + +@beartype +def _targets_with_name( + *, + context: ValidatorContext, + name: str, +) -> list[ImageTarget | VuMarkTarget]: + """Return the targets in the database which have the given name. + + Args: + context: The context of the request. + name: The name to look for. + + Returns: + Every target which is not deleted and which has the given name. + """ + return [ + target + for target in context.database.not_deleted_targets + if target.name == name + ] + + +@beartype +def validate_new_target_name_characters_in_range( *, - request_body: bytes, - request_method: str, - request_path: str, + context: ValidatorContext, ) -> None: - """Validate the characters in the name argument given to a VWS - endpoint. + """Validate the characters in the name given when adding a target. Args: - request_body: The body of the request. - request_method: The HTTP method the request is using. - request_path: The path to the endpoint. + context: The context of the request. Raises: - FailError: Characters are out of range and the request is trying to - make a new target. - TargetNameExistError: Characters are out of range and the request is - for another endpoint. + FailError: Characters are out of range. """ - if not request_body: + if _name_characters_in_range(name=_new_target_name(context=context)): return - request_text = request_body.decode() - if "name" not in json.loads(s=request_text): - return + _LOGGER.warning(msg="Characters are out of range.") + raise FailError(status_code=HTTPStatus.INTERNAL_SERVER_ERROR) - name = json.loads(s=request_text)["name"] - max_character_ord = 65535 - if all(ord(character) <= max_character_ord for character in name): - return +@beartype +def validate_existing_target_name_characters_in_range( + *, + context: ValidatorContext, +) -> None: + """Validate the characters in the name given when updating a target. + + Args: + context: The context of the request. - if (request_method, request_path) == (HTTPMethod.POST, "/targets"): - _LOGGER.warning(msg="Characters are out of range.") - raise FailError(status_code=HTTPStatus.INTERNAL_SERVER_ERROR) + Raises: + TargetNameExistError: Characters are out of range. + """ + name = _given_name(context=context) + if name is None or _name_characters_in_range(name=name): + return _LOGGER.warning(msg="Characters are out of range.") raise TargetNameExistError @beartype -def validate_name_type(*, request_body: bytes) -> None: +def validate_name_type(*, context: ValidatorContext) -> None: """Validate the type of the name argument given to a VWS endpoint. Args: - request_body: The body of the request. + context: The context of the request. Raises: FailError: A name is given and it is not a string. """ - if not request_body: + request_json = json.loads(s=context.request_body.decode()) + if "name" not in request_json: return - request_text = request_body.decode() - if "name" not in json.loads(s=request_text): - return - - name = json.loads(s=request_text)["name"] - - if isinstance(name, str): + if isinstance(request_json["name"], str): return _LOGGER.warning(msg="Name is not a string.") @@ -84,25 +153,20 @@ def validate_name_type(*, request_body: bytes) -> None: @beartype -def validate_name_length(*, request_body: bytes) -> None: +def validate_name_length(*, context: ValidatorContext) -> None: """Validate the length of the name argument given to a VWS endpoint. Args: - request_body: The body of the request. + context: The context of the request. Raises: FailError: A name is given and it is not a between 1 and 64 characters in length. """ - if not request_body: - return - - request_text = request_body.decode() - if "name" not in json.loads(s=request_text): + name = _given_name(context=context) + if name is None: return - name = json.loads(s=request_text)["name"] - max_length = 64 if name and len(name) <= max_length: return @@ -114,42 +178,18 @@ def validate_name_length(*, request_body: bytes) -> None: @beartype def validate_name_does_not_exist_new_target( *, - database: AnyDatabase, - request_body: bytes, - request_path: str, + context: ValidatorContext, ) -> None: """Validate that the name does not exist for any existing target. Args: - database: The database which the request's server keys belong to. - request_body: The body of the request. - request_path: The path to the endpoint. + context: The context of the request. Raises: TargetNameExistError: The target name already exists. """ - if not request_body: - return - - request_text = request_body.decode() - if "name" not in json.loads(s=request_text): - return - - split_path = request_path.split(sep="/") - - split_path_no_target_id_length = 2 - if len(split_path) != split_path_no_target_id_length: - return - - name = json.loads(s=request_text)["name"] - - matching_name_targets = [ - target - for target in database.not_deleted_targets - if target.name == name - ] - - if not matching_name_targets: + name = _new_target_name(context=context) + if not _targets_with_name(context=context, name=name): return _LOGGER.warning(msg="Target name already exists.") @@ -159,50 +199,31 @@ def validate_name_does_not_exist_new_target( @beartype def validate_name_does_not_exist_existing_target( *, - request_body: bytes, - request_path: str, - database: AnyDatabase, + context: ValidatorContext, ) -> None: """Validate that the name does not exist for any existing target apart from the one being updated. Args: - database: The database which the request's server keys belong to. - request_body: The body of the request. - request_path: The path to the endpoint. + context: The context of the request. Raises: TargetNameExistError: The target name is not the same as the name of the target being updated but it is the same as another target. """ - if not request_body: - return - - request_text = request_body.decode() - if "name" not in json.loads(s=request_text): - return - - split_path = request_path.split(sep="/") - split_path_no_target_id_length = 2 - if len(split_path) == split_path_no_target_id_length: + name = _given_name(context=context) + if name is None: return - target_id = split_path[-1] - - name = json.loads(s=request_text)["name"] - - matching_name_targets = [ - target - for target in database.not_deleted_targets - if target.name == name - ] - + matching_name_targets = _targets_with_name(context=context, name=name) if not matching_name_targets: return (matching_name_target,) = matching_name_targets - if matching_name_target.target_id == target_id: + if matching_name_target.target_id == target_id_from_path( + request_path=context.request_path, + ): return _LOGGER.warning(msg="Name already exists for another target.") diff --git a/src/mock_vws/_services_validators/project_state_validators.py b/src/mock_vws/_services_validators/project_state_validators.py index 884310db0..b8cc8484e 100644 --- a/src/mock_vws/_services_validators/project_state_validators.py +++ b/src/mock_vws/_services_validators/project_state_validators.py @@ -1,11 +1,10 @@ """Validators for the project state.""" import logging -from http import HTTPMethod from beartype import beartype -from mock_vws._database_matchers import AnyDatabase +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import ( ProjectHasNoApiAccessError, ProjectInactiveError, @@ -19,18 +18,11 @@ @beartype -def validate_project_state( - *, - request_path: str, - request_method: str, - database: AnyDatabase, -) -> None: +def validate_project_state(*, context: ValidatorContext) -> None: """Validate the state of the project. Args: - request_path: The path of the request. - request_method: The HTTP method of the request. - database: The database which the request's server keys belong to. + context: The context of the request. Raises: ProjectInactiveError: The project is inactive and this endpoint does @@ -40,20 +32,19 @@ def validate_project_state( States.PROJECT_HAS_NO_API_ACCESS: ProjectHasNoApiAccessError, States.PROJECT_SUSPENDED: ProjectSuspendedError, } - if error := state_errors.get(database.state): + if error := state_errors.get(context.database.state): raise error - if database.state != States.PROJECT_INACTIVE: + if context.database.state != States.PROJECT_INACTIVE: return if ( - isinstance(database, CloudDatabase) - and request_method == HTTPMethod.GET - and "duplicates" not in request_path + isinstance(context.database, CloudDatabase) + and context.allowed_for_inactive_cloud_project ): return - if isinstance(database, VuMarkDatabase): + if isinstance(context.database, VuMarkDatabase): return _LOGGER.warning(msg="The project is inactive.") diff --git a/src/mock_vws/_services_validators/request_quota_validators.py b/src/mock_vws/_services_validators/request_quota_validators.py index fb76e77d8..ad8af850f 100644 --- a/src/mock_vws/_services_validators/request_quota_validators.py +++ b/src/mock_vws/_services_validators/request_quota_validators.py @@ -8,16 +8,25 @@ from beartype import beartype -from mock_vws._database_matchers import AnyDatabase from mock_vws.database import CloudDatabase +from .context import ValidatorContext from .exceptions import RequestQuotaReachedError @beartype -def validate_request_quota(*, database: AnyDatabase) -> None: +def validate_request_quota(*, context: ValidatorContext) -> None: """Raise an error if the matching cloud database has no request quota. + + Args: + context: The context of the request. + + Raises: + RequestQuotaReachedError: The database's request quota is exhausted. """ - if isinstance(database, CloudDatabase) and database.request_quota == 0: + if ( + isinstance(context.database, CloudDatabase) + and context.database.request_quota == 0 + ): raise RequestQuotaReachedError diff --git a/src/mock_vws/_services_validators/request_rate_limiter.py b/src/mock_vws/_services_validators/request_rate_limiter.py new file mode 100644 index 000000000..5356d1d7e --- /dev/null +++ b/src/mock_vws/_services_validators/request_rate_limiter.py @@ -0,0 +1,100 @@ +"""A tracker of recent VWS requests, used to apply request rate limits.""" + +import threading +from collections import deque +from collections.abc import Callable + +from beartype import beartype + +from mock_vws.database import CloudDatabase +from mock_vws.request_rate_limits import ( + RateLimitedEndpoint, + RequestRateLimit, +) + +from .exceptions import TooManyRequestsError + +_WINDOW_SECONDS = 1.0 + + +@beartype +class RequestRateLimiter: + """Track request times independently for each cloud database.""" + + def __init__( + self, + *, + time_function: Callable[[], float], + ) -> None: + """Initialize an empty rate limiter.""" + self._request_times: dict[tuple[str, str], deque[float]] = {} + self._lock = threading.Lock() + self._time_function = time_function + + def validate( + self, + *, + database: CloudDatabase, + endpoint: RateLimitedEndpoint, + ) -> None: + """Raise an error if a rate limit for the request is exhausted. + + Args: + database: The database which the request is made against. + endpoint: The endpoint group which the request belongs to. + + Raises: + TooManyRequestsError: A limit which applies to the request has + been reached. + """ + # The ``requests_per_second_limit`` setting applies to every VWS + # request made against the database, no matter which endpoint is + # used, and so it has a bucket of its own. + buckets: list[tuple[str, RequestRateLimit]] = [] + if database.requests_per_second_limit is not None: + buckets.append( + ( + "ALL_ENDPOINTS", + RequestRateLimit( + max_requests=database.requests_per_second_limit, + window_seconds=_WINDOW_SECONDS, + ), + ) + ) + + if database.request_rate_limits is not None: + endpoint_limit = database.request_rate_limits.for_endpoint( + endpoint=endpoint, + ) + if endpoint_limit is not None: + (limit_endpoint, limit) = endpoint_limit + buckets.append((limit_endpoint.name, limit)) + + with self._lock: + now = self._time_function() + request_times_for_buckets: list[deque[float]] = [] + for bucket_name, limit in buckets: + request_times = self._request_times.setdefault( + (database.server_access_key, bucket_name), + deque(), + ) + window_start = now - limit.window_seconds + while request_times and request_times[0] <= window_start: + request_times.popleft() + + if len(request_times) >= limit.max_requests: + raise TooManyRequestsError + + request_times_for_buckets.append(request_times) + + for request_times in request_times_for_buckets: + request_times.append(now) + + def remove_database(self, *, database: CloudDatabase) -> None: + """Discard request history for a removed database.""" + with self._lock: + self._request_times = { + key: value + for key, value in self._request_times.items() + if key[0] != database.server_access_key + } diff --git a/src/mock_vws/_services_validators/request_rate_validators.py b/src/mock_vws/_services_validators/request_rate_validators.py index ce73a622d..9c34d6e3a 100644 --- a/src/mock_vws/_services_validators/request_rate_validators.py +++ b/src/mock_vws/_services_validators/request_rate_validators.py @@ -1,143 +1,22 @@ """Validators for the VWS request rates.""" -import re -import threading -from collections import deque -from collections.abc import Callable -from http import HTTPMethod - from beartype import beartype -from mock_vws._database_matchers import AnyDatabase from mock_vws.database import CloudDatabase -from mock_vws.request_rate_limits import ( - RateLimitedEndpoint, - RequestRateLimit, -) - -from .exceptions import TooManyRequestsError - -_WINDOW_SECONDS = 1.0 - -_GET_TARGET_PATH_PATTERN = re.compile(pattern=r"^/targets/[^/]+$") -_GET_DUPLICATES_PATH_PATTERN = re.compile(pattern=r"^/duplicates/[^/]+$") - - -@beartype -def _rate_limited_endpoint( - *, - request_method: str, - request_path: str, -) -> RateLimitedEndpoint: - """Return the endpoint group which a request belongs to.""" - path = request_path.split(sep="?", maxsplit=1)[0] - if request_method == HTTPMethod.GET: - if path == "/targets": - return RateLimitedEndpoint.LIST_TARGETS - if _GET_TARGET_PATH_PATTERN.fullmatch(string=path): - return RateLimitedEndpoint.GET_TARGET - if _GET_DUPLICATES_PATH_PATTERN.fullmatch(string=path): - return RateLimitedEndpoint.GET_DUPLICATES - return RateLimitedEndpoint.OTHER - - -@beartype -class RequestRateLimiter: - """Track request times independently for each cloud database.""" - def __init__( - self, - *, - time_function: Callable[[], float], - ) -> None: - """Initialize an empty rate limiter.""" - self._request_times: dict[tuple[str, str], deque[float]] = {} - self._lock = threading.Lock() - self._time_function = time_function - - def validate( - self, - *, - database: CloudDatabase, - endpoint: RateLimitedEndpoint, - ) -> None: - """Raise an error if a rate limit for the request is exhausted. - - Args: - database: The database which the request is made against. - endpoint: The endpoint group which the request belongs to. - - Raises: - TooManyRequestsError: A limit which applies to the request has - been reached. - """ - # The ``requests_per_second_limit`` setting applies to every VWS - # request made against the database, no matter which endpoint is - # used, and so it has a bucket of its own. - buckets: list[tuple[str, RequestRateLimit]] = [] - if database.requests_per_second_limit is not None: - buckets.append( - ( - "ALL_ENDPOINTS", - RequestRateLimit( - max_requests=database.requests_per_second_limit, - window_seconds=_WINDOW_SECONDS, - ), - ) - ) - - if database.request_rate_limits is not None: - endpoint_limit = database.request_rate_limits.for_endpoint( - endpoint=endpoint, - ) - if endpoint_limit is not None: - (limit_endpoint, limit) = endpoint_limit - buckets.append((limit_endpoint.name, limit)) - - with self._lock: - now = self._time_function() - request_times_for_buckets: list[deque[float]] = [] - for bucket_name, limit in buckets: - request_times = self._request_times.setdefault( - (database.server_access_key, bucket_name), - deque(), - ) - window_start = now - limit.window_seconds - while request_times and request_times[0] <= window_start: - request_times.popleft() - - if len(request_times) >= limit.max_requests: - raise TooManyRequestsError - - request_times_for_buckets.append(request_times) - - for request_times in request_times_for_buckets: - request_times.append(now) - - def remove_database(self, *, database: CloudDatabase) -> None: - """Discard request history for a removed database.""" - with self._lock: - self._request_times = { - key: value - for key, value in self._request_times.items() - if key[0] != database.server_access_key - } +from .context import ValidatorContext @beartype -def validate_request_rate( - *, - request_method: str, - request_path: str, - database: AnyDatabase, - request_rate_limiter: RequestRateLimiter, -) -> None: +def validate_request_rate(*, context: ValidatorContext) -> None: """Apply the configured request rates to the matching cloud database. + + Args: + context: The context of the request. """ - if isinstance(database, CloudDatabase): - endpoint = _rate_limited_endpoint( - request_method=request_method, - request_path=request_path, + if isinstance(context.database, CloudDatabase): + context.request_rate_limiter.validate( + database=context.database, + endpoint=context.rate_limited_endpoint, ) - request_rate_limiter.validate(database=database, endpoint=endpoint) diff --git a/src/mock_vws/_services_validators/routes.py b/src/mock_vws/_services_validators/routes.py new file mode 100644 index 000000000..99a21fcdd --- /dev/null +++ b/src/mock_vws/_services_validators/routes.py @@ -0,0 +1,390 @@ +"""The VWS routes, and the validators which apply to each of them. + +Vuforia reports one problem with a request even when the request has more +than one. Which problem it reports is decided by the order of the validators +in each route's chain, so that order is the mock's record of Vuforia's error +precedence, verified against the real service. +""" + +import re +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from http import HTTPMethod + +from beartype import beartype + +from mock_vws._mock_common import RECO_COUNTS_REPORT_PATH_PATTERN +from mock_vws.request_rate_limits import RateLimitedEndpoint + +from .active_flag_validators import validate_active_flag +from .content_length_validators import ( + validate_content_length_header_is_int, + validate_content_length_header_not_too_large, + validate_content_length_header_not_too_small, +) +from .content_type_validators import validate_content_type_header_given +from .database_id_validators import validate_database_id_matches_keys +from .date_validators import ( + validate_date_format, + validate_date_header_given, + validate_date_in_range, +) +from .image_validators import ( + validate_image_color_space, + validate_image_data_type, + validate_image_encoding, + validate_image_format, + validate_image_integrity, + validate_image_is_image, + validate_image_pixel_count, + validate_image_size, +) +from .instance_id_validators import ( + validate_instance_id_not_empty, + validate_instance_id_type, +) +from .json_validators import ( + validate_json, + validate_no_body_given, + validate_vumark_instance_json, +) +from .key_validators import validate_keys +from .metadata_validators import ( + validate_metadata_encoding, + validate_metadata_size, + validate_metadata_type, +) +from .name_validators import ( + validate_existing_target_name_characters_in_range, + validate_name_does_not_exist_existing_target, + validate_name_does_not_exist_new_target, + validate_name_length, + validate_name_type, + validate_new_target_name_characters_in_range, +) +from .project_state_validators import validate_project_state +from .request_quota_validators import validate_request_quota +from .request_rate_validators import validate_request_rate +from .target_quota_validators import validate_target_quota +from .target_validators import validate_target_id_exists +from .width_validators import validate_width + +# The Flask app routes a target ID of any characters other than a slash, so +# the route table has to match the same paths that it does. The +# ``requests``-based mock serves only alphanumeric target IDs, and gives its +# own unrouted response for anything else. +_TARGET_ID_PATTERN = "[^/]+" + + +# A check which a request must pass before an endpoint runs. Every validator +# takes a +# :py:class:`~mock_vws._services_validators.context.ValidatorContext` as its +# ``context`` keyword argument, and raises a ``ValidatorError`` if the request +# is not valid. +type Validator = Callable[..., None] + + +@beartype +@dataclass(frozen=True, kw_only=True) +class Route: + """A VWS route, and everything the validators need to know about it. + + Args: + path_pattern: A pattern which matches the path of the route, and only + the path of the route. + http_method: The HTTP method of the route. + mandatory_keys: Keys required in the request body. + optional_keys: Keys which are not required in the request body but + which are allowed. + rate_limited_endpoint: The group of endpoints which the route shares a + request rate limit with. + allowed_for_inactive_cloud_project: Whether the route works against an + inactive cloud database. + validators: The validators which apply to the route, in the order in + which they apply. + + Attributes: + path_pattern: A pattern which matches the path of the route, and only + the path of the route. + http_method: The HTTP method of the route. + mandatory_keys: Keys required in the request body. + optional_keys: Keys which are not required in the request body but + which are allowed. + rate_limited_endpoint: The group of endpoints which the route shares a + request rate limit with. + allowed_for_inactive_cloud_project: Whether the route works against an + inactive cloud database. + validators: The validators which apply to the route, in the order in + which they apply. + """ + + path_pattern: str + http_method: HTTPMethod + mandatory_keys: frozenset[str] + optional_keys: frozenset[str] + rate_limited_endpoint: RateLimitedEndpoint + allowed_for_inactive_cloud_project: bool + validators: Sequence[Validator] + + +# Every route is quota checked, rate limited and refused when the project is +# in a state which does not allow it. +_PROJECT_VALIDATORS: Sequence[Validator] = ( + validate_request_quota, + validate_request_rate, + validate_project_state, +) + +_DATE_HEADER_VALIDATORS: Sequence[Validator] = ( + validate_date_header_given, + validate_date_format, + validate_date_in_range, +) + +_CONTENT_LENGTH_HEADER_VALIDATORS: Sequence[Validator] = ( + validate_content_length_header_is_int, + validate_content_length_header_not_too_large, + validate_content_length_header_not_too_small, +) + +_METADATA_VALIDATORS: Sequence[Validator] = ( + validate_metadata_type, + validate_metadata_encoding, + validate_metadata_size, +) + +_IMAGE_VALIDATORS: Sequence[Validator] = ( + validate_image_data_type, + validate_image_encoding, + validate_image_is_image, + validate_image_format, + validate_image_color_space, + validate_image_size, + validate_image_pixel_count, + validate_image_integrity, +) + +_ADD_TARGET = Route( + path_pattern="/targets", + http_method=HTTPMethod.POST, + mandatory_keys=frozenset({"image", "width", "name"}), + optional_keys=frozenset({"active_flag", "application_metadata"}), + rate_limited_endpoint=RateLimitedEndpoint.OTHER, + allowed_for_inactive_cloud_project=False, + validators=( + *_PROJECT_VALIDATORS, + validate_target_quota, + *_DATE_HEADER_VALIDATORS, + validate_json, + validate_keys, + *_METADATA_VALIDATORS, + validate_active_flag, + *_IMAGE_VALIDATORS, + validate_name_type, + validate_name_length, + validate_new_target_name_characters_in_range, + validate_name_does_not_exist_new_target, + validate_width, + validate_content_type_header_given, + *_CONTENT_LENGTH_HEADER_VALIDATORS, + ), +) + +_UPDATE_TARGET = Route( + path_pattern=f"/targets/{_TARGET_ID_PATTERN}", + http_method=HTTPMethod.PUT, + mandatory_keys=frozenset(), + optional_keys=frozenset( + { + "active_flag", + "application_metadata", + "image", + "name", + "width", + } + ), + rate_limited_endpoint=RateLimitedEndpoint.OTHER, + allowed_for_inactive_cloud_project=False, + validators=( + *_PROJECT_VALIDATORS, + validate_target_id_exists, + *_DATE_HEADER_VALIDATORS, + validate_json, + validate_keys, + *_METADATA_VALIDATORS, + validate_active_flag, + *_IMAGE_VALIDATORS, + validate_name_type, + validate_name_length, + validate_existing_target_name_characters_in_range, + validate_name_does_not_exist_existing_target, + validate_width, + validate_content_type_header_given, + *_CONTENT_LENGTH_HEADER_VALIDATORS, + ), +) + +_DELETE_TARGET = Route( + path_pattern=f"/targets/{_TARGET_ID_PATTERN}", + http_method=HTTPMethod.DELETE, + mandatory_keys=frozenset(), + optional_keys=frozenset(), + rate_limited_endpoint=RateLimitedEndpoint.OTHER, + allowed_for_inactive_cloud_project=False, + validators=( + *_PROJECT_VALIDATORS, + validate_target_id_exists, + validate_no_body_given, + *_DATE_HEADER_VALIDATORS, + *_CONTENT_LENGTH_HEADER_VALIDATORS, + ), +) + +_DATABASE_SUMMARY = Route( + path_pattern="/summary", + http_method=HTTPMethod.GET, + mandatory_keys=frozenset(), + optional_keys=frozenset(), + rate_limited_endpoint=RateLimitedEndpoint.OTHER, + allowed_for_inactive_cloud_project=True, + validators=( + *_PROJECT_VALIDATORS, + validate_no_body_given, + *_DATE_HEADER_VALIDATORS, + *_CONTENT_LENGTH_HEADER_VALIDATORS, + ), +) + +_TARGET_LIST = Route( + path_pattern="/targets", + http_method=HTTPMethod.GET, + mandatory_keys=frozenset(), + optional_keys=frozenset(), + rate_limited_endpoint=RateLimitedEndpoint.LIST_TARGETS, + allowed_for_inactive_cloud_project=True, + validators=( + *_PROJECT_VALIDATORS, + validate_no_body_given, + *_DATE_HEADER_VALIDATORS, + *_CONTENT_LENGTH_HEADER_VALIDATORS, + ), +) + +_GET_TARGET = Route( + path_pattern=f"/targets/{_TARGET_ID_PATTERN}", + http_method=HTTPMethod.GET, + mandatory_keys=frozenset(), + optional_keys=frozenset(), + rate_limited_endpoint=RateLimitedEndpoint.GET_TARGET, + allowed_for_inactive_cloud_project=True, + validators=( + *_PROJECT_VALIDATORS, + validate_target_id_exists, + validate_no_body_given, + *_DATE_HEADER_VALIDATORS, + *_CONTENT_LENGTH_HEADER_VALIDATORS, + ), +) + +_TARGET_SUMMARY = Route( + path_pattern=f"/summary/{_TARGET_ID_PATTERN}", + http_method=HTTPMethod.GET, + mandatory_keys=frozenset(), + optional_keys=frozenset(), + rate_limited_endpoint=RateLimitedEndpoint.OTHER, + allowed_for_inactive_cloud_project=True, + validators=( + *_PROJECT_VALIDATORS, + validate_target_id_exists, + validate_no_body_given, + *_DATE_HEADER_VALIDATORS, + *_CONTENT_LENGTH_HEADER_VALIDATORS, + ), +) + +_GET_DUPLICATES = Route( + path_pattern=f"/duplicates/{_TARGET_ID_PATTERN}", + http_method=HTTPMethod.GET, + mandatory_keys=frozenset(), + optional_keys=frozenset(), + rate_limited_endpoint=RateLimitedEndpoint.GET_DUPLICATES, + allowed_for_inactive_cloud_project=False, + validators=( + *_PROJECT_VALIDATORS, + validate_target_id_exists, + validate_no_body_given, + *_DATE_HEADER_VALIDATORS, + *_CONTENT_LENGTH_HEADER_VALIDATORS, + ), +) + +_GENERATE_INSTANCE = Route( + path_pattern=f"/targets/{_TARGET_ID_PATTERN}/instances", + http_method=HTTPMethod.POST, + mandatory_keys=frozenset({"instance_id"}), + optional_keys=frozenset(), + rate_limited_endpoint=RateLimitedEndpoint.OTHER, + allowed_for_inactive_cloud_project=False, + validators=( + *_PROJECT_VALIDATORS, + validate_target_id_exists, + *_DATE_HEADER_VALIDATORS, + validate_vumark_instance_json, + validate_keys, + validate_instance_id_type, + validate_instance_id_not_empty, + validate_content_type_header_given, + *_CONTENT_LENGTH_HEADER_VALIDATORS, + ), +) + +_RECO_COUNTS_REPORT = Route( + path_pattern=RECO_COUNTS_REPORT_PATH_PATTERN, + http_method=HTTPMethod.POST, + mandatory_keys=frozenset({"month"}), + optional_keys=frozenset(), + rate_limited_endpoint=RateLimitedEndpoint.OTHER, + allowed_for_inactive_cloud_project=False, + validators=( + validate_database_id_matches_keys, + *_PROJECT_VALIDATORS, + *_DATE_HEADER_VALIDATORS, + validate_json, + validate_keys, + validate_content_type_header_given, + *_CONTENT_LENGTH_HEADER_VALIDATORS, + ), +) + +_ROUTES = ( + _ADD_TARGET, + _RECO_COUNTS_REPORT, + _DELETE_TARGET, + _DATABASE_SUMMARY, + _TARGET_LIST, + _GET_TARGET, + _GET_DUPLICATES, + _UPDATE_TARGET, + _GENERATE_INSTANCE, + _TARGET_SUMMARY, +) + + +@beartype +def match_route(*, request_path: str, request_method: str) -> Route: + """Return the route which a request was made to. + + Args: + request_path: The path of the request. + request_method: The HTTP method of the request. + + Returns: + The one route which the request matches. + """ + (matching_route,) = ( + route + for route in _ROUTES + if re.fullmatch(pattern=route.path_pattern, string=request_path) + and request_method == route.http_method + ) + return matching_route diff --git a/src/mock_vws/_services_validators/target_quota_validators.py b/src/mock_vws/_services_validators/target_quota_validators.py index e48d9f8d2..23f43bc40 100644 --- a/src/mock_vws/_services_validators/target_quota_validators.py +++ b/src/mock_vws/_services_validators/target_quota_validators.py @@ -1,28 +1,27 @@ """Validators for the VWS target quota.""" -from http import HTTPMethod - from beartype import beartype -from mock_vws._database_matchers import AnyDatabase from mock_vws.database import CloudDatabase +from .context import ValidatorContext from .exceptions import TargetQuotaReachedError @beartype -def validate_target_quota( - *, - request_method: str, - request_path: str, - database: AnyDatabase, -) -> None: - """Raise an error when adding a target would exceed the quota.""" - if request_method != HTTPMethod.POST or request_path != "/targets": - return +def validate_target_quota(*, context: ValidatorContext) -> None: + """Raise an error when adding a target would exceed the quota. + + Args: + context: The context of the request. + Raises: + TargetQuotaReachedError: The database already holds as many targets + as its quota allows. + """ if ( - isinstance(database, CloudDatabase) - and len(database.not_deleted_targets) >= database.target_quota + isinstance(context.database, CloudDatabase) + and len(context.database.not_deleted_targets) + >= context.database.target_quota ): raise TargetQuotaReachedError diff --git a/src/mock_vws/_services_validators/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index c71263705..70a5de805 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -1,12 +1,10 @@ """Validators for given target IDs.""" import logging -import re from beartype import beartype -from mock_vws._database_matchers import AnyDatabase -from mock_vws._mock_common import RECO_COUNTS_REPORT_PATH_PATTERN +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import UnknownTargetError _LOGGER = logging.getLogger(name=__name__) @@ -14,45 +12,44 @@ @beartype -def validate_target_id_exists( - *, - request_path: str, - database: AnyDatabase, -) -> None: - """Validate that if a target ID is given, it exists in the database - matching the request. +def target_id_from_path(*, request_path: str) -> str: + """Return the target ID which a request path names. Args: request_path: The path of the request. - database: The database which the request's server keys belong to. - Raises: - UnknownTargetError: There are no matching targets for a given target - ID. + Returns: + The target ID in the path, which is the last segment except on the + VuMark instance generation endpoint, where it is the segment before + ``instances``. """ - if re.fullmatch( - pattern=RECO_COUNTS_REPORT_PATH_PATTERN, - string=request_path, - ): - return - split_path = request_path.split(sep="/") - - request_path_no_target_id_length = 2 - if len(split_path) == request_path_no_target_id_length: - return - - target_id = split_path[-1] if ( len(split_path) == _TARGETS_WITH_INSTANCE_PATH_LENGTH and split_path[-3] == "targets" and split_path[-1] == "instances" ): - target_id = split_path[-2] + return split_path[-2] + return split_path[-1] + + +@beartype +def validate_target_id_exists(*, context: ValidatorContext) -> None: + """Validate that the target ID given in the request path exists in the + database matching the request. + + Args: + context: The context of the request. + + Raises: + UnknownTargetError: There are no matching targets for the given target + ID. + """ + target_id = target_id_from_path(request_path=context.request_path) matching_targets = [ target - for target in database.not_deleted_targets + for target in context.database.not_deleted_targets if target.target_id == target_id ] if not matching_targets: diff --git a/src/mock_vws/_services_validators/width_validators.py b/src/mock_vws/_services_validators/width_validators.py index ab47947d2..01262d52f 100644 --- a/src/mock_vws/_services_validators/width_validators.py +++ b/src/mock_vws/_services_validators/width_validators.py @@ -6,29 +6,27 @@ from beartype import beartype +from mock_vws._services_validators.context import ValidatorContext from mock_vws._services_validators.exceptions import FailError _LOGGER = logging.getLogger(name=__name__) @beartype -def validate_width(*, request_body: bytes) -> None: +def validate_width(*, context: ValidatorContext) -> None: """Validate the width argument given to a VWS endpoint. Args: - request_body: The body of the request. + context: The context of the request. Raises: FailError: Width is given and is not a positive number. """ - if not request_body: + request_json = json.loads(s=context.request_body.decode()) + if "width" not in request_json: return - request_text = request_body.decode() - if "width" not in json.loads(s=request_text): - return - - width = json.loads(s=request_text).get("width") + width = request_json["width"] width_is_number = isinstance(width, int | float) width_positive = width_is_number and width > 0 diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py index dca9b3769..c69057054 100644 --- a/src/mock_vws/target_manager.py +++ b/src/mock_vws/target_manager.py @@ -7,7 +7,7 @@ from beartype import beartype -from mock_vws._services_validators.request_rate_validators import ( +from mock_vws._services_validators.request_rate_limiter import ( RequestRateLimiter, ) from mock_vws.database import CloudDatabase, VuMarkDatabase diff --git a/tests/mock_vws/test_requests_mock_usage.py b/tests/mock_vws/test_requests_mock_usage.py index 92a094213..3e95039bc 100644 --- a/tests/mock_vws/test_requests_mock_usage.py +++ b/tests/mock_vws/test_requests_mock_usage.py @@ -36,7 +36,7 @@ from mock_vws._services_validators.exceptions import ( TooManyRequestsError as TooManyRequestsValidatorError, ) -from mock_vws._services_validators.request_rate_validators import ( +from mock_vws._services_validators.request_rate_limiter import ( RequestRateLimiter, ) from mock_vws.database import CloudDatabase, VuMarkDatabase diff --git a/tests/mock_vws/test_target_validators.py b/tests/mock_vws/test_target_validators.py index 9b445df30..85fcad411 100644 --- a/tests/mock_vws/test_target_validators.py +++ b/tests/mock_vws/test_target_validators.py @@ -3,32 +3,8 @@ import pytest from mock_vws._services_validators.target_validators import ( - validate_target_id_exists, + target_id_from_path, ) -from mock_vws.database import CloudDatabase -from mock_vws.target import ImageTarget -from mock_vws.target_raters import HardcodedTargetTrackingRater -from tests.mock_vws.utils import make_image_file - - -def _database_with_target(*, target_id: str) -> CloudDatabase: - """Create a database containing one target with the given ID.""" - target = ImageTarget( - active_flag=True, - application_metadata=None, - image_value=make_image_file( - file_format="PNG", - color_space="RGB", - width=8, - height=8, - ).getvalue(), - name="example", - processing_time_seconds=0, - target_id=target_id, - target_tracking_rater=HardcodedTargetTrackingRater(rating=5), - width=1, - ) - return CloudDatabase(targets={target}) @pytest.mark.parametrize( @@ -38,17 +14,10 @@ def _database_with_target(*, target_id: str) -> CloudDatabase: ("/targets/target123/instances", "target123"), ], ) -def test_validate_target_id_exists_uses_correct_path_segment( +def test_target_id_from_path_uses_correct_path_segment( *, request_path: str, target_id: str, ) -> None: - """Validation uses the right target segment for both endpoint - shapes. - """ - database = _database_with_target(target_id=target_id) - - validate_target_id_exists( - request_path=request_path, - database=database, - ) + """The right target segment is used for both endpoint shapes.""" + assert target_id_from_path(request_path=request_path) == target_id