From 2a72c0f65b37e8001cb833cfb93cba439829ec7e Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Mon, 7 Sep 2026 23:10:35 +0100 Subject: [PATCH] Parse the request body once per request in the validators The services validators each parsed the JSON body again, and the image validators each base64 decoded the image again. The parsed JSON and the decoded image now live on the validator context, computed on first use and shared by the rest of the chain. The Query API validators each parsed the whole multipart body again, and the response builder parsed it once more. The body is now parsed once in run_query_validators, after the Content-Type header has been validated, and the parsed form and the matched database are returned to the caller so that the response builder reuses them. Closes #3371 Co-Authored-By: Claude Fable 5.1 --- src/mock_vws/_flask_server/vwq.py | 8 +- src/mock_vws/_query_tools.py | 44 ++------ src/mock_vws/_query_validators/__init__.py | 87 +++++++------- .../_query_validators/auth_validators.py | 7 +- .../_query_validators/fields_validators.py | 20 +--- .../_query_validators/image_validators.py | 106 +++--------------- .../include_target_data_validators.py | 18 +-- src/mock_vws/_query_validators/multipart.py | 35 +++++- .../num_results_validators.py | 18 +-- .../project_state_validators.py | 25 +---- .../mock_web_query_api.py | 8 +- .../active_flag_validators.py | 3 +- src/mock_vws/_services_validators/context.py | 57 ++++++++++ .../_services_validators/image_validators.py | 41 ++----- .../instance_id_validators.py | 5 +- .../_services_validators/json_validators.py | 20 +--- .../_services_validators/key_validators.py | 3 +- .../metadata_validators.py | 7 +- .../_services_validators/name_validators.py | 7 +- .../_services_validators/width_validators.py | 3 +- 20 files changed, 212 insertions(+), 310 deletions(-) diff --git a/src/mock_vws/_flask_server/vwq.py b/src/mock_vws/_flask_server/vwq.py index 79b8252a5..a87f48307 100644 --- a/src/mock_vws/_flask_server/vwq.py +++ b/src/mock_vws/_flask_server/vwq.py @@ -137,7 +137,7 @@ def query() -> Response: databases = get_all_cloud_databases() request_body = request.stream.read() - run_query_validators( + validated_query = run_query_validators( request_headers=dict(request.headers), request_body=request_body, request_method=request.method, @@ -147,11 +147,7 @@ def query() -> Response: date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) response_text = get_query_match_response_text( - request_headers=dict(request.headers), - request_body=request_body, - request_method=request.method, - request_path=request.path, - databases=databases, + validated_query=validated_query, query_match_checker=query_match_checker, ) diff --git a/src/mock_vws/_query_tools.py b/src/mock_vws/_query_tools.py index 2ab51244d..8601830f8 100644 --- a/src/mock_vws/_query_tools.py +++ b/src/mock_vws/_query_tools.py @@ -2,65 +2,39 @@ import base64 import uuid -from collections.abc import Iterable, Mapping from typing import Any from beartype import beartype from mock_vws._base64_decoding import decode_base64 from mock_vws._constants import ResultCodes, TargetStatuses -from mock_vws._database_matchers import get_database_matching_client_keys from mock_vws._matching import matching_targets from mock_vws._mock_common import json_dump -from mock_vws._query_validators.multipart import parse_multipart -from mock_vws.database import CloudDatabase +from mock_vws._query_validators import ValidatedQuery from mock_vws.image_matchers import ImageMatcher @beartype def get_query_match_response_text( *, - request_headers: Mapping[str, str], - request_body: bytes, - request_method: str, - request_path: str, - databases: Iterable[CloudDatabase], + validated_query: ValidatedQuery, query_match_checker: ImageMatcher, ) -> str: """ Args: - request_path: The path of the request. - request_headers: The headers sent with the request. - request_body: The body of the request. - request_method: The HTTP method of the request. - databases: All Vuforia databases. + validated_query: The database and the parsed body which the query + validators resolved the request to. query_match_checker: A callable which takes two image values and returns a match score, or ``None`` if they do not match. Returns: The response text for a query endpoint request. """ - fields, files = parse_multipart( - request_headers=request_headers, - request_body=request_body, - ) - - max_num_results = fields.get(key="max_num_results", default="1") - include_target_data = fields.get( - key="include_target_data", - default="top", - ).lower() - - image_part = files["image"] - image_value = image_part.stream.read() - - database = get_database_matching_client_keys( - request_headers=request_headers, - request_body=request_body, - request_method=request_method, - request_path=request_path, - databases=databases, - ) + fields = validated_query.form.fields + max_num_results = fields.get("max_num_results", "1") + include_target_data = fields.get("include_target_data", "top").lower() + image_value = validated_query.form.files["image"] + database = validated_query.database matches_best_first = matching_targets( matcher=query_match_checker, diff --git a/src/mock_vws/_query_validators/__init__.py b/src/mock_vws/_query_validators/__init__.py index 454767494..043ba7be3 100644 --- a/src/mock_vws/_query_validators/__init__.py +++ b/src/mock_vws/_query_validators/__init__.py @@ -1,9 +1,14 @@ """Input validators to use in the mock query API.""" from collections.abc import Iterable, Mapping +from dataclasses import dataclass from beartype import beartype +from mock_vws._query_validators.multipart import ( + MultipartForm, + parse_multipart, +) from mock_vws.database import CloudDatabase from .accept_header_validators import validate_accept_header @@ -38,6 +43,24 @@ from .project_state_validators import validate_project_state +@beartype +@dataclass(frozen=True, kw_only=True) +class ValidatedQuery: + """What the validators learn about a query request which passes them. + + Args: + database: The database which the request's client keys belong to. + form: The parsed body of the request. + + Attributes: + database: The database which the request's client keys belong to. + form: The parsed body of the request. + """ + + database: CloudDatabase + form: MultipartForm + + @beartype def run_query_validators( *, @@ -46,15 +69,28 @@ def run_query_validators( request_body: bytes, request_method: str, databases: Iterable[CloudDatabase], -) -> None: +) -> ValidatedQuery: """Run all validators. + 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 here, so that order is the mock's record of Vuforia's error + precedence, verified against the real service. + + The body is parsed once, after the ``Content-Type`` header which names + its boundary has been validated, and the parsed form is shared by every + validator which reads the body. + Args: request_path: The path of the request. request_headers: The headers sent with the request. request_body: The body of the request. request_method: The HTTP method of the request. databases: All Vuforia databases. + + Returns: + The database which the request's client keys belong to, and the + parsed body of the request. """ validate_content_length_header_is_int(request_headers=request_headers) validate_content_length_header_not_too_large( @@ -72,20 +108,14 @@ def run_query_validators( request_headers=request_headers, databases=databases, ) - validate_authorization( - request_headers=request_headers, - request_body=request_body, - request_method=request_method, - request_path=request_path, - databases=databases, - ) - validate_project_state( + database = validate_authorization( request_headers=request_headers, request_body=request_body, request_method=request_method, request_path=request_path, databases=databases, ) + validate_project_state(database=database) validate_accept_header(request_headers=request_headers) validate_date_header_given(request_headers=request_headers) validate_date_format(request_headers=request_headers) @@ -94,35 +124,16 @@ def run_query_validators( request_headers=request_headers, request_body=request_body, ) - validate_extra_fields( - request_headers=request_headers, - request_body=request_body, - ) - validate_image_field_given( - request_headers=request_headers, - request_body=request_body, - ) - validate_image_is_image( - request_headers=request_headers, - request_body=request_body, - ) - validate_image_format( - request_headers=request_headers, - request_body=request_body, - ) - validate_image_dimensions( - request_headers=request_headers, - request_body=request_body, - ) - validate_image_file_size( - request_headers=request_headers, - request_body=request_body, - ) - validate_max_num_results( - request_headers=request_headers, - request_body=request_body, - ) - validate_include_target_data( + form = parse_multipart( request_headers=request_headers, request_body=request_body, ) + validate_extra_fields(form=form) + validate_image_field_given(form=form) + validate_image_is_image(form=form) + validate_image_format(form=form) + validate_image_dimensions(form=form) + validate_image_file_size(form=form) + validate_max_num_results(form=form) + validate_include_target_data(form=form) + return ValidatedQuery(database=database, form=form) diff --git a/src/mock_vws/_query_validators/auth_validators.py b/src/mock_vws/_query_validators/auth_validators.py index ddd4310ea..edce27080 100644 --- a/src/mock_vws/_query_validators/auth_validators.py +++ b/src/mock_vws/_query_validators/auth_validators.py @@ -115,7 +115,7 @@ def validate_authorization( request_body: bytes, request_method: str, databases: Iterable[CloudDatabase], -) -> None: +) -> CloudDatabase: """Validate the authorization header given to the query endpoint. Args: @@ -125,12 +125,15 @@ def validate_authorization( request_method: The HTTP method of the request. databases: All Vuforia databases. + Returns: + The database which the request's client keys belong to. + Raises: AuthenticationFailureError: The "Authorization" header is not as expected. """ try: - get_database_matching_client_keys( + return get_database_matching_client_keys( request_headers=request_headers, request_body=request_body, request_method=request_method, diff --git a/src/mock_vws/_query_validators/fields_validators.py b/src/mock_vws/_query_validators/fields_validators.py index fd4830d79..610f3e6dd 100644 --- a/src/mock_vws/_query_validators/fields_validators.py +++ b/src/mock_vws/_query_validators/fields_validators.py @@ -1,38 +1,26 @@ """Validators for the fields given.""" import logging -from collections.abc import Mapping from beartype import beartype from mock_vws._query_validators.exceptions import UnknownParametersError -from mock_vws._query_validators.multipart import parse_multipart +from mock_vws._query_validators.multipart import MultipartForm _LOGGER = logging.getLogger(name=__name__) @beartype -def validate_extra_fields( - *, - request_headers: Mapping[str, str], - request_body: bytes, -) -> None: +def validate_extra_fields(*, form: MultipartForm) -> None: """Validate that the no unknown fields are given. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + form: The parsed body of the request. Raises: UnknownParametersError: Extra fields are given. - NoContentDispositionError: A part of the body has no - ``Content-Disposition`` header. """ - fields, files = parse_multipart( - request_headers=request_headers, - request_body=request_body, - ) - parsed_keys = fields.keys() | files.keys() + parsed_keys = form.fields.keys() | form.files.keys() known_parameters = {"image", "max_num_results", "include_target_data"} if not parsed_keys - known_parameters: diff --git a/src/mock_vws/_query_validators/image_validators.py b/src/mock_vws/_query_validators/image_validators.py index f3d1926fd..19a8f7e7e 100644 --- a/src/mock_vws/_query_validators/image_validators.py +++ b/src/mock_vws/_query_validators/image_validators.py @@ -2,10 +2,8 @@ import io import logging -from collections.abc import Mapping from beartype import beartype -from werkzeug.datastructures import FileStorage, MultiDict from mock_vws._image_opening import open_image from mock_vws._query_validators.exceptions import ( @@ -13,53 +11,22 @@ ImageNotGivenError, RequestEntityTooLargeError, ) -from mock_vws._query_validators.multipart import parse_multipart +from mock_vws._query_validators.multipart import MultipartForm _LOGGER = logging.getLogger(name=__name__) @beartype -def _parse_multipart_files( - *, - request_headers: Mapping[str, str], - request_body: bytes, -) -> MultiDict[str, FileStorage]: - """Parse the multipart body and return the files section. - - Args: - request_headers: The headers sent with the request. - request_body: The body of the request. - - Returns: - The files parsed from the multipart body. - """ - _, files = parse_multipart( - request_headers=request_headers, - request_body=request_body, - ) - return files - - -@beartype -def validate_image_field_given( - *, - request_headers: Mapping[str, str], - request_body: bytes, -) -> None: +def validate_image_field_given(*, form: MultipartForm) -> None: """Validate that the image field is given. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + form: The parsed body of the request. Raises: ImageNotGivenError: The image field is not given. """ - files = _parse_multipart_files( - request_headers=request_headers, - request_body=request_body, - ) - if files.get(key="image") is not None: + if "image" in form.files: return _LOGGER.warning(msg="The image field is not given.") @@ -67,26 +34,16 @@ def validate_image_field_given( @beartype -def validate_image_file_size( - *, - request_headers: Mapping[str, str], - request_body: bytes, -) -> None: +def validate_image_file_size(*, form: MultipartForm) -> None: """Validate the file size of the image given to the query endpoint. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + form: The parsed body of the request. Raises: RequestEntityTooLargeError: The image file size is too large. """ - files = _parse_multipart_files( - request_headers=request_headers, - request_body=request_body, - ) - image_part = files["image"] - image_value = image_part.stream.read() + image_value = form.files["image"] # This is the documented maximum size of a PNG as per. # https://developer.vuforia.com/library/web-api/vuforia-query-web-api. @@ -102,28 +59,17 @@ def validate_image_file_size( @beartype -def validate_image_dimensions( - *, - request_headers: Mapping[str, str], - request_body: bytes, -) -> None: +def validate_image_dimensions(*, form: MultipartForm) -> None: """Validate the dimensions the image given to the query endpoint. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + form: The parsed body of the request. Raises: BadImageError: The image is given and is not within the maximum width and height limits. """ - files = _parse_multipart_files( - request_headers=request_headers, - request_body=request_body, - ) - image_part = files["image"] - image_value = image_part.stream.read() - image_file = io.BytesIO(initial_bytes=image_value) + image_file = io.BytesIO(initial_bytes=form.files["image"]) with open_image(fp=image_file) as pil_image: max_width = 30000 max_height = 30000 @@ -135,26 +81,17 @@ def validate_image_dimensions( @beartype -def validate_image_format( - *, - request_headers: Mapping[str, str], - request_body: bytes, -) -> None: +def validate_image_format(*, form: MultipartForm) -> None: """Validate the format of the image given to the query endpoint. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + form: The parsed body of the request. Raises: BadImageError: The image is given and is not either a PNG or a JPEG. """ - files = _parse_multipart_files( - request_headers=request_headers, - request_body=request_body, - ) - image_part = files["image"] - with open_image(fp=image_part.stream) as pil_image: + image_file = io.BytesIO(initial_bytes=form.files["image"]) + with open_image(fp=image_file) as pil_image: if pil_image.format in {"PNG", "JPEG"}: return @@ -163,25 +100,16 @@ def validate_image_format( @beartype -def validate_image_is_image( - *, - request_headers: Mapping[str, str], - request_body: bytes, -) -> None: +def validate_image_is_image(*, form: MultipartForm) -> None: """Validate that the given image data is actually an image file. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + form: The parsed body of the request. Raises: BadImageError: Image data is given and it is not an image file. """ - files = _parse_multipart_files( - request_headers=request_headers, - request_body=request_body, - ) - image_file = files["image"].stream + image_file = io.BytesIO(initial_bytes=form.files["image"]) try: with open_image(fp=image_file) as _: diff --git a/src/mock_vws/_query_validators/include_target_data_validators.py b/src/mock_vws/_query_validators/include_target_data_validators.py index 89396b8e5..75e6debe4 100644 --- a/src/mock_vws/_query_validators/include_target_data_validators.py +++ b/src/mock_vws/_query_validators/include_target_data_validators.py @@ -1,39 +1,29 @@ """Validators for the ``include_target_data`` field.""" import logging -from collections.abc import Mapping from beartype import beartype from mock_vws._query_validators.exceptions import InvalidIncludeTargetDataError -from mock_vws._query_validators.multipart import parse_multipart +from mock_vws._query_validators.multipart import MultipartForm _LOGGER = logging.getLogger(name=__name__) @beartype -def validate_include_target_data( - *, - request_headers: Mapping[str, str], - request_body: bytes, -) -> None: +def validate_include_target_data(*, form: MultipartForm) -> None: """Validate the ``include_target_data`` field is either an accepted value or not given. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + form: The parsed body of the request. Raises: InvalidIncludeTargetDataError: The ``include_target_data`` field is not an accepted value. """ - fields, _ = parse_multipart( - request_headers=request_headers, - request_body=request_body, - ) - include_target_data = fields.get(key="include_target_data", default="top") + include_target_data = form.fields.get("include_target_data", "top") allowed_included_target_data = {"top", "all", "none"} if include_target_data.lower() in allowed_included_target_data: return diff --git a/src/mock_vws/_query_validators/multipart.py b/src/mock_vws/_query_validators/multipart.py index a6c5c6702..8ab774816 100644 --- a/src/mock_vws/_query_validators/multipart.py +++ b/src/mock_vws/_query_validators/multipart.py @@ -5,6 +5,7 @@ import io import logging from collections.abc import Mapping +from dataclasses import dataclass from email.message import EmailMessage from beartype import beartype @@ -16,6 +17,27 @@ _LOGGER = logging.getLogger(name=__name__) +@beartype +@dataclass(frozen=True, kw_only=True) +class MultipartForm: + """The parsed ``multipart/form-data`` body of a query request. + + Where the body gives a field more than once, the first value is kept, as + it is by every reader of the raw parse. + + Args: + fields: The form fields, by name. + files: The content of each file part, by name. + + Attributes: + fields: The form fields, by name. + files: The content of each file part, by name. + """ + + fields: Mapping[str, str] + files: Mapping[str, bytes] + + @beartype def _parse_with_boundary( *, @@ -47,7 +69,7 @@ def parse_multipart( *, request_headers: Mapping[str, str], request_body: bytes, -) -> tuple[MultiDict[str, str], MultiDict[str, FileStorage]]: +) -> MultipartForm: """Parse the multipart body of a query request. Vuforia accepts a body which ends before its closing boundary, as a @@ -60,7 +82,7 @@ def parse_multipart( request_body: The body of the request. Returns: - The fields and the files parsed from the multipart body. + The form parsed from the multipart body. Raises: NoContentDispositionError: The body ends within the headers of a part, @@ -95,12 +117,19 @@ def parse_multipart( for candidate in candidates: try: - return _parse_with_boundary( + fields, files = _parse_with_boundary( request_body=candidate, boundary=boundary, ) except ValueError: continue + return MultipartForm( + fields=fields.to_dict(), + files={ + name: file_storage.stream.read() + for name, file_storage in files.items() + }, + ) # Every remaining body is one in which a part has no usable # ``Content-Disposition`` header, either because the body ends before that diff --git a/src/mock_vws/_query_validators/num_results_validators.py b/src/mock_vws/_query_validators/num_results_validators.py index 7d1f8962f..1197b7f9f 100644 --- a/src/mock_vws/_query_validators/num_results_validators.py +++ b/src/mock_vws/_query_validators/num_results_validators.py @@ -1,7 +1,6 @@ """Validators for the ``max_num_results`` fields.""" import logging -from collections.abc import Mapping from beartype import beartype @@ -9,24 +8,19 @@ InvalidMaxNumResultsError, MaxNumResultsOutOfRangeError, ) -from mock_vws._query_validators.multipart import parse_multipart +from mock_vws._query_validators.multipart import MultipartForm _LOGGER = logging.getLogger(name=__name__) @beartype -def validate_max_num_results( - *, - request_headers: Mapping[str, str], - request_body: bytes, -) -> None: +def validate_max_num_results(*, form: MultipartForm) -> None: """Validate the ``max_num_results`` field is either an integer within range or not given. Args: - request_headers: The headers sent with the request. - request_body: The body of the request. + form: The parsed body of the request. Raises: InvalidMaxNumResultsError: The ``max_num_results`` given is not an @@ -34,11 +28,7 @@ def validate_max_num_results( MaxNumResultsOutOfRangeError: The ``max_num_results`` given is not in range. """ - fields, _ = parse_multipart( - request_headers=request_headers, - request_body=request_body, - ) - max_num_results = fields.get(key="max_num_results", default="1") + max_num_results = form.fields.get("max_num_results", "1") try: max_num_results_int = int(max_num_results) diff --git a/src/mock_vws/_query_validators/project_state_validators.py b/src/mock_vws/_query_validators/project_state_validators.py index 7767499b2..4f6075826 100644 --- a/src/mock_vws/_query_validators/project_state_validators.py +++ b/src/mock_vws/_query_validators/project_state_validators.py @@ -1,11 +1,9 @@ """Validators for the project state.""" import logging -from collections.abc import Iterable, Mapping from beartype import beartype -from mock_vws._database_matchers import get_database_matching_client_keys from mock_vws._query_validators.exceptions import InactiveProjectError from mock_vws.database import CloudDatabase from mock_vws.states import States @@ -14,34 +12,15 @@ @beartype -def validate_project_state( - *, - request_path: str, - request_headers: Mapping[str, str], - request_body: bytes, - request_method: str, - databases: Iterable[CloudDatabase], -) -> None: +def validate_project_state(*, database: CloudDatabase) -> None: """Validate the state of the project. Args: - request_path: The path of the request. - request_headers: The headers sent with the request. - request_body: The body of the request. - request_method: The HTTP method of the request. - databases: All Vuforia databases. + database: The database which the request's client keys belong to. Raises: InactiveProjectError: The project is inactive. """ - database = get_database_matching_client_keys( - request_headers=request_headers, - request_body=request_body, - request_method=request_method, - request_path=request_path, - databases=databases, - ) - if database.state != States.PROJECT_INACTIVE: return diff --git a/src/mock_vws/_requests_mock_server/mock_web_query_api.py b/src/mock_vws/_requests_mock_server/mock_web_query_api.py index bdf7b67b9..9362f2bfb 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_query_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_query_api.py @@ -117,7 +117,7 @@ def query(self, request: RequestData) -> _ResponseType: ) try: - run_query_validators( + validated_query = run_query_validators( request_path=request.path, request_headers=request.headers, request_body=request.body, @@ -128,11 +128,7 @@ def query(self, request: RequestData) -> _ResponseType: return exc.status_code, exc.headers, exc.response_text response_text = get_query_match_response_text( - request_headers=request.headers, - request_body=request.body, - request_method=request.method, - request_path=request.path, - databases=self._target_manager.cloud_databases, + validated_query=validated_query, query_match_checker=self._query_match_checker, ) diff --git a/src/mock_vws/_services_validators/active_flag_validators.py b/src/mock_vws/_services_validators/active_flag_validators.py index d2d8921a2..58eab5c40 100644 --- a/src/mock_vws/_services_validators/active_flag_validators.py +++ b/src/mock_vws/_services_validators/active_flag_validators.py @@ -1,6 +1,5 @@ """Validators for the active flag.""" -import json import logging from http import HTTPStatus @@ -23,7 +22,7 @@ def validate_active_flag(*, context: ValidatorContext) -> None: FailError: There is active flag data given to the endpoint which is not either a Boolean or NULL. """ - request_json = json.loads(s=context.request_body.decode()) + request_json = context.request_json if "active_flag" not in request_json: return diff --git a/src/mock_vws/_services_validators/context.py b/src/mock_vws/_services_validators/context.py index 9fbf5c7af..8f85bd760 100644 --- a/src/mock_vws/_services_validators/context.py +++ b/src/mock_vws/_services_validators/context.py @@ -1,16 +1,30 @@ """The request context which every services validator is given.""" +import json from collections.abc import Mapping from dataclasses import dataclass +from functools import cached_property +from typing import Any, TypeIs from beartype import beartype +from mock_vws._base64_decoding import decode_base64 from mock_vws._database_matchers import AnyDatabase from mock_vws.request_rate_limits import RateLimitedEndpoint from .request_rate_limiter import RequestRateLimiter +@beartype +def _is_json_object(value: object, /) -> TypeIs[dict[str, Any]]: + """Return whether a decoded JSON value is an object. + + JSON object keys are always strings, so a ``dict`` from ``json.loads`` + is a ``dict[str, Any]``. + """ + return isinstance(value, dict) + + @beartype @dataclass(frozen=True, kw_only=True) class ValidatorContext: @@ -21,6 +35,10 @@ class ValidatorContext: copied onto the context rather than being looked up again from the path and the method. + The parsed forms of the body are computed the first time a validator + asks for them and then shared by every validator in the chain, so the + body is parsed once per request rather than once per validator. + Args: request_path: The path of the request. request_headers: The headers sent with the request. @@ -57,3 +75,42 @@ class ValidatorContext: optional_keys: frozenset[str] rate_limited_endpoint: RateLimitedEndpoint allowed_for_inactive_cloud_project: bool + + @cached_property + def request_json(self) -> dict[str, Any]: + """The request body parsed as a JSON object. + + A route's JSON validator runs before any validator which reads this, + so by the time a validator does read it, the body is known to be a + JSON object. + + Raises: + ValueError: The body is not UTF-8 or is not JSON. Both + :py:class:`json.JSONDecodeError` and + :py:class:`UnicodeDecodeError` are kinds of this. + TypeError: The body is JSON but is not a JSON object. + """ + parsed: object = json.loads( + s=self.request_body.decode(encoding="utf-8"), + ) + if not _is_json_object(parsed): + msg = "The request body is not a JSON object." + raise TypeError(msg) + return parsed + + @cached_property + def decoded_image(self) -> bytes | None: + """The base64 decoded image given in the request body, or ``None`` + if no image was given. + + The image data type and encoding validators run before any validator + which reads this, so by the time a validator does read it, the image + is known to be a base64 string. + + Raises: + binascii.Error: The image cannot be base64 decoded. + """ + image = self.request_json.get("image") + if image is None: + return None + return decode_base64(encoded_data=image) diff --git a/src/mock_vws/_services_validators/image_validators.py b/src/mock_vws/_services_validators/image_validators.py index b1e68ac4f..ee12cfafe 100644 --- a/src/mock_vws/_services_validators/image_validators.py +++ b/src/mock_vws/_services_validators/image_validators.py @@ -2,13 +2,11 @@ import binascii import io -import json import logging from http import HTTPStatus from beartype import beartype -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 ( @@ -20,25 +18,6 @@ _LOGGER = logging.getLogger(name=__name__) -@beartype -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. @@ -49,7 +28,7 @@ def validate_image_data_type(*, context: ValidatorContext) -> None: Raises: FailError: Image data is given and it is not a string. """ - request_json = json.loads(s=context.request_body.decode()) + request_json = context.request_json if "image" not in request_json: return @@ -72,12 +51,8 @@ def validate_image_encoding(*, context: ValidatorContext) -> None: 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"]) + _ = context.decoded_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 @@ -93,7 +68,7 @@ def validate_image_is_image(*, context: ValidatorContext) -> None: Raises: BadImageError: Image data is given and it is not an image file. """ - decoded = _decoded_image(context=context) + decoded = context.decoded_image if decoded is None: return @@ -117,7 +92,7 @@ def validate_image_format(*, context: ValidatorContext) -> None: Raises: BadImageError: The image is given and is not either a PNG or a JPEG. """ - decoded = _decoded_image(context=context) + decoded = context.decoded_image if decoded is None: return @@ -141,7 +116,7 @@ def validate_image_color_space(*, context: ValidatorContext) -> None: BadImageError: The image is given and is not in either the RGB or greyscale color space. """ - decoded = _decoded_image(context=context) + decoded = context.decoded_image if decoded is None: return @@ -167,7 +142,7 @@ def validate_image_size(*, context: ValidatorContext) -> None: ImageTooLargeError: The image is given and is not under a certain file size threshold. """ - decoded = _decoded_image(context=context) + decoded = context.decoded_image if decoded is None: return @@ -193,7 +168,7 @@ def validate_image_pixel_count(*, context: ValidatorContext) -> None: ImageTooLargeError: The image is given and it has more than the maximum number of pixels. """ - decoded = _decoded_image(context=context) + decoded = context.decoded_image if decoded is None: return @@ -221,7 +196,7 @@ def validate_image_integrity(*, context: ValidatorContext) -> None: Raises: BadImageError: The image is given and is not a valid image file. """ - decoded = _decoded_image(context=context) + decoded = context.decoded_image if decoded is None: return diff --git a/src/mock_vws/_services_validators/instance_id_validators.py b/src/mock_vws/_services_validators/instance_id_validators.py index 42b1ea0e9..7c70b8a65 100644 --- a/src/mock_vws/_services_validators/instance_id_validators.py +++ b/src/mock_vws/_services_validators/instance_id_validators.py @@ -1,6 +1,5 @@ """Validators for VuMark instance IDs.""" -import json import logging from beartype import beartype @@ -26,7 +25,7 @@ def validate_instance_id_type(*, context: ValidatorContext) -> None: BadRequestError: There is instance_id data given to the endpoint which is not a string. """ - instance_id = json.loads(s=context.request_body.decode())["instance_id"] + instance_id = context.request_json["instance_id"] if isinstance(instance_id, str): return @@ -49,7 +48,7 @@ def validate_instance_id_not_empty(*, context: ValidatorContext) -> None: InvalidInstanceIdError: There is instance_id data given to the endpoint which is an empty string. """ - instance_id = json.loads(s=context.request_body.decode())["instance_id"] + instance_id = context.request_json["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 0dcd7a889..57ccafb70 100644 --- a/src/mock_vws/_services_validators/json_validators.py +++ b/src/mock_vws/_services_validators/json_validators.py @@ -1,10 +1,8 @@ """Validators for given JSON.""" -import json import logging from collections.abc import Callable from http import HTTPStatus -from json.decoder import JSONDecodeError from beartype import beartype @@ -66,20 +64,14 @@ def _validate_json( _LOGGER.warning(msg="The request body is empty.") raise make_empty_body_error() + # 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 + # or which is not a JSON object. 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=context.request_body.decode(encoding="utf-8"), - ) - except (JSONDecodeError, UnicodeDecodeError) as exc: - _LOGGER.warning(msg="The request body is not valid JSON.") - raise make_invalid_json_error() from exc - - if not isinstance(request_json, dict): + _ = context.request_json + except (TypeError, ValueError) as exc: _LOGGER.warning(msg="The request body is not a JSON object.") - raise make_invalid_json_error() + raise make_invalid_json_error() from exc @beartype diff --git a/src/mock_vws/_services_validators/key_validators.py b/src/mock_vws/_services_validators/key_validators.py index 84b7adff4..2372eea27 100644 --- a/src/mock_vws/_services_validators/key_validators.py +++ b/src/mock_vws/_services_validators/key_validators.py @@ -1,6 +1,5 @@ """Validators for JSON keys.""" -import json import logging from http import HTTPStatus @@ -25,7 +24,7 @@ def validate_keys(*, context: ValidatorContext) -> None: missing. """ allowed_keys = context.mandatory_keys | context.optional_keys - request_json = json.loads(s=context.request_body.decode()) + request_json = context.request_json given_keys = set(request_json.keys()) all_given_keys_allowed = given_keys.issubset(allowed_keys) all_mandatory_keys_given = context.mandatory_keys.issubset(given_keys) diff --git a/src/mock_vws/_services_validators/metadata_validators.py b/src/mock_vws/_services_validators/metadata_validators.py index ce9d42c69..fcef39644 100644 --- a/src/mock_vws/_services_validators/metadata_validators.py +++ b/src/mock_vws/_services_validators/metadata_validators.py @@ -1,7 +1,6 @@ """Validators for application metadata.""" import binascii -import json import logging from http import HTTPStatus @@ -30,7 +29,7 @@ def validate_metadata_size(*, context: ValidatorContext) -> None: MetadataTooLargeError: Application metadata is given and it is too large. """ - request_json = json.loads(s=context.request_body.decode()) + request_json = context.request_json application_metadata = request_json.get("application_metadata") if application_metadata is None: return @@ -55,7 +54,7 @@ def validate_metadata_encoding(*, context: ValidatorContext) -> None: FailError: Application metadata is given and it cannot be base64 decoded. """ - request_json = json.loads(s=context.request_body.decode()) + request_json = context.request_json application_metadata = request_json.get("application_metadata") if application_metadata is None: @@ -79,7 +78,7 @@ def validate_metadata_type(*, context: ValidatorContext) -> None: FailError: Application metadata is given and it is not a string or NULL. """ - request_json = json.loads(s=context.request_body.decode()) + request_json = context.request_json if "application_metadata" not in request_json: return diff --git a/src/mock_vws/_services_validators/name_validators.py b/src/mock_vws/_services_validators/name_validators.py index 377574f2f..bc49df600 100644 --- a/src/mock_vws/_services_validators/name_validators.py +++ b/src/mock_vws/_services_validators/name_validators.py @@ -1,6 +1,5 @@ """Validators for target names.""" -import json import logging from http import HTTPStatus @@ -33,7 +32,7 @@ def _given_name(*, context: ValidatorContext) -> str | None: 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()) + request_json = context.request_json name: str | None = request_json.get("name") return name @@ -64,7 +63,7 @@ def _new_target_name(*, context: ValidatorContext) -> str: 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"] + name: str = context.request_json["name"] return name @@ -141,7 +140,7 @@ def validate_name_type(*, context: ValidatorContext) -> None: Raises: FailError: A name is given and it is not a string. """ - request_json = json.loads(s=context.request_body.decode()) + request_json = context.request_json if "name" not in request_json: return diff --git a/src/mock_vws/_services_validators/width_validators.py b/src/mock_vws/_services_validators/width_validators.py index 01262d52f..fa5b7bbdc 100644 --- a/src/mock_vws/_services_validators/width_validators.py +++ b/src/mock_vws/_services_validators/width_validators.py @@ -1,6 +1,5 @@ """Validators for the width field.""" -import json import logging from http import HTTPStatus @@ -22,7 +21,7 @@ def validate_width(*, context: ValidatorContext) -> None: Raises: FailError: Width is given and is not a positive number. """ - request_json = json.loads(s=context.request_body.decode()) + request_json = context.request_json if "width" not in request_json: return