diff --git a/CHANGELOG.md b/CHANGELOG.md index 41bd620..0b347c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- Add Address Validation feedback submission with the first response ID and a validation conclusion. + ### Fixed - Raise API or HTTP exceptions for unsuccessful Address Validation responses instead of returning error payloads. diff --git a/googlemaps/addressvalidation.py b/googlemaps/addressvalidation.py index d77c9ce..97db4e4 100644 --- a/googlemaps/addressvalidation.py +++ b/googlemaps/addressvalidation.py @@ -42,6 +42,13 @@ def _addressvalidation_extract(response): raise exceptions.HTTPError(response.status_code) +def _addressvalidation_feedback_extract(response): + """Extract feedback without parsing an empty successful response.""" + if 200 <= response.status_code < 300: + return {} + return _addressvalidation_extract(response) + + def addressvalidation(client, addressLines, regionCode=None , locality=None, enableUspsCass=None): """ The Google Maps Address Validation API returns a verification of an address @@ -76,4 +83,43 @@ def addressvalidation(client, addressLines, regionCode=None , locality=None, ena base_url=_ADDRESSVALIDATION_BASE_URL, extract_body=_addressvalidation_extract, post_json=params) + + +def addressvalidation_feedback(client, response_id, conclusion): + """Provide the final outcome of an address validation sequence. + + Send feedback once, after the transaction concludes. Use the response ID + from the first validation response, not a later attempt in the sequence. + See https://developers.google.com/maps/documentation/address-validation/reference/rest/v1/TopLevel/provideValidationFeedback. + + :param response_id: The ``responseId`` from the first validation response. + :type response_id: str + + :param conclusion: One of ``VALIDATED_VERSION_USED``, ``USER_VERSION_USED``, + ``UNVALIDATED_VERSION_USED``, or ``UNUSED``. + :type conclusion: str + + :raises ValueError: If the response ID is not a nonempty string or the + conclusion is not supported. + :raises googlemaps.exceptions.ApiError: If the API returns a structured error. + :raises googlemaps.exceptions.HTTPError: If an unsuccessful response does + not contain a structured API error. + + :returns: An empty dictionary on success. + :rtype: dict + """ + if not isinstance(response_id, str) or not response_id: + raise ValueError("response_id must be a nonempty string") + if conclusion not in ( + "VALIDATED_VERSION_USED", "USER_VERSION_USED", + "UNVALIDATED_VERSION_USED", "UNUSED", + ): + raise ValueError("conclusion must be a supported validation conclusion") + + return client._request( + "/v1:provideValidationFeedback", {}, + base_url=_ADDRESSVALIDATION_BASE_URL, + extract_body=_addressvalidation_feedback_extract, + post_json={"responseId": response_id, "conclusion": conclusion}, + ) \ No newline at end of file diff --git a/googlemaps/client.py b/googlemaps/client.py index d1f4ab6..ad8be7b 100644 --- a/googlemaps/client.py +++ b/googlemaps/client.py @@ -428,6 +428,7 @@ def _generate_auth_url(self, path, params, accepts_clientid): from googlemaps.places import places_autocomplete_query from googlemaps.maps import static_map from googlemaps.addressvalidation import addressvalidation +from googlemaps.addressvalidation import addressvalidation_feedback def make_api_method(func): """ @@ -472,6 +473,7 @@ def wrapper(*args, **kwargs): Client.places_autocomplete_query = make_api_method(places_autocomplete_query) Client.static_map = make_api_method(static_map) Client.addressvalidation = make_api_method(addressvalidation) +Client.addressvalidation_feedback = make_api_method(addressvalidation_feedback) def sign_hmac(secret, payload): diff --git a/tests/test_addressvalidation.py b/tests/test_addressvalidation.py index 272cdc0..586e3e5 100644 --- a/tests/test_addressvalidation.py +++ b/tests/test_addressvalidation.py @@ -136,4 +136,106 @@ def test_transient_error_exhausts_retry_timeout(self): with self.assertRaises(googlemaps.exceptions.Timeout): self.client.addressvalidation(["1600 Amphitheatre Parkway"]) - self.assertEqual(1, len(responses.calls)) \ No newline at end of file + self.assertEqual(1, len(responses.calls)) + + +class AddressValidationFeedbackTest(TestCase): + def setUp(self): + self.client = googlemaps.Client("AIza-feedback-tests-non-secret") + self.url = ( + "https://addressvalidation.googleapis.com/v1:provideValidationFeedback" + ) + + def test_feedback_submits_supported_conclusions(self): + for conclusion in ( + "VALIDATED_VERSION_USED", "USER_VERSION_USED", + "UNVALIDATED_VERSION_USED", "UNUSED", + ): + with self.subTest(conclusion=conclusion): + with responses.RequestsMock() as http: + http.add( + responses.POST, self.url, body="", status=200, + match=[responses.matchers.json_params_matcher({ + "responseId": "initial-response-id", + "conclusion": conclusion, + })], + ) + + self.assertEqual({}, self.client.addressvalidation_feedback( + "initial-response-id", conclusion + )) + + @responses.activate + def test_feedback_accepts_no_content(self): + responses.add(responses.POST, self.url, status=204) + + self.assertEqual({}, self.client.addressvalidation_feedback( + "initial-response-id", "UNUSED" + )) + + @responses.activate + def test_feedback_rejects_invalid_response_ids_before_transport(self): + for response_id in ("", None, 123, []): + with self.subTest(response_id=response_id): + with self.assertRaises(ValueError): + self.client.addressvalidation_feedback(response_id, "UNUSED") + + self.assertEqual(0, len(responses.calls)) + + @responses.activate + def test_feedback_rejects_invalid_conclusions_before_transport(self): + for conclusion in ( + "VALIDATION_CONCLUSION_UNSPECIFIED", "UNKNOWN", + "validated_version_used", None, [], + ): + with self.subTest(conclusion=conclusion): + with self.assertRaises(ValueError): + self.client.addressvalidation_feedback( + "initial-response-id", conclusion + ) + + self.assertEqual(0, len(responses.calls)) + + @responses.activate + def test_feedback_raises_structured_api_errors(self): + responses.add( + responses.POST, self.url, status=400, + json={"error": { + "status": "INVALID_ARGUMENT", + "message": "The response ID is unknown.", + }}, + ) + + with self.assertRaises(googlemaps.exceptions.ApiError) as error: + self.client.addressvalidation_feedback("unknown-response-id", "UNUSED") + + self.assertEqual("INVALID_ARGUMENT", error.exception.status) + self.assertEqual("The response ID is unknown.", error.exception.message) + + @responses.activate + def test_feedback_raises_http_errors_for_non_json_bodies(self): + responses.add( + responses.POST, self.url, status=403, + body="Access denied", content_type="text/html", + ) + + with self.assertRaises(googlemaps.exceptions.HTTPError) as error: + self.client.addressvalidation_feedback("initial-response-id", "UNUSED") + + self.assertEqual(403, error.exception.status_code) + + @responses.activate + def test_feedback_retries_transient_errors_before_extraction(self): + responses.add( + responses.POST, self.url, body="Unavailable", status=503, + ) + responses.add(responses.POST, self.url, body="", status=200) + + with mock.patch("googlemaps.client.time.sleep"): + self.assertEqual({}, self.client.addressvalidation_feedback( + "initial-response-id", "USER_VERSION_USED" + )) + + self.assertEqual([503, 200], [ + call.response.status_code for call in responses.calls + ]) \ No newline at end of file