Arvados-DCO-1.1-Signed-off-by: Radhika Chippada <radhika@curoverse.com>
[arvados.git] / sdk / python / arvados / api.py
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: Apache-2.0
4
5 from __future__ import absolute_import
6 from future import standard_library
7 standard_library.install_aliases()
8 from builtins import range
9 import collections
10 import http.client
11 import httplib2
12 import json
13 import logging
14 import os
15 import re
16 import socket
17 import time
18 import types
19
20 import apiclient
21 from apiclient import discovery as apiclient_discovery
22 from apiclient import errors as apiclient_errors
23 from . import config
24 from . import errors
25 from . import util
26 from . import cache
27
28 _logger = logging.getLogger('arvados.api')
29
30 MAX_IDLE_CONNECTION_DURATION = 30
31 RETRY_DELAY_INITIAL = 2
32 RETRY_DELAY_BACKOFF = 2
33 RETRY_COUNT = 2
34
35 class OrderedJsonModel(apiclient.model.JsonModel):
36     """Model class for JSON that preserves the contents' order.
37
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::
40
41         from arvados.api import OrderedJsonModel
42         client = arvados.api('v1', ..., model=OrderedJsonModel())
43     """
44
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:
51             body = body['data']
52         return body
53
54
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))
60
61     if 'headers' not in kwargs:
62         kwargs['headers'] = {}
63
64     if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
65         kwargs['headers']['X-External-Client'] = '1'
66
67     kwargs['headers']['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
68
69     retryable = method in [
70         'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
71     retry_count = self._retry_count if retryable else 0
72
73     if (not retryable and
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():
79             conn.close()
80         self.connections.clear()
81
82     delay = self._retry_delay_initial
83     for _ in range(retry_count):
84         self._last_request_time = time.time()
85         try:
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",
89                           delay, exc_info=True)
90         except socket.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",
97                           delay, exc_info=True)
98             for conn in self.connections.values():
99                 conn.close()
100         time.sleep(delay)
101         delay = delay * self._retry_delay_backoff
102
103     self._last_request_time = time.time()
104     return self.orig_http_request(uri, method, **kwargs)
105
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
116     return http
117
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):
122     global _cast_orig
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)
127     else:
128         return _cast_orig(value, schema_type)
129 apiclient_discovery._cast = _cast_objects_too
130
131 # Convert apiclient's HttpErrors into our own API error subclass for better
132 # error reporting.
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)
139
140 def http_cache(data_type):
141     homedir = os.environ.get('HOME')
142     if not homedir or len(homedir) == 0:
143         return None
144     path = homedir + '/.cache/arvados/' + data_type
145     try:
146         util.mkdir_dash_p(path)
147     except OSError:
148         return None
149     return cache.SafeHTTPCache(path, max_age=60*60*24*2)
150
151 def api(version=None, cache=True, host=None, token=None, insecure=False, **kwargs):
152     """Return an apiclient Resources object for an Arvados instance.
153
154     :version:
155       A string naming the version of the Arvados API to use (for
156       example, 'v1').
157
158     :cache:
159       Use a cache (~/.cache/arvados/discovery) for the discovery
160       document.
161
162     :host:
163       The Arvados API server host (and optional :port) to connect to.
164
165     :token:
166       The authentication token to send with each API call.
167
168     :insecure:
169       If True, ignore SSL certificate validation errors.
170
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.
176
177     """
178
179     if not version:
180         version = 'v1'
181         _logger.info("Using default API version. " +
182                      "Call arvados.api('%s') instead." %
183                      version)
184     if 'discoveryServiceUrl' in kwargs:
185         if host:
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.
190         if not token:
191             raise ValueError("discoveryServiceUrl provided, but token missing")
192     elif host and token:
193         pass
194     elif not host and not token:
195         return api_from_config(version=version, cache=cache, **kwargs)
196     else:
197         # Caller provided one but not the other
198         if not host:
199             raise ValueError("token argument provided, but host missing.")
200         else:
201             raise ValueError("host argument provided, but token missing.")
202
203     if host:
204         # Caller wants us to build the discoveryServiceUrl
205         kwargs['discoveryServiceUrl'] = (
206             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
207
208     if 'http' not in kwargs:
209         http_kwargs = {'ca_certs': util.ca_certs_path()}
210         if cache:
211             http_kwargs['cache'] = http_cache('discovery')
212         if insecure:
213             http_kwargs['disable_ssl_certificate_validation'] = True
214         kwargs['http'] = httplib2.Http(**http_kwargs)
215
216     kwargs['http'] = _patch_http_request(kwargs['http'], token)
217
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
223     return svc
224
225 def api_from_config(version=None, apiconfig=None, **kwargs):
226     """Return an apiclient Resources object enabling access to an Arvados server
227     instance.
228
229     :version:
230       A string naming the version of the Arvados REST API to use (for
231       example, 'v1').
232
233     :apiconfig:
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
238       default.)
239
240     Other keyword arguments such as `cache` will be passed along `api()`
241
242     """
243     # Load from user configuration or environment
244     if apiconfig is None:
245         apiconfig = config.settings()
246
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)
253
254     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)