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