Merge branch '14383-java-sdk-double-slash'. Fixes #14383.
[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         except httplib2.SSLHandshakeError as e:
100             # Intercept and re-raise with a better error message.
101             raise httplib2.SSLHandshakeError("Could not connect to %s\n%s\nPossible causes: remote SSL/TLS certificate expired, or was issued by an untrusted certificate authority." % (uri, e))
102
103         time.sleep(delay)
104         delay = delay * self._retry_delay_backoff
105
106     self._last_request_time = time.time()
107     return self.orig_http_request(uri, method, headers=headers, **kwargs)
108
109 def _patch_http_request(http, api_token):
110     http.arvados_api_token = api_token
111     http.max_request_size = 0
112     http.orig_http_request = http.request
113     http.request = types.MethodType(_intercept_http_request, http)
114     http._last_request_time = 0
115     http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
116     http._retry_delay_initial = RETRY_DELAY_INITIAL
117     http._retry_delay_backoff = RETRY_DELAY_BACKOFF
118     http._retry_count = RETRY_COUNT
119     http._request_id = util.new_request_id
120     return http
121
122 # Monkey patch discovery._cast() so objects and arrays get serialized
123 # with json.dumps() instead of str().
124 _cast_orig = apiclient_discovery._cast
125 def _cast_objects_too(value, schema_type):
126     global _cast_orig
127     if (type(value) != type('') and
128         type(value) != type(b'') and
129         (schema_type == 'object' or schema_type == 'array')):
130         return json.dumps(value)
131     else:
132         return _cast_orig(value, schema_type)
133 apiclient_discovery._cast = _cast_objects_too
134
135 # Convert apiclient's HttpErrors into our own API error subclass for better
136 # error reporting.
137 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
138 # apiclient submodules import the class into their own namespace.
139 def _new_http_error(cls, *args, **kwargs):
140     return super(apiclient_errors.HttpError, cls).__new__(
141         errors.ApiError, *args, **kwargs)
142 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
143
144 def http_cache(data_type):
145     homedir = os.environ.get('HOME')
146     if not homedir or len(homedir) == 0:
147         return None
148     path = homedir + '/.cache/arvados/' + data_type
149     try:
150         util.mkdir_dash_p(path)
151     except OSError:
152         return None
153     return cache.SafeHTTPCache(path, max_age=60*60*24*2)
154
155 def api(version=None, cache=True, host=None, token=None, insecure=False,
156         request_id=None, timeout=5*60, **kwargs):
157     """Return an apiclient Resources object for an Arvados instance.
158
159     :version:
160       A string naming the version of the Arvados API to use (for
161       example, 'v1').
162
163     :cache:
164       Use a cache (~/.cache/arvados/discovery) for the discovery
165       document.
166
167     :host:
168       The Arvados API server host (and optional :port) to connect to.
169
170     :token:
171       The authentication token to send with each API call.
172
173     :insecure:
174       If True, ignore SSL certificate validation errors.
175
176     :timeout:
177       A timeout value for http requests.
178
179     :request_id:
180       Default X-Request-Id header value for outgoing requests that
181       don't already provide one. If None or omitted, generate a random
182       ID. When retrying failed requests, the same ID is used on all
183       attempts.
184
185     Additional keyword arguments will be passed directly to
186     `apiclient_discovery.build` if a new Resource object is created.
187     If the `discoveryServiceUrl` or `http` keyword arguments are
188     missing, this function will set default values for them, based on
189     the current Arvados configuration settings.
190
191     """
192
193     if not version:
194         version = 'v1'
195         _logger.info("Using default API version. " +
196                      "Call arvados.api('%s') instead." %
197                      version)
198     if 'discoveryServiceUrl' in kwargs:
199         if host:
200             raise ValueError("both discoveryServiceUrl and host provided")
201         # Here we can't use a token from environment, config file,
202         # etc. Those probably have nothing to do with the host
203         # provided by the caller.
204         if not token:
205             raise ValueError("discoveryServiceUrl provided, but token missing")
206     elif host and token:
207         pass
208     elif not host and not token:
209         return api_from_config(
210             version=version, cache=cache, request_id=request_id, **kwargs)
211     else:
212         # Caller provided one but not the other
213         if not host:
214             raise ValueError("token argument provided, but host missing.")
215         else:
216             raise ValueError("host argument provided, but token missing.")
217
218     if host:
219         # Caller wants us to build the discoveryServiceUrl
220         kwargs['discoveryServiceUrl'] = (
221             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
222
223     if 'http' not in kwargs:
224         http_kwargs = {'ca_certs': util.ca_certs_path()}
225         if cache:
226             http_kwargs['cache'] = http_cache('discovery')
227         if insecure:
228             http_kwargs['disable_ssl_certificate_validation'] = True
229         kwargs['http'] = httplib2.Http(**http_kwargs)
230
231     if kwargs['http'].timeout is None:
232         kwargs['http'].timeout = timeout
233
234     kwargs['http'] = _patch_http_request(kwargs['http'], token)
235
236     svc = apiclient_discovery.build('arvados', version, cache_discovery=False, **kwargs)
237     svc.api_token = token
238     svc.insecure = insecure
239     svc.request_id = request_id
240     kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
241     kwargs['http'].cache = None
242     kwargs['http']._request_id = lambda: svc.request_id or util.new_request_id()
243     return svc
244
245 def api_from_config(version=None, apiconfig=None, **kwargs):
246     """Return an apiclient Resources object enabling access to an Arvados server
247     instance.
248
249     :version:
250       A string naming the version of the Arvados REST API to use (for
251       example, 'v1').
252
253     :apiconfig:
254       If provided, this should be a dict-like object (must support the get()
255       method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
256       optionally ARVADOS_API_HOST_INSECURE.  If not provided, use
257       arvados.config (which gets these parameters from the environment by
258       default.)
259
260     Other keyword arguments such as `cache` will be passed along `api()`
261
262     """
263     # Load from user configuration or environment
264     if apiconfig is None:
265         apiconfig = config.settings()
266
267     errors = []
268     for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
269         if x not in apiconfig:
270             errors.append(x)
271     if errors:
272         raise ValueError(" and ".join(errors)+" not set.\nPlease set in %s or export environment variable." % config.default_config_file)
273     host = apiconfig.get('ARVADOS_API_HOST')
274     token = apiconfig.get('ARVADOS_API_TOKEN')
275     insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
276
277     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)