diff --git a/docs/source/api-reference.rst b/docs/source/api-reference.rst index 01bba09b2..c94d810e1 100644 --- a/docs/source/api-reference.rst +++ b/docs/source/api-reference.rst @@ -13,6 +13,10 @@ API Reference :undoc-members: :members: +.. automodule:: vws.vumark_accept + :undoc-members: + :members: + .. automodule:: vws.response :undoc-members: :members: diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index ce782a4af..2329ef7f4 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -42,6 +42,7 @@ decodable dev dict docstring +enum filename foo formdata @@ -100,6 +101,7 @@ usefixtures validators vuforia vuforia's +vumark vwq vws xxx diff --git a/src/vws/__init__.py b/src/vws/__init__.py index bfd96d6c2..a091641f9 100644 --- a/src/vws/__init__.py +++ b/src/vws/__init__.py @@ -1,9 +1,11 @@ """A library for Vuforia Web Services.""" from .query import CloudRecoService +from .vumark_service import VuMarkService from .vws import VWS __all__ = [ "VWS", "CloudRecoService", + "VuMarkService", ] diff --git a/src/vws/_vws_request.py b/src/vws/_vws_request.py new file mode 100644 index 000000000..0ea914388 --- /dev/null +++ b/src/vws/_vws_request.py @@ -0,0 +1,85 @@ +"""Internal helper for making authenticated requests to the Vuforia Target +API. +""" + +from urllib.parse import urljoin + +import requests +from beartype import BeartypeConf, beartype +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws.response import Response + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def target_api_request( + *, + content_type: str, + server_access_key: str, + server_secret_key: str, + method: str, + data: bytes, + request_path: str, + base_vws_url: str, + request_timeout_seconds: float | tuple[float, float], + extra_headers: dict[str, str], +) -> Response: + """Make a request to the Vuforia Target API. + + This uses `requests` to make a request against https://vws.vuforia.com. + + Args: + content_type: The content type of the request. + server_access_key: A VWS server access key. + server_secret_key: A VWS server secret key. + method: The HTTP method which will be used in the request. + data: The request body which will be used in the request. + request_path: The path to the endpoint which will be used in the + request. + base_vws_url: The base URL for the VWS API. + request_timeout_seconds: The timeout for the request, as used by + ``requests.request``. This can be a float to set both the + connect and read timeouts, or a (connect, read) tuple. + extra_headers: Additional headers to include in the request. + + Returns: + The response to the request made by `requests`. + """ + date_string = rfc_1123_date() + + signature_string = authorization_header( + access_key=server_access_key, + secret_key=server_secret_key, + method=method, + content=data, + content_type=content_type, + date=date_string, + request_path=request_path, + ) + + headers = { + "Authorization": signature_string, + "Date": date_string, + "Content-Type": content_type, + **extra_headers, + } + + url = urljoin(base=base_vws_url, url=request_path) + + requests_response = requests.request( + method=method, + url=url, + headers=headers, + data=data, + timeout=request_timeout_seconds, + ) + + return Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=bytes(requests_response.content), + ) diff --git a/src/vws/exceptions/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index 6b1447577..865d2372c 100644 --- a/src/vws/exceptions/vws_exceptions.py +++ b/src/vws/exceptions/vws_exceptions.py @@ -24,9 +24,10 @@ class UnknownTargetError(VWSError): def target_id(self) -> str: """The unknown target ID.""" path = urlparse(url=self.response.url).path - # Every HTTP path which can raise this error is in the format - # `/something/{target_id}`. - return path.split(sep="/", maxsplit=2)[-1] + # Every HTTP path which can raise this error has the target ID as the + # second path segment, e.g. `/something/{target_id}` or + # `/something/{target_id}/more`. + return path.split(sep="/")[2] @beartype @@ -68,9 +69,10 @@ class TargetStatusProcessingError(VWSError): def target_id(self) -> str: """The processing target ID.""" path = urlparse(url=self.response.url).path - # Every HTTP path which can raise this error is in the format - # `/something/{target_id}`. - return path.split(sep="/", maxsplit=2)[-1] + # Every HTTP path which can raise this error has the target ID as the + # second path segment, e.g. `/something/{target_id}` or + # `/something/{target_id}/more`. + return path.split(sep="/")[2] # This is not simulated by the mock. @@ -157,9 +159,10 @@ class TargetStatusNotSuccessError(VWSError): def target_id(self) -> str: """The unknown target ID.""" path = urlparse(url=self.response.url).path - # Every HTTP path which can raise this error is in the format - # `/something/{target_id}`. - return path.split(sep="/", maxsplit=2)[-1] + # Every HTTP path which can raise this error has the target ID as the + # second path segment, e.g. `/something/{target_id}` or + # `/something/{target_id}/more`. + return path.split(sep="/")[2] @beartype @@ -167,3 +170,35 @@ class TooManyRequestsError(VWSError): # pragma: no cover """Exception raised when Vuforia returns a response with a result code 'TooManyRequests'. """ + + +# This is not simulated by client code because the accept parameter uses +# the VuMarkAccept enum, which only allows valid values. +@beartype +class InvalidAcceptHeaderError(VWSError): # pragma: no cover + """Exception raised when Vuforia returns a response with a result code + ``InvalidAcceptHeader``. + """ + + +@beartype +class InvalidInstanceIdError(VWSError): + """Exception raised when Vuforia returns a response with a result code + ``InvalidInstanceId``. + """ + + +# This is not simulated by client code because the request body +# is always valid JSON when using this client. +@beartype +class BadRequestError(VWSError): # pragma: no cover + """Exception raised when Vuforia returns a response with a result code + ``BadRequest``. + """ + + +@beartype +class InvalidTargetTypeError(VWSError): + """Exception raised when Vuforia returns a response with a result code + ``InvalidTargetType``. + """ diff --git a/src/vws/query.py b/src/vws/query.py index dd36e8f21..ad55d8a47 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -156,6 +156,7 @@ def query( headers=dict(requests_response.headers), request_body=requests_response.request.body, tell_position=requests_response.raw.tell(), + content=bytes(requests_response.content), ) if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: diff --git a/src/vws/response.py b/src/vws/response.py index e2060cb9e..71d1b9a48 100644 --- a/src/vws/response.py +++ b/src/vws/response.py @@ -16,3 +16,4 @@ class Response: headers: dict[str, str] request_body: bytes | str | None tell_position: int + content: bytes diff --git a/src/vws/vumark_accept.py b/src/vws/vumark_accept.py new file mode 100644 index 000000000..5dd21ab08 --- /dev/null +++ b/src/vws/vumark_accept.py @@ -0,0 +1,18 @@ +"""Tools for managing ``VWS.generate_vumark_instance``'s ``accept``.""" + +from enum import StrEnum, unique + +from beartype import beartype + + +@beartype +@unique +class VuMarkAccept(StrEnum): + """ + Options for the ``accept`` parameter of + ``VWS.generate_vumark_instance``. + """ + + PNG = "image/png" + SVG = "image/svg+xml" + PDF = "application/pdf" diff --git a/src/vws/vumark_service.py b/src/vws/vumark_service.py new file mode 100644 index 000000000..a23ac33f2 --- /dev/null +++ b/src/vws/vumark_service.py @@ -0,0 +1,143 @@ +"""Interface to the Vuforia VuMark Generation Web API.""" + +import json +from http import HTTPMethod, HTTPStatus + +from beartype import BeartypeConf, beartype + +from vws._vws_request import target_api_request +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + BadRequestError, + DateRangeError, + FailError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, + InvalidTargetTypeError, + RequestTimeTooSkewedError, + TargetStatusNotSuccessError, + TooManyRequestsError, + UnknownTargetError, +) +from vws.vumark_accept import VuMarkAccept + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class VuMarkService: + """An interface to the Vuforia VuMark Generation Web API.""" + + def __init__( + self, + server_access_key: str, + server_secret_key: str, + base_vws_url: str = "https://vws.vuforia.com", + request_timeout_seconds: float | tuple[float, float] = 30.0, + ) -> None: + """ + Args: + server_access_key: A VWS server access key. + server_secret_key: A VWS server secret key. + base_vws_url: The base URL for the VWS API. + request_timeout_seconds: The timeout for each HTTP request, as + used by ``requests.request``. This can be a float to set + both the connect and read timeouts, or a (connect, read) + tuple. + """ + self._server_access_key = server_access_key + self._server_secret_key = server_secret_key + self._base_vws_url = base_vws_url + self._request_timeout_seconds = request_timeout_seconds + + def generate_vumark_instance( + self, + *, + target_id: str, + instance_id: str, + accept: VuMarkAccept, + ) -> bytes: + """Generate a VuMark instance image. + + See + https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/ + for parameter details. + + Args: + target_id: The ID of the VuMark target. + instance_id: The instance ID to encode in the VuMark. + accept: The image format to return. + + Returns: + The VuMark instance image bytes. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.InvalidAcceptHeaderError: The + Accept header value is not supported. + ~vws.exceptions.vws_exceptions.InvalidInstanceIdError: The + instance ID is invalid. For example, it may be empty. + ~vws.exceptions.vws_exceptions.InvalidTargetTypeError: The target + is not a VuMark template target. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.TargetStatusNotSuccessError: The + target is not in the success state. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + request_path = f"/targets/{target_id}/instances" + content_type = "application/json" + request_data = json.dumps(obj={"instance_id": instance_id}).encode( + encoding="utf-8", + ) + + response = target_api_request( + content_type=content_type, + server_access_key=self._server_access_key, + server_secret_key=self._server_secret_key, + method=HTTPMethod.POST, + data=request_data, + request_path=request_path, + base_vws_url=self._base_vws_url, + request_timeout_seconds=self._request_timeout_seconds, + extra_headers={"Accept": accept}, + ) + + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + raise TooManyRequestsError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + if response.status_code == HTTPStatus.OK: + return response.content + + result_code = json.loads(s=response.text)["result_code"] + + exception = { + "AuthenticationFailure": AuthenticationFailureError, + "BadRequest": BadRequestError, + "DateRangeError": DateRangeError, + "Fail": FailError, + "InvalidAcceptHeader": InvalidAcceptHeaderError, + "InvalidInstanceId": InvalidInstanceIdError, + "InvalidTargetType": InvalidTargetTypeError, + "RequestTimeTooSkewed": RequestTimeTooSkewedError, + "TargetStatusNotSuccess": TargetStatusNotSuccessError, + "UnknownTarget": UnknownTargetError, + }[result_code] + + raise exception(response=response) diff --git a/src/vws/vws.py b/src/vws/vws.py index 4cb75ca32..6977ed18f 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -7,12 +7,10 @@ from datetime import date from http import HTTPMethod, HTTPStatus from typing import BinaryIO -from urllib.parse import urljoin -import requests from beartype import BeartypeConf, beartype -from vws_auth_tools import authorization_header, rfc_1123_date +from vws._vws_request import target_api_request from vws.exceptions.custom_exceptions import ( ServerError, TargetProcessingTimeoutError, @@ -20,6 +18,7 @@ from vws.exceptions.vws_exceptions import ( AuthenticationFailureError, BadImageError, + BadRequestError, DateRangeError, FailError, ImageTooLargeError, @@ -58,76 +57,6 @@ def _get_image_data(image: _ImageType) -> bytes: return image_data -@beartype(conf=BeartypeConf(is_pep484_tower=True)) -def _target_api_request( - *, - content_type: str, - server_access_key: str, - server_secret_key: str, - method: str, - data: bytes, - request_path: str, - base_vws_url: str, - request_timeout_seconds: float | tuple[float, float], -) -> Response: - """Make a request to the Vuforia Target API. - - This uses `requests` to make a request against https://vws.vuforia.com. - - Args: - content_type: The content type of the request. - server_access_key: A VWS server access key. - server_secret_key: A VWS server secret key. - method: The HTTP method which will be used in the request. - data: The request body which will be used in the request. - request_path: The path to the endpoint which will be used in the - request. - base_vws_url: The base URL for the VWS API. - request_timeout_seconds: The timeout for the request, as used by - ``requests.request``. This can be a float to set both the - connect and read timeouts, or a (connect, read) tuple. - - Returns: - The response to the request made by `requests`. - """ - date_string = rfc_1123_date() - - signature_string = authorization_header( - access_key=server_access_key, - secret_key=server_secret_key, - method=method, - content=data, - content_type=content_type, - date=date_string, - request_path=request_path, - ) - - headers = { - "Authorization": signature_string, - "Date": date_string, - "Content-Type": content_type, - } - - url = urljoin(base=base_vws_url, url=request_path) - - requests_response = requests.request( - method=method, - url=url, - headers=headers, - data=data, - timeout=request_timeout_seconds, - ) - - return Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - ) - - @beartype(conf=BeartypeConf(is_pep484_tower=True)) class VWS: """An interface to Vuforia Web Services APIs.""" @@ -162,6 +91,7 @@ def make_request( request_path: str, expected_result_code: str, content_type: str, + extra_headers: dict[str, str] | None = None, ) -> Response: """Make a request to the Vuforia Target API. @@ -175,6 +105,7 @@ def make_request( expected_result_code: See "VWS API Result Codes" on https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. content_type: The content type of the request. + extra_headers: Additional headers to include in the request. Returns: The response to the request made by `requests`. @@ -188,7 +119,7 @@ def make_request( This may happen if the server address is not a valid Vuforia server. """ - response = _target_api_request( + response = target_api_request( content_type=content_type, server_access_key=self._server_access_key, server_secret_key=self._server_secret_key, @@ -197,6 +128,7 @@ def make_request( request_path=request_path, base_vws_url=self._base_vws_url, request_timeout_seconds=self._request_timeout_seconds, + extra_headers=extra_headers or {}, ) if ( @@ -218,6 +150,7 @@ def make_request( exception = { "AuthenticationFailure": AuthenticationFailureError, "BadImage": BadImageError, + "BadRequest": BadRequestError, "DateRangeError": DateRangeError, "Fail": FailError, "ImageTooLarge": ImageTooLargeError, diff --git a/tests/conftest.py b/tests/conftest.py index 9073a8c03..0f15e6a9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,9 +7,10 @@ import pytest from mock_vws import MockVWS -from mock_vws.database import CloudDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.target import VuMarkTarget -from vws import VWS, CloudRecoService +from vws import VWS, CloudRecoService, VuMarkService @pytest.fixture(name="_mock_database") @@ -22,6 +23,34 @@ def fixture_mock_database() -> Generator[CloudDatabase]: yield database +@pytest.fixture(name="_mock_vumark_database") +def fixture_mock_vumark_database() -> Generator[VuMarkDatabase]: + """Yield a mock ``VuMarkDatabase`` with a template target.""" + vumark_target = VuMarkTarget(name="vumark-template") + with MockVWS() as mock: + database = VuMarkDatabase(vumark_targets={vumark_target}) + mock.add_vumark_database(vumark_database=database) + yield database + + +@pytest.fixture +def vumark_service_client( + _mock_vumark_database: VuMarkDatabase, +) -> VuMarkService: + """A ``VuMarkService`` client which connects to a mock VuMark database.""" + return VuMarkService( + server_access_key=_mock_vumark_database.server_access_key, + server_secret_key=_mock_vumark_database.server_secret_key, + ) + + +@pytest.fixture +def vumark_target_id(_mock_vumark_database: VuMarkDatabase) -> str: + """The ID of the VuMark template target.""" + (target,) = _mock_vumark_database.vumark_targets + return target.target_id + + @pytest.fixture def vws_client(_mock_database: CloudDatabase) -> VWS: """A VWS client which connects to a mock database.""" diff --git a/tests/test_vws.py b/tests/test_vws.py index 87cdbf8f0..de8b92cab 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -13,7 +13,7 @@ from mock_vws import MockVWS from mock_vws.database import CloudDatabase -from vws import VWS, CloudRecoService +from vws import VWS, CloudRecoService, VuMarkService from vws.exceptions.custom_exceptions import TargetProcessingTimeoutError from vws.reports import ( DatabaseSummaryReport, @@ -21,6 +21,7 @@ TargetStatuses, TargetSummaryReport, ) +from vws.vumark_accept import VuMarkAccept class TestAddTarget: @@ -729,3 +730,30 @@ def test_no_fields_given( ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.update_target(target_id=target_id) + + +class TestGenerateVumarkInstance: + """Tests for generating VuMark instances.""" + + @staticmethod + @pytest.mark.parametrize( + argnames=("accept", "expected_prefix"), + argvalues=[ + pytest.param(VuMarkAccept.PNG, b"\x89PNG\r\n\x1a\n", id="png"), + pytest.param(VuMarkAccept.SVG, b"<", id="svg"), + pytest.param(VuMarkAccept.PDF, b"%PDF", id="pdf"), + ], + ) + def test_generate_vumark_instance( + vumark_service_client: VuMarkService, + vumark_target_id: str, + accept: VuMarkAccept, + expected_prefix: bytes, + ) -> None: + """The returned bytes match the requested format.""" + result = vumark_service_client.generate_vumark_instance( + target_id=vumark_target_id, + instance_id="12345", + accept=accept, + ) + assert result.startswith(expected_prefix) diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index 74374e3d0..49d0365c9 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -10,7 +10,7 @@ from mock_vws.database import CloudDatabase from mock_vws.states import States -from vws import VWS +from vws import VWS, VuMarkService from vws.exceptions.base_exceptions import VWSError from vws.exceptions.custom_exceptions import ( ServerError, @@ -18,9 +18,13 @@ from vws.exceptions.vws_exceptions import ( AuthenticationFailureError, BadImageError, + BadRequestError, DateRangeError, FailError, ImageTooLargeError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, + InvalidTargetTypeError, MetadataTooLargeError, ProjectHasNoAPIAccessError, ProjectInactiveError, @@ -33,6 +37,7 @@ TargetStatusProcessingError, UnknownTargetError, ) +from vws.vumark_accept import VuMarkAccept def test_image_too_large( @@ -339,9 +344,13 @@ def test_vwsexception_inheritance() -> None: subclasses = [ AuthenticationFailureError, BadImageError, + BadRequestError, DateRangeError, FailError, ImageTooLargeError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, + InvalidTargetTypeError, MetadataTooLargeError, ProjectInactiveError, ProjectHasNoAPIAccessError, @@ -358,6 +367,31 @@ def test_vwsexception_inheritance() -> None: assert issubclass(subclass, VWSError) +def test_invalid_instance_id( + vumark_service_client: VuMarkService, + vumark_target_id: str, +) -> None: + """ + An ``InvalidInstanceId`` exception is raised when an empty instance + ID is given. + """ + with pytest.raises(expected_exception=InvalidInstanceIdError) as exc: + vumark_service_client.generate_vumark_instance( + target_id=vumark_target_id, + instance_id="", + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +def test_invalid_target_type() -> None: + """ + See https://github.com/VWS-Python/vws-python-mock/issues/2961 for + writing this test. + """ + + def test_base_exception( vws_client: VWS, high_quality_image: io.BytesIO,