Merge branch '12765-workbench-404-trashed' refs #12765
[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", headers={}, **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 config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
62         headers['X-External-Client'] = '1'
63
64     headers['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
65     if not headers.get('X-Request-Id'):
66         headers['X-Request-Id'] = self._request_id()
67
68     retryable = method in [
69         'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
70     retry_count = self._retry_count if retryable else 0
71
72     if (not retryable and
73         time.time() - self._last_request_time > self._max_keepalive_idle):
74         # High probability of failure due to connection atrophy. Make
75         # sure this request [re]opens a new connection by closing and
76         # forgetting all cached connections first.
77         for conn in self.connections.values():
78             conn.close()
79         self.connections.clear()
80
81     delay = self._retry_delay_initial
82     for _ in range(retry_count):
83         self._last_request_time = time.time()
84         try:
85             return self.orig_http_request(uri, method, headers=headers, **kwargs)
86         except http.client.HTTPException:
87             _logger.debug("Retrying API request in %d s after HTTP error",
88                           delay, exc_info=True)
89         except socket.error:
90             # This is the one case where httplib2 doesn't close the
91             # underlying connection first.  Close all open
92             # connections, expecting this object only has the one
93             # connection to the API server.  This is safe because
94             # httplib2 reopens connections when needed.
95             _logger.debug("Retrying API request in %d s after socket error",
96                           delay, exc_info=True)
97             for conn in self.connections.values():
98                 conn.close()
99         time.sleep(delay)
100         delay = delay * self._retry_delay_backoff
101
102     self._last_request_time = time.time()
103     return self.orig_http_request(uri, method, headers=headers, **kwargs)
104
105 def _patch_http_request(http, api_token):
106     http.arvados_api_token = api_token
107     http.max_request_size = 0
108     http.orig_http_request = http.request
109     http.request = types.MethodType(_intercept_http_request, http)
110     http._last_request_time = 0
111     http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
112     http._retry_delay_initial = RETRY_DELAY_INITIAL
113     http._retry_delay_backoff = RETRY_DELAY_BACKOFF
114     http._retry_count = RETRY_COUNT
115     http._request_id = util.new_request_id
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,
152         request_id=None, **kwargs):
153     """Return an apiclient Resources object for an Arvados instance.
154
155     :version:
156       A string naming the version of the Arvados API to use (for
157       example, 'v1').
158
159     :cache:
160       Use a cache (~/.cache/arvados/discovery) for the discovery
161       document.
162
163     :host:
164       The Arvados API server host (and optional :port) to connect to.
165
166     :token:
167       The authentication token to send with each API call.
168
169     :insecure:
170       If True, ignore SSL certificate validation errors.
171
172     :request_id:
173       Default X-Request-Id header value for outgoing requests that
174       don't already provide one. If None or omitted, generate a random
175       ID. When retrying failed requests, the same ID is used on all
176       attempts.
177
178     Additional keyword arguments will be passed directly to
179     `apiclient_discovery.build` if a new Resource object is created.
180     If the `discoveryServiceUrl` or `http` keyword arguments are
181     missing, this function will set default values for them, based on
182     the current Arvados configuration settings.
183
184     """
185
186     if not version:
187         version = 'v1'
188         _logger.info("Using default API version. " +
189                      "Call arvados.api('%s') instead." %
190                      version)
191     if 'discoveryServiceUrl' in kwargs:
192         if host:
193             raise ValueError("both discoveryServiceUrl and host provided")
194         # Here we can't use a token from environment, config file,
195         # etc. Those probably have nothing to do with the host
196         # provided by the caller.
197         if not token:
198             raise ValueError("discoveryServiceUrl provided, but token missing")
199     elif host and token:
200         pass
201     elif not host and not token:
202         return api_from_config(
203             version=version, cache=cache, request_id=request_id, **kwargs)
204     else:
205         # Caller provided one but not the other
206         if not host:
207             raise ValueError("token argument provided, but host missing.")
208         else:
209             raise ValueError("host argument provided, but token missing.")
210
211     if host:
212         # Caller wants us to build the discoveryServiceUrl
213         kwargs['discoveryServiceUrl'] = (
214             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
215
216     if 'http' not in kwargs:
217         http_kwargs = {'ca_certs': util.ca_certs_path()}
218         if cache:
219             http_kwargs['cache'] = http_cache('discovery')
220         if insecure:
221             http_kwargs['disable_ssl_certificate_validation'] = True
222         kwargs['http'] = httplib2.Http(**http_kwargs)
223
224     kwargs['http'] = _patch_http_request(kwargs['http'], token)
225
226     svc = apiclient_discovery.build('arvados', version, cache_discovery=False, **kwargs)
227     svc.api_token = token
228     svc.insecure = insecure
229     svc.request_id = request_id
230     kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
231     kwargs['http'].cache = None
232     kwargs['http']._request_id = lambda: svc.request_id or util.new_request_id()
233     return svc
234
235 def api_from_config(version=None, apiconfig=None, **kwargs):
236     """Return an apiclient Resources object enabling access to an Arvados server
237     instance.
238
239     :version:
240       A string naming the version of the Arvados REST API to use (for
241       example, 'v1').
242
243     :apiconfig:
244       If provided, this should be a dict-like object (must support the get()
245       method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
246       optionally ARVADOS_API_HOST_INSECURE.  If not provided, use
247       arvados.config (which gets these parameters from the environment by
248       default.)
249
250     Other keyword arguments such as `cache` will be passed along `api()`
251
252     """
253     # Load from user configuration or environment
254     if apiconfig is None:
255         apiconfig = config.settings()
256
257     for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
258         if x not in apiconfig:
259             raise ValueError("%s is not set. Aborting." % x)
260     host = apiconfig.get('ARVADOS_API_HOST')
261     token = apiconfig.get('ARVADOS_API_TOKEN')
262     insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
263
264     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)