1 # Copyright (C) The Arvados Authors. All rights reserved.
3 # SPDX-License-Identifier: Apache-2.0
5 from __future__ import absolute_import
6 from future import standard_library
7 standard_library.install_aliases()
8 from builtins import range
21 from apiclient import discovery as apiclient_discovery
22 from apiclient import errors as apiclient_errors
28 _logger = logging.getLogger('arvados.api')
30 MAX_IDLE_CONNECTION_DURATION = 30
31 RETRY_DELAY_INITIAL = 2
32 RETRY_DELAY_BACKOFF = 2
35 class OrderedJsonModel(apiclient.model.JsonModel):
36 """Model class for JSON that preserves the contents' order.
38 API clients that care about preserving the order of fields in API
39 server responses can use this model to do so, like this::
41 from arvados.api import OrderedJsonModel
42 client = arvados.api('v1', ..., model=OrderedJsonModel())
45 def deserialize(self, content):
46 # This is a very slightly modified version of the parent class'
47 # implementation. Copyright (c) 2010 Google.
48 content = content.decode('utf-8')
49 body = json.loads(content, object_pairs_hook=collections.OrderedDict)
50 if self._data_wrapper and isinstance(body, dict) and 'data' in body:
55 def _intercept_http_request(self, uri, method="GET", **kwargs):
56 if (self.max_request_size and
57 kwargs.get('body') and
58 self.max_request_size < len(kwargs['body'])):
59 raise apiclient_errors.MediaUploadSizeError("Request size %i bytes exceeds published limit of %i bytes" % (len(kwargs['body']), self.max_request_size))
61 if 'headers' not in kwargs:
62 kwargs['headers'] = {}
64 if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
65 kwargs['headers']['X-External-Client'] = '1'
67 kwargs['headers']['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
69 retryable = method in [
70 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
71 retry_count = self._retry_count if retryable else 0
74 time.time() - self._last_request_time > self._max_keepalive_idle):
75 # High probability of failure due to connection atrophy. Make
76 # sure this request [re]opens a new connection by closing and
77 # forgetting all cached connections first.
78 for conn in self.connections.values():
80 self.connections.clear()
82 delay = self._retry_delay_initial
83 for _ in range(retry_count):
84 self._last_request_time = time.time()
86 return self.orig_http_request(uri, method, **kwargs)
87 except http.client.HTTPException:
88 _logger.debug("Retrying API request in %d s after HTTP error",
91 # This is the one case where httplib2 doesn't close the
92 # underlying connection first. Close all open
93 # connections, expecting this object only has the one
94 # connection to the API server. This is safe because
95 # httplib2 reopens connections when needed.
96 _logger.debug("Retrying API request in %d s after socket error",
98 for conn in self.connections.values():
101 delay = delay * self._retry_delay_backoff
103 self._last_request_time = time.time()
104 return self.orig_http_request(uri, method, **kwargs)
106 def _patch_http_request(http, api_token):
107 http.arvados_api_token = api_token
108 http.max_request_size = 0
109 http.orig_http_request = http.request
110 http.request = types.MethodType(_intercept_http_request, http)
111 http._last_request_time = 0
112 http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
113 http._retry_delay_initial = RETRY_DELAY_INITIAL
114 http._retry_delay_backoff = RETRY_DELAY_BACKOFF
115 http._retry_count = RETRY_COUNT
118 # Monkey patch discovery._cast() so objects and arrays get serialized
119 # with json.dumps() instead of str().
120 _cast_orig = apiclient_discovery._cast
121 def _cast_objects_too(value, schema_type):
123 if (type(value) != type('') and
124 type(value) != type(b'') and
125 (schema_type == 'object' or schema_type == 'array')):
126 return json.dumps(value)
128 return _cast_orig(value, schema_type)
129 apiclient_discovery._cast = _cast_objects_too
131 # Convert apiclient's HttpErrors into our own API error subclass for better
133 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
134 # apiclient submodules import the class into their own namespace.
135 def _new_http_error(cls, *args, **kwargs):
136 return super(apiclient_errors.HttpError, cls).__new__(
137 errors.ApiError, *args, **kwargs)
138 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
140 def http_cache(data_type):
141 homedir = os.environ.get('HOME')
142 if not homedir or len(homedir) == 0:
144 path = homedir + '/.cache/arvados/' + data_type
146 util.mkdir_dash_p(path)
149 return cache.SafeHTTPCache(path, max_age=60*60*24*2)
151 def api(version=None, cache=True, host=None, token=None, insecure=False, **kwargs):
152 """Return an apiclient Resources object for an Arvados instance.
155 A string naming the version of the Arvados API to use (for
159 Use a cache (~/.cache/arvados/discovery) for the discovery
163 The Arvados API server host (and optional :port) to connect to.
166 The authentication token to send with each API call.
169 If True, ignore SSL certificate validation errors.
171 Additional keyword arguments will be passed directly to
172 `apiclient_discovery.build` if a new Resource object is created.
173 If the `discoveryServiceUrl` or `http` keyword arguments are
174 missing, this function will set default values for them, based on
175 the current Arvados configuration settings.
181 _logger.info("Using default API version. " +
182 "Call arvados.api('%s') instead." %
184 if 'discoveryServiceUrl' in kwargs:
186 raise ValueError("both discoveryServiceUrl and host provided")
187 # Here we can't use a token from environment, config file,
188 # etc. Those probably have nothing to do with the host
189 # provided by the caller.
191 raise ValueError("discoveryServiceUrl provided, but token missing")
194 elif not host and not token:
195 return api_from_config(version=version, cache=cache, **kwargs)
197 # Caller provided one but not the other
199 raise ValueError("token argument provided, but host missing.")
201 raise ValueError("host argument provided, but token missing.")
204 # Caller wants us to build the discoveryServiceUrl
205 kwargs['discoveryServiceUrl'] = (
206 'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
208 if 'http' not in kwargs:
209 http_kwargs = {'ca_certs': util.ca_certs_path()}
211 http_kwargs['cache'] = http_cache('discovery')
213 http_kwargs['disable_ssl_certificate_validation'] = True
214 kwargs['http'] = httplib2.Http(**http_kwargs)
216 kwargs['http'] = _patch_http_request(kwargs['http'], token)
218 svc = apiclient_discovery.build('arvados', version, cache_discovery=False, **kwargs)
219 svc.api_token = token
220 svc.insecure = insecure
221 kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
222 kwargs['http'].cache = None
225 def api_from_config(version=None, apiconfig=None, **kwargs):
226 """Return an apiclient Resources object enabling access to an Arvados server
230 A string naming the version of the Arvados REST API to use (for
234 If provided, this should be a dict-like object (must support the get()
235 method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
236 optionally ARVADOS_API_HOST_INSECURE. If not provided, use
237 arvados.config (which gets these parameters from the environment by
240 Other keyword arguments such as `cache` will be passed along `api()`
243 # Load from user configuration or environment
244 if apiconfig is None:
245 apiconfig = config.settings()
247 for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
248 if x not in apiconfig:
249 raise ValueError("%s is not set. Aborting." % x)
250 host = apiconfig.get('ARVADOS_API_HOST')
251 token = apiconfig.get('ARVADOS_API_TOKEN')
252 insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
254 return api(version=version, host=host, token=token, insecure=insecure, **kwargs)