20259: Add documentation for banner and tooltip features
[arvados.git] / sdk / python / tests / test_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 builtins import str
7 from builtins import range
8 import arvados
9 import collections
10 import httplib2
11 import itertools
12 import json
13 import mimetypes
14 import os
15 import socket
16 import string
17 import unittest
18 import urllib.parse as urlparse
19
20 import mock
21 from . import run_test_server
22
23 from apiclient import errors as apiclient_errors
24 from apiclient import http as apiclient_http
25 from arvados.api import (
26     api_client,
27     normalize_api_kwargs,
28     api_kwargs_from_config,
29     OrderedJsonModel,
30     RETRY_DELAY_INITIAL,
31     RETRY_DELAY_BACKOFF,
32     RETRY_COUNT,
33 )
34 from .arvados_testutil import fake_httplib2_response, queue_with
35
36 if not mimetypes.inited:
37     mimetypes.init()
38
39 class ArvadosApiTest(run_test_server.TestCaseWithServers):
40     MAIN_SERVER = {}
41     ERROR_HEADERS = {'Content-Type': mimetypes.types_map['.json']}
42
43     def api_error_response(self, code, *errors):
44         return (fake_httplib2_response(code, **self.ERROR_HEADERS),
45                 json.dumps({'errors': errors,
46                             'error_token': '1234567890+12345678'}).encode())
47
48     def _config_from_environ(self):
49         return {
50             key: value
51             for key, value in os.environ.items()
52             if key.startswith('ARVADOS_API_')
53         }
54
55     def _discoveryServiceUrl(
56             self,
57             host=None,
58             path='/discovery/v1/apis/{api}/{apiVersion}/rest',
59             scheme='https',
60     ):
61         if host is None:
62             host = os.environ['ARVADOS_API_HOST']
63         return urlparse.urlunsplit((scheme, host, path, None, None))
64
65     def test_new_api_objects_with_cache(self):
66         clients = [arvados.api('v1', cache=True) for index in [0, 1]]
67         self.assertIsNot(*clients)
68
69     def test_empty_list(self):
70         answer = arvados.api('v1').humans().list(
71             filters=[['uuid', '=', None]]).execute()
72         self.assertEqual(answer['items_available'], len(answer['items']))
73
74     def test_nonempty_list(self):
75         answer = arvados.api('v1').collections().list().execute()
76         self.assertNotEqual(0, answer['items_available'])
77         self.assertNotEqual(0, len(answer['items']))
78
79     def test_timestamp_inequality_filter(self):
80         api = arvados.api('v1')
81         new_item = api.specimens().create(body={}).execute()
82         for operator, should_include in [
83                 ['<', False], ['>', False],
84                 ['<=', True], ['>=', True], ['=', True]]:
85             response = api.specimens().list(filters=[
86                 ['created_at', operator, new_item['created_at']],
87                 # Also filter by uuid to ensure (if it matches) it's on page 0
88                 ['uuid', '=', new_item['uuid']]]).execute()
89             uuids = [item['uuid'] for item in response['items']]
90             did_include = new_item['uuid'] in uuids
91             self.assertEqual(
92                 did_include, should_include,
93                 "'%s %s' filter should%s have matched '%s'" % (
94                     operator, new_item['created_at'],
95                     ('' if should_include else ' not'),
96                     new_item['created_at']))
97
98     def test_exceptions_include_errors(self):
99         mock_responses = {
100             'arvados.humans.get': self.api_error_response(
101                 422, "Bad UUID format", "Bad output format"),
102             }
103         req_builder = apiclient_http.RequestMockBuilder(mock_responses)
104         api = arvados.api('v1', requestBuilder=req_builder)
105         with self.assertRaises(apiclient_errors.HttpError) as err_ctx:
106             api.humans().get(uuid='xyz-xyz-abcdef').execute()
107         err_s = str(err_ctx.exception)
108         for msg in ["Bad UUID format", "Bad output format"]:
109             self.assertIn(msg, err_s)
110
111     @mock.patch('time.sleep')
112     def test_exceptions_include_request_id(self, sleep):
113         api = arvados.api('v1')
114         api.request_id='fake-request-id'
115         api._http.orig_http_request = mock.MagicMock()
116         api._http.orig_http_request.side_effect = socket.error('mock error')
117         caught = None
118         try:
119             api.users().current().execute()
120         except Exception as e:
121             caught = e
122         self.assertRegex(str(caught), r'fake-request-id')
123
124     def test_exceptions_without_errors_have_basic_info(self):
125         mock_responses = {
126             'arvados.humans.delete': (
127                 fake_httplib2_response(500, **self.ERROR_HEADERS),
128                 b"")
129             }
130         req_builder = apiclient_http.RequestMockBuilder(mock_responses)
131         api = arvados.api('v1', requestBuilder=req_builder)
132         with self.assertRaises(apiclient_errors.HttpError) as err_ctx:
133             api.humans().delete(uuid='xyz-xyz-abcdef').execute()
134         self.assertIn("500", str(err_ctx.exception))
135
136     def test_request_too_large(self):
137         api = arvados.api('v1')
138         maxsize = api._rootDesc.get('maxRequestSize', 0)
139         with self.assertRaises(apiclient_errors.MediaUploadSizeError):
140             text = "X" * maxsize
141             arvados.api('v1').collections().create(body={"manifest_text": text}).execute()
142
143     def test_default_request_timeout(self):
144         api = arvados.api('v1')
145         self.assertEqual(api._http.timeout, 300,
146             "Default timeout value should be 300")
147
148     def test_custom_request_timeout(self):
149         api = arvados.api('v1', timeout=1234)
150         self.assertEqual(api._http.timeout, 1234,
151             "Requested timeout value was 1234")
152
153     def test_ordered_json_model(self):
154         mock_responses = {
155             'arvados.humans.get': (
156                 None,
157                 json.dumps(collections.OrderedDict(
158                     (c, int(c, 16)) for c in string.hexdigits
159                 )).encode(),
160             ),
161         }
162         req_builder = apiclient_http.RequestMockBuilder(mock_responses)
163         api = arvados.api('v1',
164                           requestBuilder=req_builder, model=OrderedJsonModel())
165         result = api.humans().get(uuid='test').execute()
166         self.assertEqual(string.hexdigits, ''.join(list(result.keys())))
167
168     def test_api_is_threadsafe(self):
169         api_kwargs = {
170             'host': os.environ['ARVADOS_API_HOST'],
171             'token': os.environ['ARVADOS_API_TOKEN'],
172             'insecure': True,
173         }
174         config_kwargs = {'apiconfig': os.environ}
175         for api_constructor, kwargs in [
176                 (arvados.api, {}),
177                 (arvados.api, api_kwargs),
178                 (arvados.api_from_config, {}),
179                 (arvados.api_from_config, config_kwargs),
180         ]:
181             sub_kwargs = "kwargs" if kwargs else "no kwargs"
182             with self.subTest(f"{api_constructor.__name__} with {sub_kwargs}"):
183                 api_client = api_constructor('v1', **kwargs)
184                 self.assertTrue(hasattr(api_client, 'localapi'),
185                                 f"client missing localapi method")
186                 self.assertTrue(hasattr(api_client, 'keep'),
187                                 f"client missing keep attribute")
188
189     def test_api_host_constructor(self):
190         cache = True
191         insecure = True
192         client = arvados.api(
193             'v1',
194             cache,
195             os.environ['ARVADOS_API_HOST'],
196             os.environ['ARVADOS_API_TOKEN'],
197             insecure,
198         )
199         self.assertEqual(client.api_token, os.environ['ARVADOS_API_TOKEN'],
200                          "client constructed with incorrect token")
201
202     def test_api_url_constructor(self):
203         client = arvados.api(
204             'v1',
205             discoveryServiceUrl=self._discoveryServiceUrl(),
206             token=os.environ['ARVADOS_API_TOKEN'],
207             insecure=True,
208         )
209         self.assertEqual(client.api_token, os.environ['ARVADOS_API_TOKEN'],
210                          "client constructed with incorrect token")
211
212     def test_api_bad_args(self):
213         all_kwargs = {
214             'host': os.environ['ARVADOS_API_HOST'],
215             'token': os.environ['ARVADOS_API_TOKEN'],
216             'discoveryServiceUrl': self._discoveryServiceUrl(),
217         }
218         for use_keys in [
219                 # Passing only a single key is missing required info
220                 *([key] for key in all_kwargs.keys()),
221                 # Passing all keys is a conflict
222                 list(all_kwargs.keys()),
223         ]:
224             kwargs = {key: all_kwargs[key] for key in use_keys}
225             kwargs_list = ', '.join(use_keys)
226             with self.subTest(f"calling arvados.api with {kwargs_list} fails"), \
227                  self.assertRaises(ValueError):
228                 arvados.api('v1', insecure=True, **kwargs)
229
230     def test_api_bad_url(self):
231         for bad_kwargs in [
232                 {'discoveryServiceUrl': self._discoveryServiceUrl() + '/BadTestURL'},
233                 {'version': 'BadTestVersion', 'host': os.environ['ARVADOS_API_HOST']},
234         ]:
235             bad_key = next(iter(bad_kwargs))
236             with self.subTest(f"api fails with bad {bad_key}"), \
237                  self.assertRaises(apiclient_errors.UnknownApiNameOrVersion):
238                 arvados.api(**bad_kwargs, token='test_api_bad_url', insecure=True)
239
240     def test_normalize_api_good_args(self):
241         for version, discoveryServiceUrl, host in [
242                 ('Test1', None, os.environ['ARVADOS_API_HOST']),
243                 (None, self._discoveryServiceUrl(), None)
244         ]:
245             argname = 'discoveryServiceUrl' if host is None else 'host'
246             with self.subTest(f"normalize_api_kwargs with {argname}"):
247                 actual = normalize_api_kwargs(
248                     version,
249                     discoveryServiceUrl,
250                     host,
251                     os.environ['ARVADOS_API_TOKEN'],
252                     insecure=True,
253                 )
254                 self.assertEqual(actual['discoveryServiceUrl'], self._discoveryServiceUrl())
255                 self.assertEqual(actual['token'], os.environ['ARVADOS_API_TOKEN'])
256                 self.assertEqual(actual['version'], version or 'v1')
257                 self.assertTrue(actual['insecure'])
258                 self.assertNotIn('host', actual)
259
260     def test_normalize_api_bad_args(self):
261         all_args = (
262             self._discoveryServiceUrl(),
263             os.environ['ARVADOS_API_HOST'],
264             os.environ['ARVADOS_API_TOKEN'],
265         )
266         for arg_index, arg_value in enumerate(all_args):
267             args = [None] * len(all_args)
268             args[arg_index] = arg_value
269             with self.subTest(f"normalize_api_kwargs with only arg #{arg_index + 1}"), \
270                  self.assertRaises(ValueError):
271                 normalize_api_kwargs('v1', *args)
272         with self.subTest("normalize_api_kwargs with discoveryServiceUrl and host"), \
273              self.assertRaises(ValueError):
274             normalize_api_kwargs('v1', *all_args)
275
276     def test_api_from_config_default(self):
277         client = arvados.api_from_config('v1')
278         self.assertEqual(client.api_token, os.environ['ARVADOS_API_TOKEN'],
279                          "client constructed with incorrect token")
280
281     def test_api_from_config_explicit(self):
282         config = self._config_from_environ()
283         client = arvados.api_from_config('v1', config)
284         self.assertEqual(client.api_token, os.environ['ARVADOS_API_TOKEN'],
285                          "client constructed with incorrect token")
286
287     def test_api_from_bad_config(self):
288         base_config = self._config_from_environ()
289         for del_key in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
290             with self.subTest(f"api_from_config without {del_key} fails"), \
291                  self.assertRaises(ValueError):
292                 config = dict(base_config)
293                 del config[del_key]
294                 arvados.api_from_config('v1', config)
295
296     def test_api_kwargs_from_good_config(self):
297         for config in [None, self._config_from_environ()]:
298             conf_type = 'default' if config is None else 'passed'
299             with self.subTest(f"api_kwargs_from_config with {conf_type} config"):
300                 version = 'Test1' if config else None
301                 actual = api_kwargs_from_config(version, config)
302                 self.assertEqual(actual['discoveryServiceUrl'], self._discoveryServiceUrl())
303                 self.assertEqual(actual['token'], os.environ['ARVADOS_API_TOKEN'])
304                 self.assertEqual(actual['version'], version or 'v1')
305                 self.assertTrue(actual['insecure'])
306                 self.assertNotIn('host', actual)
307
308     def test_api_kwargs_from_bad_config(self):
309         base_config = self._config_from_environ()
310         for del_key in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
311             with self.subTest(f"api_kwargs_from_config without {del_key} fails"), \
312                  self.assertRaises(ValueError):
313                 config = dict(base_config)
314                 del config[del_key]
315                 api_kwargs_from_config('v1', config)
316
317     def test_api_client_constructor(self):
318         client = api_client(
319             'v1',
320             self._discoveryServiceUrl(),
321             os.environ['ARVADOS_API_TOKEN'],
322             insecure=True,
323         )
324         self.assertEqual(client.api_token, os.environ['ARVADOS_API_TOKEN'],
325                          "client constructed with incorrect token")
326         self.assertFalse(
327             hasattr(client, 'localapi'),
328             "client has localapi method when it should not be thread-safe",
329         )
330
331     def test_api_client_bad_url(self):
332         all_args = ('v1', self._discoveryServiceUrl(), 'test_api_client_bad_url')
333         for arg_index, arg_value in [
334                 (0, 'BadTestVersion'),
335                 (1, all_args[1] + '/BadTestURL'),
336         ]:
337             with self.subTest(f"api_client fails with {arg_index}={arg_value!r}"), \
338                  self.assertRaises(apiclient_errors.UnknownApiNameOrVersion):
339                 args = list(all_args)
340                 args[arg_index] = arg_value
341                 api_client(*args, insecure=True)
342
343
344 class RetryREST(unittest.TestCase):
345     def setUp(self):
346         self.api = arvados.api('v1')
347         self.assertTrue(hasattr(self.api._http, 'orig_http_request'),
348                         "test doesn't know how to intercept HTTP requests")
349         self.mock_response = {'user': 'person'}
350         self.request_success = (fake_httplib2_response(200),
351                                 json.dumps(self.mock_response))
352         self.api._http.orig_http_request = mock.MagicMock()
353         # All requests succeed by default. Tests override as needed.
354         self.api._http.orig_http_request.return_value = self.request_success
355
356     @mock.patch('time.sleep')
357     def test_socket_error_retry_get(self, sleep):
358         self.api._http.orig_http_request.side_effect = (
359             socket.error('mock error'),
360             self.request_success,
361         )
362         self.assertEqual(self.api.users().current().execute(),
363                          self.mock_response)
364         self.assertGreater(self.api._http.orig_http_request.call_count, 1,
365                            "client got the right response without retrying")
366         self.assertEqual(sleep.call_args_list,
367                          [mock.call(RETRY_DELAY_INITIAL)])
368
369     @mock.patch('time.sleep')
370     def test_same_automatic_request_id_on_retry(self, sleep):
371         self.api._http.orig_http_request.side_effect = (
372             socket.error('mock error'),
373             self.request_success,
374         )
375         self.api.users().current().execute()
376         calls = self.api._http.orig_http_request.call_args_list
377         self.assertEqual(len(calls), 2)
378         self.assertEqual(
379             calls[0][1]['headers']['X-Request-Id'],
380             calls[1][1]['headers']['X-Request-Id'])
381         self.assertRegex(calls[0][1]['headers']['X-Request-Id'], r'^req-[a-z0-9]{20}$')
382
383     @mock.patch('time.sleep')
384     def test_provided_request_id_on_retry(self, sleep):
385         self.api.request_id='fake-request-id'
386         self.api._http.orig_http_request.side_effect = (
387             socket.error('mock error'),
388             self.request_success,
389         )
390         self.api.users().current().execute()
391         calls = self.api._http.orig_http_request.call_args_list
392         self.assertEqual(len(calls), 2)
393         for call in calls:
394             self.assertEqual(call[1]['headers']['X-Request-Id'], 'fake-request-id')
395
396     @mock.patch('time.sleep')
397     def test_socket_error_retry_delay(self, sleep):
398         self.api._http.orig_http_request.side_effect = socket.error('mock')
399         self.api._http._retry_count = 3
400         with self.assertRaises(socket.error):
401             self.api.users().current().execute()
402         self.assertEqual(self.api._http.orig_http_request.call_count, 4)
403         self.assertEqual(sleep.call_args_list, [
404             mock.call(RETRY_DELAY_INITIAL),
405             mock.call(RETRY_DELAY_INITIAL * RETRY_DELAY_BACKOFF),
406             mock.call(RETRY_DELAY_INITIAL * RETRY_DELAY_BACKOFF**2),
407         ])
408
409     @mock.patch('time.time', side_effect=[i*2**20 for i in range(99)])
410     def test_close_old_connections_non_retryable(self, sleep):
411         self._test_connection_close(expect=1)
412
413     @mock.patch('time.time', side_effect=itertools.count())
414     def test_no_close_fresh_connections_non_retryable(self, sleep):
415         self._test_connection_close(expect=0)
416
417     @mock.patch('time.time', side_effect=itertools.count())
418     def test_override_max_idle_time(self, sleep):
419         self.api._http._max_keepalive_idle = 0
420         self._test_connection_close(expect=1)
421
422     def _test_connection_close(self, expect=0):
423         # Do two POST requests. The second one must close all
424         # connections +expect+ times.
425         self.api.users().create(body={}).execute()
426         mock_conns = {str(i): mock.MagicMock() for i in range(2)}
427         self.api._http.connections = mock_conns.copy()
428         self.api.users().create(body={}).execute()
429         for c in mock_conns.values():
430             self.assertEqual(c.close.call_count, expect)
431
432     @mock.patch('time.sleep')
433     def test_socket_error_no_retry_post(self, sleep):
434         self.api._http.orig_http_request.side_effect = (
435             socket.error('mock error'),
436             self.request_success,
437         )
438         with self.assertRaises(socket.error):
439             self.api.users().create(body={}).execute()
440         self.assertEqual(self.api._http.orig_http_request.call_count, 1,
441                          "client should try non-retryable method exactly once")
442         self.assertEqual(sleep.call_args_list, [])
443
444
445 if __name__ == '__main__':
446     unittest.main()