diff --git a/googleapiclient/discovery.py b/googleapiclient/discovery.py index e1f7ee48df..e2090c0c84 100644 --- a/googleapiclient/discovery.py +++ b/googleapiclient/discovery.py @@ -91,11 +91,13 @@ URITEMPLATE = re.compile("{[^}]*}") VARNAME = re.compile("[a-zA-Z0-9_-]+") DISCOVERY_URI = ( - "https://www.googleapis.com/discovery/v1/apis/" "{api}/{apiVersion}/rest" + "https://www.googleapis.com/discovery/v1/apis/" + "{api}/{apiVersion}/rest{?labels*}" ) V1_DISCOVERY_URI = DISCOVERY_URI V2_DISCOVERY_URI = ( - "https://{api}.googleapis.com/$discovery/rest?" "version={apiVersion}" + "https://{api}.googleapis.com/$discovery/rest?" + "version={apiVersion}{&labels*}" ) DEFAULT_METHOD_DOC = "A description of how to use this function" HTTP_PAYLOAD_METHODS = frozenset(["PUT", "POST", "PATCH"]) @@ -207,6 +209,7 @@ def build( num_retries=1, static_discovery=None, always_use_jwt_access=False, + labels=None, ): """Construct a Resource for interacting with an API. @@ -268,6 +271,11 @@ def build( always_use_jwt_access: Boolean, whether always use self signed JWT for service account credentials. This only applies to google.oauth2.service_account.Credentials. + labels: list of strings, an optional list of visibility labels (e.g., + ['PREVIEW', 'GOOGLE_INTERNAL']) to filter the discovery document and + restrict/downgrade visibility context of subsequent requests. Note: + while the client supports multiple labels, Google API servers may + not currently support multiple labels simultaneously. Returns: A Resource object with methods for interacting with the service. @@ -276,7 +284,21 @@ def build( google.auth.exceptions.MutualTLSChannelError: if there are any problems setting up mutual TLS channel. """ + if labels is not None: + if isinstance(labels, str): + labels = [labels] + else: + try: + labels_list = list(labels) + except TypeError: + raise ValueError("labels must be a string or an iterable of strings") + if not all(isinstance(label, str) and label for label in labels_list): + raise ValueError("labels must be a string or an iterable of non-empty strings") + labels = sorted(list(set(labels_list))) + params = {"api": serviceName, "apiVersion": version} + if labels: + params["labels"] = labels # The default value for `static_discovery` depends on the value of # `discoveryServiceUrl`. `static_discovery` will default to `True` when @@ -285,7 +307,7 @@ def build( # google-api-python-client 1.x which does not support the `static_discovery` # parameter. if static_discovery is None: - if discoveryServiceUrl is None: + if discoveryServiceUrl is None and labels is None: static_discovery = True else: static_discovery = False @@ -324,6 +346,7 @@ def build( adc_cert_path=adc_cert_path, adc_key_path=adc_key_path, always_use_jwt_access=always_use_jwt_access, + labels=labels, ) break # exit if a service was created except HttpError as e: @@ -474,6 +497,7 @@ def build_from_document( adc_cert_path=None, adc_key_path=None, always_use_jwt_access=False, + labels=None, ): """Create a Resource for interacting with an API. @@ -526,6 +550,10 @@ def build_from_document( always_use_jwt_access: Boolean, whether always use self signed JWT for service account credentials. This only applies to google.oauth2.service_account.Credentials. + labels: list of strings, an optional list of visibility labels (e.g., + ['PREVIEW', 'GOOGLE_INTERNAL']) to restrict/downgrade visibility context + of requests. Note: while the client supports multiple labels, Google + API servers may not currently support multiple labels simultaneously. Returns: A Resource object with methods for interacting with the service. @@ -535,6 +563,18 @@ def build_from_document( setting up mutual TLS channel. """ + if labels is not None: + if isinstance(labels, str): + labels = [labels] + else: + try: + labels_list = list(labels) + except TypeError: + raise ValueError("labels must be a string or an iterable of strings") + if not all(isinstance(label, str) and label for label in labels_list): + raise ValueError("labels must be a string or an iterable of non-empty strings") + labels = sorted(list(set(labels_list))) + if client_options is None: client_options = google.api_core.client_options.ClientOptions() if isinstance(client_options, collections.abc.Mapping): @@ -736,6 +776,7 @@ def build_from_document( rootDesc=service, schema=schema, universe_domain=universe_domain, + labels=labels, ) @@ -1180,8 +1221,14 @@ def method(self, **kwargs): api_version = methodDesc.get("apiVersion", None) headers = {} + if getattr(self, "_labels", None): + headers["X-Goog-Visibilities"] = ",".join(self._labels) headers, params, query, body = model.request( - headers, actual_path_params, actual_query_params, body_value, api_version + headers, + actual_path_params, + actual_query_params, + body_value, + api_version, ) expanded_url = uritemplate.expand(pathUrl, params) @@ -1413,6 +1460,7 @@ def __init__( rootDesc, schema, universe_domain=universe.DEFAULT_UNIVERSE if HAS_UNIVERSE else "", + labels=None, ): """Build a Resource from the API description. @@ -1445,6 +1493,7 @@ def __init__( self._schema = schema self._universe_domain = universe_domain self._credentials_validated = False + self._labels = labels self._set_service_methods() @@ -1568,6 +1617,7 @@ def methodResource(self): rootDesc=rootDesc, schema=schema, universe_domain=self._universe_domain, + labels=getattr(self, "_labels", None), ) setattr(methodResource, "__doc__", "A collection resource.") diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2d74ce6dfc..9fca8e5891 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -2302,6 +2302,7 @@ def test_pickle(self): "_developerKey", "_dynamic_attrs", "_http", + "_labels", "_model", "_requestBuilder", "_resourceDesc", @@ -2430,6 +2431,205 @@ def test_resumable_media_upload_no_content(self): self.assertTrue(isinstance(status, MediaUploadProgress)) self.assertEqual(0, status.progress()) + @mock.patch("googleapiclient.discovery._retrieve_discovery_doc") + def test_discovery_with_labels(self, mock_retrieve): + mock_retrieve.return_value = read_datafile("zoo.json") + + zoo = build( + "zoo", + "v1", + labels=["PREVIEW", "GOOGLE_INTERNAL"], + ) + + # Verify that the labels were coerced to a sorted list + self.assertEqual(zoo._labels, ["GOOGLE_INTERNAL", "PREVIEW"]) + + # Verify that _retrieve_discovery_doc was called with the sorted labels in the URL query string + mock_retrieve.assert_called_once() + args, kwargs = mock_retrieve.call_args + requested_url = args[0] + self.assertEqual( + requested_url, + "https://www.googleapis.com/discovery/v1/apis/zoo/v1/rest?labels=GOOGLE_INTERNAL&labels=PREVIEW", + ) + + # Verify that subsequent request objects generated by the Resource have X-Goog-Visibilities header set with sorted labels + request = zoo.animals().get(name="Lion") + self.assertEqual( + request.headers["X-Goog-Visibilities"], "GOOGLE_INTERNAL,PREVIEW" + ) + + def test_discovery_with_labels_and_cache_miss(self): + mock_cache = mock.Mock() + mock_cache.get.return_value = None + + # We need to mock the HTTP request to return the discovery doc + mock_http = mock.Mock() + mock_http.request.return_value = ( + httplib2.Response({"status": "200"}), + read_datafile("zoo.json").encode("utf-8"), + ) + + zoo = build( + "zoo", + "v1", + http=mock_http, + cache=mock_cache, + cache_discovery=True, + labels=["PREVIEW", "GOOGLE_INTERNAL"], + ) + + # Expected URL must have sorted labels + expected_url = "https://www.googleapis.com/discovery/v1/apis/zoo/v1/rest?labels=GOOGLE_INTERNAL&labels=PREVIEW" + + # Verify cache get was called with the expected URL + mock_cache.get.assert_called_once_with(expected_url) + + # Verify HTTP request was made (since cache missed) + mock_http.request.assert_called_once() + + # Verify cache set was called with the expected URL and content + mock_cache.set.assert_called_once_with( + expected_url, read_datafile("zoo.json") + ) + + def test_discovery_with_labels_and_cache_hit(self): + mock_cache = mock.Mock() + mock_cache.get.return_value = read_datafile("zoo.json") + + # HTTP should NOT be called + mock_http = mock.Mock() + + zoo = build( + "zoo", + "v1", + http=mock_http, + cache=mock_cache, + cache_discovery=True, + labels=["PREVIEW", "GOOGLE_INTERNAL"], + ) + + # Expected URL must have sorted labels + expected_url = "https://www.googleapis.com/discovery/v1/apis/zoo/v1/rest?labels=GOOGLE_INTERNAL&labels=PREVIEW" + + # Verify cache get was called with the expected URL + mock_cache.get.assert_called_once_with(expected_url) + + # Verify HTTP request was NOT made (cache hit) + mock_http.request.assert_not_called() + + # Verify cache set was NOT called + mock_cache.set.assert_not_called() + + @mock.patch("googleapiclient.discovery._retrieve_discovery_doc") + def test_discovery_with_labels_coercion_string(self, mock_retrieve): + mock_retrieve.return_value = read_datafile("zoo.json") + + # Pass a single string instead of a list + zoo = build("zoo", "v1", labels="PREVIEW") + + self.assertEqual(zoo._labels, ["PREVIEW"]) + mock_retrieve.assert_called_once() + args, _ = mock_retrieve.call_args + self.assertEqual( + args[0], + "https://www.googleapis.com/discovery/v1/apis/zoo/v1/rest?labels=PREVIEW", + ) + + @mock.patch("googleapiclient.discovery._retrieve_discovery_doc") + def test_discovery_with_labels_coercion_set_unsorted(self, mock_retrieve): + mock_retrieve.return_value = read_datafile("zoo.json") + + # Pass an unsorted set + zoo = build("zoo", "v1", labels={"PREVIEW", "GOOGLE_INTERNAL"}) + + # Should be coerced to a sorted list + self.assertEqual(zoo._labels, ["GOOGLE_INTERNAL", "PREVIEW"]) + mock_retrieve.assert_called_once() + args, _ = mock_retrieve.call_args + self.assertEqual( + args[0], + "https://www.googleapis.com/discovery/v1/apis/zoo/v1/rest?labels=GOOGLE_INTERNAL&labels=PREVIEW", + ) + + def test_discovery_with_labels_coercion_invalid(self): + # Pass an invalid non-iterable type + with self.assertRaises(ValueError): + build("zoo", "v1", labels=123) + + # Pass an iterable with invalid types (non-sortable together) + with self.assertRaises(ValueError): + build("zoo", "v1", labels=["A", 1]) + + # Pass an iterable of non-string types + with self.assertRaises(ValueError): + build("zoo", "v1", labels=[1, 2]) + + # Pass an iterable with an empty string + with self.assertRaises(ValueError): + build("zoo", "v1", labels=["PREVIEW", ""]) + + @mock.patch("googleapiclient.discovery._retrieve_discovery_doc") + def test_discovery_with_labels_deduplication(self, mock_retrieve): + mock_retrieve.return_value = read_datafile("zoo.json") + + # Pass duplicates in labels + zoo = build( + "zoo", + "v1", + developerKey="123", + labels=["PREVIEW", "GOOGLE_INTERNAL", "PREVIEW"], + ) + + # Verify that the labels were deduplicated and sorted + self.assertEqual(zoo._labels, ["GOOGLE_INTERNAL", "PREVIEW"]) + + mock_retrieve.assert_called_once() + args, _ = mock_retrieve.call_args + # URL should only have one of each label + self.assertEqual( + args[0], + "https://www.googleapis.com/discovery/v1/apis/zoo/v1/rest?labels=GOOGLE_INTERNAL&labels=PREVIEW", + ) + + @mock.patch("googleapiclient.discovery._retrieve_discovery_doc") + def test_discovery_with_labels_v2_fallback(self, mock_retrieve): + v1_url = "https://www.googleapis.com/discovery/v1/apis/zoo/v1/rest?labels=GOOGLE_INTERNAL&labels=PREVIEW" + v2_url = "https://zoo.googleapis.com/$discovery/rest?version=v1&labels=GOOGLE_INTERNAL&labels=PREVIEW" + + # Mock side effect: V1 fails with 404, V2 succeeds + def retrieve_side_effect(url, *args, **kwargs): + if url == v1_url: + resp = httplib2.Response({"status": "404"}) + raise HttpError(resp, b"Not Found") + elif url == v2_url: + return read_datafile("zoo.json") + else: + self.fail(f"Unexpected URL requested: {url}") + + mock_retrieve.side_effect = retrieve_side_effect + + zoo = build( + "zoo", + "v1", + labels=["PREVIEW", "GOOGLE_INTERNAL"], + ) + + # Verify that the labels were coerced to a sorted list + self.assertEqual(zoo._labels, ["GOOGLE_INTERNAL", "PREVIEW"]) + + # Verify that _retrieve_discovery_doc was called twice (first V1, then V2) + self.assertEqual(mock_retrieve.call_count, 2) + call_args_list = mock_retrieve.call_args_list + self.assertEqual(call_args_list[0][0][0], v1_url) + self.assertEqual(call_args_list[1][0][0], v2_url) + + # Verify that subsequent request objects generated by the Resource have X-Goog-Visibilities header set with sorted labels + request = zoo.animals().get(name="Lion") + self.assertEqual( + request.headers["X-Goog-Visibilities"], "GOOGLE_INTERNAL,PREVIEW" + ) + class Next(unittest.TestCase): def test_next_successful_none_on_no_next_page_token(self):