Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions googleapiclient/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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")
Comment thread
Capstan marked this conversation as resolved.
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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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")
Comment thread
Capstan marked this conversation as resolved.
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):
Expand Down Expand Up @@ -736,6 +776,7 @@ def build_from_document(
rootDesc=service,
schema=schema,
universe_domain=universe_domain,
labels=labels,
)


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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.")
Expand Down
200 changes: 200 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -2302,6 +2302,7 @@ def test_pickle(self):
"_developerKey",
"_dynamic_attrs",
"_http",
"_labels",
"_model",
"_requestBuilder",
"_resourceDesc",
Expand Down Expand Up @@ -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])

Comment thread
Capstan marked this conversation as resolved.
# 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):
Expand Down
Loading