Merge branch 'master' into 3408-production-datamanager
[arvados.git] / sdk / python / arvados / api.py
1 import collections
2 import httplib2
3 import json
4 import logging
5 import os
6 import re
7 import types
8
9 import apiclient
10 from apiclient import discovery as apiclient_discovery
11 from apiclient import errors as apiclient_errors
12 import config
13 import errors
14 import util
15
16 _logger = logging.getLogger('arvados.api')
17
18 class OrderedJsonModel(apiclient.model.JsonModel):
19     """Model class for JSON that preserves the contents' order.
20
21     API clients that care about preserving the order of fields in API
22     server responses can use this model to do so, like this::
23
24         from arvados.api import OrderedJsonModel
25         client = arvados.api('v1', ..., model=OrderedJsonModel())
26     """
27
28     def deserialize(self, content):
29         # This is a very slightly modified version of the parent class'
30         # implementation.  Copyright (c) 2010 Google.
31         content = content.decode('utf-8')
32         body = json.loads(content, object_pairs_hook=collections.OrderedDict)
33         if self._data_wrapper and isinstance(body, dict) and 'data' in body:
34             body = body['data']
35         return body
36
37
38 def _intercept_http_request(self, uri, **kwargs):
39     from httplib import BadStatusLine
40
41     if (self.max_request_size and
42         kwargs.get('body') and
43         self.max_request_size < len(kwargs['body'])):
44         raise apiclient_errors.MediaUploadSizeError("Request size %i bytes exceeds published limit of %i bytes" % (len(kwargs['body']), self.max_request_size))
45
46     if 'headers' not in kwargs:
47         kwargs['headers'] = {}
48
49     if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
50         kwargs['headers']['X-External-Client'] = '1'
51
52     kwargs['headers']['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
53     try:
54         return self.orig_http_request(uri, **kwargs)
55     except BadStatusLine:
56         # This is how httplib tells us that it tried to reuse an
57         # existing connection but it was already closed by the
58         # server. In that case, yes, we would like to retry.
59         # Unfortunately, we are not absolutely certain that the
60         # previous call did not succeed, so this is slightly
61         # risky.
62         return self.orig_http_request(uri, **kwargs)
63
64 def _patch_http_request(http, api_token):
65     http.arvados_api_token = api_token
66     http.max_request_size = 0
67     http.orig_http_request = http.request
68     http.request = types.MethodType(_intercept_http_request, http)
69     return http
70
71 # Monkey patch discovery._cast() so objects and arrays get serialized
72 # with json.dumps() instead of str().
73 _cast_orig = apiclient_discovery._cast
74 def _cast_objects_too(value, schema_type):
75     global _cast_orig
76     if (type(value) != type('') and
77         (schema_type == 'object' or schema_type == 'array')):
78         return json.dumps(value)
79     else:
80         return _cast_orig(value, schema_type)
81 apiclient_discovery._cast = _cast_objects_too
82
83 # Convert apiclient's HttpErrors into our own API error subclass for better
84 # error reporting.
85 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
86 # apiclient submodules import the class into their own namespace.
87 def _new_http_error(cls, *args, **kwargs):
88     return super(apiclient_errors.HttpError, cls).__new__(
89         errors.ApiError, *args, **kwargs)
90 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
91
92 def http_cache(data_type):
93     path = os.environ['HOME'] + '/.cache/arvados/' + data_type
94     try:
95         util.mkdir_dash_p(path)
96     except OSError:
97         path = None
98     return path
99
100 def api(version=None, cache=True, host=None, token=None, insecure=False, **kwargs):
101     """Return an apiclient Resources object for an Arvados instance.
102
103     :version:
104       A string naming the version of the Arvados API to use (for
105       example, 'v1').
106
107     :cache:
108       Use a cache (~/.cache/arvados/discovery) for the discovery
109       document.
110
111     :host:
112       The Arvados API server host (and optional :port) to connect to.
113
114     :token:
115       The authentication token to send with each API call.
116
117     :insecure:
118       If True, ignore SSL certificate validation errors.
119
120     Additional keyword arguments will be passed directly to
121     `apiclient_discovery.build` if a new Resource object is created.
122     If the `discoveryServiceUrl` or `http` keyword arguments are
123     missing, this function will set default values for them, based on
124     the current Arvados configuration settings.
125
126     """
127
128     if not version:
129         version = 'v1'
130         _logger.info("Using default API version. " +
131                      "Call arvados.api('%s') instead." %
132                      version)
133     if 'discoveryServiceUrl' in kwargs:
134         if host:
135             raise ValueError("both discoveryServiceUrl and host provided")
136         # Here we can't use a token from environment, config file,
137         # etc. Those probably have nothing to do with the host
138         # provided by the caller.
139         if not token:
140             raise ValueError("discoveryServiceUrl provided, but token missing")
141     elif host and token:
142         pass
143     elif not host and not token:
144         return api_from_config(version=version, cache=cache, **kwargs)
145     else:
146         # Caller provided one but not the other
147         if not host:
148             raise ValueError("token argument provided, but host missing.")
149         else:
150             raise ValueError("host argument provided, but token missing.")
151
152     if host:
153         # Caller wants us to build the discoveryServiceUrl
154         kwargs['discoveryServiceUrl'] = (
155             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
156
157     if 'http' not in kwargs:
158         http_kwargs = {}
159         # Prefer system's CA certificates (if available) over httplib2's.
160         certs_path = '/etc/ssl/certs/ca-certificates.crt'
161         if os.path.exists(certs_path):
162             http_kwargs['ca_certs'] = certs_path
163         if cache:
164             http_kwargs['cache'] = http_cache('discovery')
165         if insecure:
166             http_kwargs['disable_ssl_certificate_validation'] = True
167         kwargs['http'] = httplib2.Http(**http_kwargs)
168
169     kwargs['http'] = _patch_http_request(kwargs['http'], token)
170
171     svc = apiclient_discovery.build('arvados', version, **kwargs)
172     svc.api_token = token
173     kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
174     kwargs['http'].cache = None
175     return svc
176
177 def api_from_config(version=None, apiconfig=None, **kwargs):
178     """Return an apiclient Resources object enabling access to an Arvados server
179     instance.
180
181     :version:
182       A string naming the version of the Arvados REST API to use (for
183       example, 'v1').
184
185     :apiconfig:
186       If provided, this should be a dict-like object (must support the get()
187       method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
188       optionally ARVADOS_API_HOST_INSECURE.  If not provided, use
189       arvados.config (which gets these parameters from the environment by
190       default.)
191
192     Other keyword arguments such as `cache` will be passed along `api()`
193
194     """
195     # Load from user configuration or environment
196     if apiconfig is None:
197         apiconfig = config.settings()
198
199     for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
200         if x not in apiconfig:
201             raise ValueError("%s is not set. Aborting." % x)
202     host = apiconfig.get('ARVADOS_API_HOST')
203     token = apiconfig.get('ARVADOS_API_TOKEN')
204     insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
205
206     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)