Skip to content

Commit 68742be

Browse files
committed
Add 'Bucket.generate_signed_url' method.
1 parent 734a9b3 commit 68742be

3 files changed

Lines changed: 289 additions & 2 deletions

File tree

storage/google/cloud/storage/blob.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ def generate_signed_url(
350350
two must be passed.
351351
352352
:type api_access_endpoint: str
353-
:param api_access_endpoint: Optional URI base. Defaults to empty string.
353+
:param api_access_endpoint: Optional URI base.
354354
355355
:type method: str
356356
:param method: The HTTP verb that will be used when requesting the URL.

storage/google/cloud/storage/bucket.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
from google.cloud.storage._helpers import _PropertyMixin
3434
from google.cloud.storage._helpers import _scalar_property
3535
from google.cloud.storage._helpers import _validate_name
36+
from google.cloud.storage._signing import generate_signed_url_v2
37+
from google.cloud.storage._signing import generate_signed_url_v4
3638
from google.cloud.storage.acl import BucketACL
3739
from google.cloud.storage.acl import DefaultObjectACL
3840
from google.cloud.storage.blob import Blob
@@ -45,6 +47,7 @@
4547
"valid before the bucket is created. Instead, pass the location "
4648
"to `Bucket.create`."
4749
)
50+
_API_ACCESS_ENDPOINT = "https://storage.googleapis.com"
4851

4952

5053
def _blobs_page_start(iterator, page, response):
@@ -1969,3 +1972,118 @@ def lock_retention_policy(self, client=None):
19691972
method="POST", path=path, query_params=query_params, _target_object=self
19701973
)
19711974
self._set_properties(api_response)
1975+
1976+
def generate_signed_url(
1977+
self,
1978+
expiration=None,
1979+
max_age=None,
1980+
api_access_endpoint=_API_ACCESS_ENDPOINT,
1981+
method="GET",
1982+
headers=None,
1983+
query_parameters=None,
1984+
client=None,
1985+
credentials=None,
1986+
version="v2",
1987+
):
1988+
"""Generates a signed URL for this bucket.
1989+
1990+
.. note::
1991+
1992+
If you are on Google Compute Engine, you can't generate a signed
1993+
URL using GCE service account. Follow `Issue 50`_ for updates on
1994+
this. If you'd like to be able to generate a signed URL from GCE,
1995+
you can use a standard service account from a JSON file rather
1996+
than a GCE service account.
1997+
1998+
.. _Issue 50: https://github.com/GoogleCloudPlatform/\
1999+
google-auth-library-python/issues/50
2000+
2001+
If you have a bucket that you want to allow access to for a set
2002+
amount of time, you can use this method to generate a URL that
2003+
is only valid within a certain time period.
2004+
2005+
This is particularly useful if you don't want publicly
2006+
accessible buckets, but don't want to require users to explicitly
2007+
log in.
2008+
2009+
:type expiration: Union[Integer, datetime.datetime, datetime.timedelta]
2010+
:param expiration: Point in time when the signed URL should expire.
2011+
Exclusive with :arg:`max_age`: exactly one of the
2012+
two must be passed.
2013+
2014+
:type max_age: Integer
2015+
:param max_age: Max number of seconds until the signature expires.
2016+
Exclusive with :arg:`expiration`: exactly one of the
2017+
two must be passed.
2018+
2019+
:type api_access_endpoint: str
2020+
:param api_access_endpoint: Optional URI base.
2021+
2022+
:type method: str
2023+
:param method: The HTTP verb that will be used when requesting the URL.
2024+
2025+
:type headers: dict
2026+
:param headers:
2027+
(Optional) Additional HTTP headers to be included as part of the
2028+
signed URLs. See:
2029+
https://cloud.google.com/storage/docs/xml-api/reference-headers
2030+
Requests using the signed URL *must* pass the specified header
2031+
(name and value) with each request for the URL.
2032+
2033+
:type query_parameters: dict
2034+
:param query_parameters:
2035+
(Optional) Additional query paramtersto be included as part of the
2036+
signed URLs. See:
2037+
https://cloud.google.com/storage/docs/xml-api/reference-headers#query
2038+
2039+
:type client: :class:`~google.cloud.storage.client.Client` or
2040+
``NoneType``
2041+
:param client: (Optional) The client to use. If not passed, falls back
2042+
to the ``client`` stored on the blob's bucket.
2043+
2044+
2045+
:type credentials: :class:`oauth2client.client.OAuth2Credentials` or
2046+
:class:`NoneType`
2047+
:param credentials: (Optional) The OAuth2 credentials to use to sign
2048+
the URL. Defaults to the credentials stored on the
2049+
client used.
2050+
2051+
:type version: str
2052+
:param version: (Optional) The version of signed credential to create.
2053+
Must be one of 'v2' | 'v4'.
2054+
2055+
:raises: :exc:`ValueError` when version is invalid.
2056+
:raises: :exc:`ValueError` when both :arg:`expiration` and
2057+
:arg:`max_age` are passed, or when neither is passed.
2058+
:raises: :exc:`TypeError` when expiration is not a valid type.
2059+
:raises: :exc:`AttributeError` if credentials is not an instance
2060+
of :class:`google.auth.credentials.Signing`.
2061+
2062+
:rtype: str
2063+
:returns: A signed URL you can use to access the resource
2064+
until expiration.
2065+
"""
2066+
if version not in ("v2", "v4"):
2067+
raise ValueError("'version' must be either 'v2' or 'v4'")
2068+
2069+
resource = "/{bucket_name}".format(bucket_name=self.name)
2070+
2071+
if credentials is None:
2072+
client = self._require_client(client)
2073+
credentials = client._credentials
2074+
2075+
if version == "v2":
2076+
helper = generate_signed_url_v2
2077+
else:
2078+
helper = generate_signed_url_v4
2079+
2080+
return helper(
2081+
credentials,
2082+
resource=resource,
2083+
expiration=expiration,
2084+
max_age=max_age,
2085+
api_access_endpoint=api_access_endpoint,
2086+
method=method.upper(),
2087+
headers=headers,
2088+
query_parameters=query_parameters,
2089+
)

