1#!/usr/bin/env python
2"""Assorted utilities shared between parts of apitools."""
3
4import collections
5import os
6import random
7
8from protorpc import messages
9import six
10from six.moves import http_client
11import six.moves.urllib.error as urllib_error
12import six.moves.urllib.parse as urllib_parse
13import six.moves.urllib.request as urllib_request
14
15from apitools.base.py import encoding
16from apitools.base.py import exceptions
17
18__all__ = [
19    'DetectGae',
20    'DetectGce',
21]
22
23_RESERVED_URI_CHARS = r":/?#[]@!$&'()*+,;="
24
25
26def DetectGae():
27    """Determine whether or not we're running on GAE.
28
29    This is based on:
30      https://developers.google.com/appengine/docs/python/#The_Environment
31
32    Returns:
33      True iff we're running on GAE.
34    """
35    server_software = os.environ.get('SERVER_SOFTWARE', '')
36    return (server_software.startswith('Development/') or
37            server_software.startswith('Google App Engine/'))
38
39
40def DetectGce():
41    """Determine whether or not we're running on GCE.
42
43    This is based on:
44      https://cloud.google.com/compute/docs/metadata#runninggce
45
46    Returns:
47      True iff we're running on a GCE instance.
48    """
49    try:
50        o = urllib_request.build_opener(urllib_request.ProxyHandler({})).open(
51            urllib_request.Request('http://metadata.google.internal'))
52    except urllib_error.URLError:
53        return False
54    return (o.getcode() == http_client.OK and
55            o.headers.get('metadata-flavor') == 'Google')
56
57
58def NormalizeScopes(scope_spec):
59    """Normalize scope_spec to a set of strings."""
60    if isinstance(scope_spec, six.string_types):
61        return set(scope_spec.split(' '))
62    elif isinstance(scope_spec, collections.Iterable):
63        return set(scope_spec)
64    raise exceptions.TypecheckError(
65        'NormalizeScopes expected string or iterable, found %s' % (
66            type(scope_spec),))
67
68
69def Typecheck(arg, arg_type, msg=None):
70    if not isinstance(arg, arg_type):
71        if msg is None:
72            if isinstance(arg_type, tuple):
73                msg = 'Type of arg is "%s", not one of %r' % (
74                    type(arg), arg_type)
75            else:
76                msg = 'Type of arg is "%s", not "%s"' % (type(arg), arg_type)
77        raise exceptions.TypecheckError(msg)
78    return arg
79
80
81def ExpandRelativePath(method_config, params, relative_path=None):
82    """Determine the relative path for request."""
83    path = relative_path or method_config.relative_path or ''
84
85    for param in method_config.path_params:
86        param_template = '{%s}' % param
87        # For more details about "reserved word expansion", see:
88        #   http://tools.ietf.org/html/rfc6570#section-3.2.2
89        reserved_chars = ''
90        reserved_template = '{+%s}' % param
91        if reserved_template in path:
92            reserved_chars = _RESERVED_URI_CHARS
93            path = path.replace(reserved_template, param_template)
94        if param_template not in path:
95            raise exceptions.InvalidUserInputError(
96                'Missing path parameter %s' % param)
97        try:
98            # TODO(craigcitro): Do we want to support some sophisticated
99            # mapping here?
100            value = params[param]
101        except KeyError:
102            raise exceptions.InvalidUserInputError(
103                'Request missing required parameter %s' % param)
104        if value is None:
105            raise exceptions.InvalidUserInputError(
106                'Request missing required parameter %s' % param)
107        try:
108            if not isinstance(value, six.string_types):
109                value = str(value)
110            path = path.replace(param_template,
111                                urllib_parse.quote(value.encode('utf_8'),
112                                                   reserved_chars))
113        except TypeError as e:
114            raise exceptions.InvalidUserInputError(
115                'Error setting required parameter %s to value %s: %s' % (
116                    param, value, e))
117    return path
118
119
120def CalculateWaitForRetry(retry_attempt, max_wait=60):
121    """Calculates amount of time to wait before a retry attempt.
122
123    Wait time grows exponentially with the number of attempts. A
124    random amount of jitter is added to spread out retry attempts from
125    different clients.
126
127    Args:
128      retry_attempt: Retry attempt counter.
129      max_wait: Upper bound for wait time [seconds].
130
131    Returns:
132      Number of seconds to wait before retrying request.
133
134    """
135
136    wait_time = 2 ** retry_attempt
137    max_jitter = wait_time / 4.0
138    wait_time += random.uniform(-max_jitter, max_jitter)
139    return max(1, min(wait_time, max_wait))
140
141
142def AcceptableMimeType(accept_patterns, mime_type):
143    """Return True iff mime_type is acceptable for one of accept_patterns.
144
145    Note that this function assumes that all patterns in accept_patterns
146    will be simple types of the form "type/subtype", where one or both
147    of these can be "*". We do not support parameters (i.e. "; q=") in
148    patterns.
149
150    Args:
151      accept_patterns: list of acceptable MIME types.
152      mime_type: the mime type we would like to match.
153
154    Returns:
155      Whether or not mime_type matches (at least) one of these patterns.
156    """
157    if '/' not in mime_type:
158        raise exceptions.InvalidUserInputError(
159            'Invalid MIME type: "%s"' % mime_type)
160    unsupported_patterns = [p for p in accept_patterns if ';' in p]
161    if unsupported_patterns:
162        raise exceptions.GeneratedClientError(
163            'MIME patterns with parameter unsupported: "%s"' % ', '.join(
164                unsupported_patterns))
165
166    def MimeTypeMatches(pattern, mime_type):
167        """Return True iff mime_type is acceptable for pattern."""
168        # Some systems use a single '*' instead of '*/*'.
169        if pattern == '*':
170            pattern = '*/*'
171        return all(accept in ('*', provided) for accept, provided
172                   in zip(pattern.split('/'), mime_type.split('/')))
173
174    return any(MimeTypeMatches(pattern, mime_type)
175               for pattern in accept_patterns)
176
177
178def MapParamNames(params, request_type):
179    """Reverse parameter remappings for URL construction."""
180    return [encoding.GetCustomJsonFieldMapping(request_type, json_name=p) or p
181            for p in params]
182
183
184def MapRequestParams(params, request_type):
185    """Perform any renames/remappings needed for URL construction.
186
187    Currently, we have several ways to customize JSON encoding, in
188    particular of field names and enums. This works fine for JSON
189    bodies, but also needs to be applied for path and query parameters
190    in the URL.
191
192    This function takes a dictionary from param names to values, and
193    performs any registered mappings. We also need the request type (to
194    look up the mappings).
195
196    Args:
197      params: (dict) Map from param names to values
198      request_type: (protorpc.messages.Message) request type for this API call
199
200    Returns:
201      A new dict of the same size, with all registered mappings applied.
202    """
203    new_params = dict(params)
204    for param_name, value in params.items():
205        field_remapping = encoding.GetCustomJsonFieldMapping(
206            request_type, python_name=param_name)
207        if field_remapping is not None:
208            new_params[field_remapping] = new_params.pop(param_name)
209        if isinstance(value, messages.Enum):
210            new_params[param_name] = encoding.GetCustomJsonEnumMapping(
211                type(value), python_name=str(value)) or str(value)
212    return new_params
213