storage/tests/unit/test_bucket.py

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2573,6 +2573,168 @@ def test_lock_retention_policy_w_user_project(self):
25732573
{"ifMetagenerationMatch": 1234, "userProject": user_project},
25742574
)
25752575

2576+
def test_generate_signed_url_w_invalid_version(self):
2577+
expiration = "2014-10-16T20:34:37.000Z"
2578+
connection = _Connection()
2579+
client = _Client(connection)
2580+
bucket = self._make_one(name="bucket_name", client=client)
2581+
with self.assertRaises(ValueError):
2582+
bucket.generate_signed_url(expiration, version="nonesuch")
2583+
2584+
def _generate_signed_url_helper(
2585+
self,
2586+
version,
2587+
bucket_name="bucket-name",
2588+
api_access_endpoint=None,
2589+
method="GET",
2590+
content_md5=None,
2591+
content_type=None,
2592+
response_type=None,
2593+
response_disposition=None,
2594+
generation=None,
2595+
headers=None,
2596+
query_parameters=None,
2597+
credentials=None,
2598+
expiration=None,
2599+
max_age=None,
2600+
):
2601+
from six.moves.urllib import parse
2602+
from google.cloud._helpers import UTC
2603+
from google.cloud.storage.blob import _API_ACCESS_ENDPOINT
2604+
2605+
api_access_endpoint = api_access_endpoint or _API_ACCESS_ENDPOINT
2606+
2607+
delta = datetime.timedelta(hours=1)
2608+
2609+
if expiration is None and max_age is None:
2610+
expiration = datetime.datetime.utcnow().replace(tzinfo=UTC) + delta
2611+
2612+
connection = _Connection()
2613+
client = _Client(connection)
2614+
bucket = self._make_one(name=bucket_name, client=client)
2615+
to_patch = "google.cloud.storage.bucket.generate_signed_url_{}".format(version)
2616+
2617+
with mock.patch(to_patch) as signer:
2618+
signed_uri = bucket.generate_signed_url(
2619+
expiration=expiration,
2620+
max_age=max_age,
2621+
api_access_endpoint=api_access_endpoint,
2622+
method=method,
2623+
credentials=credentials,
2624+
headers=headers,
2625+
query_parameters=query_parameters,
2626+
version=version,
2627+
)
2628+
2629+
self.assertEqual(signed_uri, signer.return_value)
2630+
2631+
if credentials is None:
2632+
expected_creds = client._credentials
2633+
else:
2634+
expected_creds = credentials
2635+
2636+
encoded_name = bucket_name.encode("utf-8")
2637+
expected_resource = "/{}".format(parse.quote(encoded_name))
2638+
expected_kwargs = {
2639+
"resource": expected_resource,
2640+
"expiration": expiration,
2641+
"max_age": max_age,
2642+
"api_access_endpoint": api_access_endpoint,
2643+
"method": method.upper(),
2644+
"headers": headers,
2645+
"query_parameters": query_parameters,
2646+
}
2647+
signer.assert_called_once_with(expected_creds, **expected_kwargs)
2648+
2649+
def _generate_signed_url_v2_helper(self, **kw):
2650+
version = "v2"
2651+
self._generate_signed_url_helper(version, **kw)
2652+
2653+
def test_generate_signed_url_v2_w_defaults(self):
2654+
self._generate_signed_url_v2_helper()
2655+
2656+
def test_generate_signed_url_v2_w_expiration(self):
2657+
from google.cloud._helpers import UTC
2658+
2659+
expiration = datetime.datetime.utcnow().replace(tzinfo=UTC)
2660+
self._generate_signed_url_v2_helper(expiration=expiration)
2661+
2662+
def test_generate_signed_url_v2_w_max_age(self):
2663+
self._generate_signed_url_v2_helper(max_age=3600)
2664+
2665+
def test_generate_signed_url_v2_w_endpoint(self):
2666+
self._generate_signed_url_v2_helper(
2667+
api_access_endpoint="https://api.example.com/v1"
2668+
)
2669+
2670+
def test_generate_signed_url_v2_w_method(self):
2671+
self._generate_signed_url_v2_helper(method="POST")
2672+
2673+
def test_generate_signed_url_v2_w_lowercase_method(self):
2674+
self._generate_signed_url_v2_helper(method="get")
2675+
2676+
def test_generate_signed_url_v2_w_content_md5(self):
2677+
self._generate_signed_url_v2_helper(content_md5="FACEDACE")
2678+
2679+
def test_generate_signed_url_v2_w_content_type(self):
2680+
self._generate_signed_url_v2_helper(content_type="text.html")
2681+
2682+
def test_generate_signed_url_v2_w_response_type(self):
2683+
self._generate_signed_url_v2_helper(response_type="text.html")
2684+
2685+
def test_generate_signed_url_v2_w_response_disposition(self):
2686+
self._generate_signed_url_v2_helper(response_disposition="inline")
2687+
2688+
def test_generate_signed_url_v2_w_generation(self):
2689+
self._generate_signed_url_v2_helper(generation=12345)
2690+
2691+
def test_generate_signed_url_v2_w_headers(self):
2692+
self._generate_signed_url_v2_helper(headers={"x-goog-foo": "bar"})
2693+
2694+
def test_generate_signed_url_v2_w_credentials(self):
2695+
credentials = object()
2696+
self._generate_signed_url_v2_helper(credentials=credentials)
2697+
2698+
def _generate_signed_url_v4_helper(self, **kw):
2699+
version = "v4"
2700+
self._generate_signed_url_helper(version, **kw)
2701+
2702+
def test_generate_signed_url_v4_w_defaults(self):
2703+
self._generate_signed_url_v2_helper()
2704+
2705+
def test_generate_signed_url_v4_w_endpoint(self):
2706+
self._generate_signed_url_v4_helper(
2707+
api_access_endpoint="https://api.example.com/v1"
2708+
)
2709+
2710+
def test_generate_signed_url_v4_w_method(self):
2711+
self._generate_signed_url_v4_helper(method="POST")
2712+
2713+
def test_generate_signed_url_v4_w_lowercase_method(self):
2714+
self._generate_signed_url_v4_helper(method="get")
2715+
2716+
def test_generate_signed_url_v4_w_content_md5(self):
2717+
self._generate_signed_url_v4_helper(content_md5="FACEDACE")
2718+
2719+
def test_generate_signed_url_v4_w_content_type(self):
2720+
self._generate_signed_url_v4_helper(content_type="text.html")
2721+
2722+
def test_generate_signed_url_v4_w_response_type(self):
2723+
self._generate_signed_url_v4_helper(response_type="text.html")
2724+
2725+
def test_generate_signed_url_v4_w_response_disposition(self):
2726+
self._generate_signed_url_v4_helper(response_disposition="inline")
2727+
2728+
def test_generate_signed_url_v4_w_generation(self):
2729+
self._generate_signed_url_v4_helper(generation=12345)
2730+
2731+
def test_generate_signed_url_v4_w_headers(self):
2732+
self._generate_signed_url_v4_helper(headers={"x-goog-foo": "bar"})
2733+
2734+
def test_generate_signed_url_v4_w_credentials(self):
2735+
credentials = object()
2736+
self._generate_signed_url_v4_helper(credentials=credentials)
2737+
25762738

25772739
class _Connection(object):
25782740
_delete_bucket = False
@@ -2612,6 +2774,13 @@ def api_request(self, **kw):
26122774

26132775
class _Client(object):
26142776
def __init__(self, connection, project=None):
2615-
self._connection = connection
26162777
self._base_connection = connection
26172778
self.project = project
2779+
2780+
@property
2781+
def _connection(self):
2782+
return self._base_connection
2783+
2784+
@property
2785+
def _credentials(self):
2786+
return self._base_connection.credentials

0 commit comments

Comments
 (0)