587 lines
23 KiB
Python
587 lines
23 KiB
Python
import random
|
|
import math
|
|
|
|
from functools import partial
|
|
from logging import getLogger
|
|
from numbers import Number
|
|
from typing import TYPE_CHECKING, Optional, Any, Type, Callable, Set
|
|
|
|
from requests.exceptions import RequestException
|
|
from boxsdk.exception import BoxException
|
|
from .box_request import BoxRequest as _BoxRequest
|
|
from .box_response import BoxResponse as _BoxResponse
|
|
from ..config import API, Client, Proxy
|
|
from ..exception import BoxAPIException
|
|
from ..network.default_network import DefaultNetwork
|
|
from ..util.json import is_json_response
|
|
from ..util.multipart_stream import MultipartStream
|
|
from ..util.shared_link import get_shared_link_header
|
|
from ..util.translator import Translator
|
|
|
|
if TYPE_CHECKING:
|
|
from boxsdk.network.network_interface import Network
|
|
from boxsdk.object.user import User
|
|
from boxsdk import NetworkResponse, OAuth2
|
|
|
|
|
|
class Session:
|
|
|
|
_retry_randomization_factor = 0.5
|
|
_retry_base_interval = 1
|
|
_JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
|
|
_CCG_GRANT_TYPE = 'client_credentials'
|
|
|
|
"""
|
|
Box API session. Provides automatic retry of failed requests.
|
|
"""
|
|
def __init__(
|
|
self,
|
|
network_layer: 'Network' = None,
|
|
default_headers: Optional['dict'] = None,
|
|
translator: Translator = None,
|
|
default_network_request_kwargs: Optional['dict'] = None,
|
|
api_config: API = None,
|
|
client_config: Client = None,
|
|
proxy_config: Optional[Proxy] = None,
|
|
):
|
|
"""
|
|
:param network_layer:
|
|
Network implementation used by the session to make requests.
|
|
:param default_headers:
|
|
A dictionary containing default values to be used as headers when this session makes an API request.
|
|
:param translator:
|
|
(optional) The translator to use for translating Box API JSON
|
|
responses into :class:`BaseAPIJSONObject` smart objects.
|
|
Defaults to a new :class:`Translator` that inherits the
|
|
registrations of the default translator.
|
|
:param default_network_request_kwargs:
|
|
A dictionary containing default values to be passed to the network layer
|
|
when this session makes an API request.
|
|
:param api_config:
|
|
Object containing URLs for the Box API.
|
|
:param client_config:
|
|
Object containing client information, including user agent string.
|
|
:param proxy_config:
|
|
Object containing proxy information.
|
|
"""
|
|
if translator is None:
|
|
translator = Translator(extend_default_translator=True, new_child=True)
|
|
self._api_config = api_config or API()
|
|
self._client_config = client_config or Client()
|
|
self._proxy_config = proxy_config or Proxy()
|
|
super().__init__()
|
|
self._network_layer = network_layer or DefaultNetwork()
|
|
self._default_headers = {
|
|
'User-Agent': self._client_config.USER_AGENT_STRING,
|
|
'X-Box-UA': self._client_config.BOX_UA_STRING,
|
|
}
|
|
self._translator = translator
|
|
self._default_network_request_kwargs = {}
|
|
if default_headers:
|
|
self._default_headers.update(default_headers)
|
|
if default_network_request_kwargs:
|
|
self._default_network_request_kwargs.update(default_network_request_kwargs)
|
|
self._logger = getLogger(__name__)
|
|
|
|
def get(self, url: str, **kwargs: Any) -> '_BoxResponse':
|
|
"""Make a GET request to the Box API.
|
|
|
|
:param url:
|
|
The URL for the request.
|
|
"""
|
|
return self.request('GET', url, **kwargs)
|
|
|
|
def post(self, url: str, **kwargs: Any) -> '_BoxResponse':
|
|
"""Make a POST request to the Box API.
|
|
|
|
:param url:
|
|
The URL for the request.
|
|
"""
|
|
return self.request('POST', url, **kwargs)
|
|
|
|
def put(self, url: str, **kwargs: Any) -> '_BoxResponse':
|
|
"""Make a PUT request to the Box API.
|
|
|
|
:param url:
|
|
The URL for the request.
|
|
"""
|
|
return self.request('PUT', url, **kwargs)
|
|
|
|
def delete(self, url: str, **kwargs: Any) -> '_BoxResponse':
|
|
"""Make a DELETE request to the Box API.
|
|
|
|
:param url:
|
|
The URL for the request.
|
|
"""
|
|
if 'expect_json_response' not in kwargs:
|
|
kwargs['expect_json_response'] = False
|
|
if 'skip_retry_codes' not in kwargs:
|
|
kwargs['skip_retry_codes'] = {202}
|
|
return self.request('DELETE', url, **kwargs)
|
|
|
|
def options(self, url: str, **kwargs: Any) -> '_BoxResponse':
|
|
"""Make an OPTIONS request to the Box API.
|
|
|
|
:param url:
|
|
The URL for the request.
|
|
"""
|
|
return self.request('OPTIONS', url, **kwargs)
|
|
|
|
def request(self, method: str, url: str, **kwargs: Any) -> '_BoxResponse':
|
|
"""Make a request to the Box API.
|
|
|
|
:param method:
|
|
The HTTP verb for the request.
|
|
:param url:
|
|
The URL for the request.
|
|
"""
|
|
response = self._prepare_and_send_request(method, url, **kwargs)
|
|
return self.box_response_constructor(response)
|
|
|
|
@property
|
|
def box_request_constructor(self) -> Type[_BoxRequest]:
|
|
"""Get the constructor for the container class representing an API request"""
|
|
return _BoxRequest
|
|
|
|
@property
|
|
def box_response_constructor(self) -> Type[_BoxResponse]:
|
|
"""Get the constructor for the container class representing an API response"""
|
|
return _BoxResponse
|
|
|
|
@property
|
|
def translator(self) -> Translator:
|
|
"""
|
|
The translator used for translating Box API JSON responses into `BaseAPIJSONObject` smart objects.
|
|
"""
|
|
return self._translator
|
|
|
|
@property
|
|
def api_config(self) -> API:
|
|
return self._api_config
|
|
|
|
@property
|
|
def client_config(self) -> Client:
|
|
return self._client_config
|
|
|
|
@property
|
|
def proxy_config(self) -> Proxy:
|
|
return self._proxy_config
|
|
|
|
def get_url(self, endpoint: str, *args: Any) -> str:
|
|
"""
|
|
Return the URL for the given Box API endpoint.
|
|
|
|
:param endpoint:
|
|
The name of the endpoint.
|
|
:param args:
|
|
Additional parts of the endpoint URL.
|
|
"""
|
|
# pylint:disable=no-self-use
|
|
url = [f'{self._api_config.BASE_API_URL}/{endpoint}']
|
|
url.extend([f'/{x}' for x in args])
|
|
return ''.join(url)
|
|
|
|
def get_constructor_kwargs(self) -> dict:
|
|
return dict(
|
|
network_layer=self._network_layer,
|
|
translator=self._translator,
|
|
default_network_request_kwargs=self._default_network_request_kwargs.copy(),
|
|
api_config=self._api_config,
|
|
client_config=self._client_config,
|
|
proxy_config=self._proxy_config,
|
|
default_headers=self._default_headers.copy(),
|
|
)
|
|
|
|
def as_user(self, user: 'User') -> 'Session':
|
|
"""
|
|
Returns a new session object with default headers set up to make requests as the specified user.
|
|
|
|
:param user:
|
|
The user to impersonate when making API requests.
|
|
"""
|
|
kwargs = self.get_constructor_kwargs()
|
|
kwargs['default_headers']['As-User'] = user.object_id
|
|
return self.__class__(**kwargs)
|
|
|
|
def with_shared_link(self, shared_link: str, shared_link_password: str = None) -> 'Session':
|
|
"""
|
|
Returns a new session object with default headers set up to make requests using the shared link for auth.
|
|
|
|
:param shared_link:
|
|
The shared link.
|
|
:param shared_link_password:
|
|
The password for the shared link.
|
|
"""
|
|
kwargs = self.get_constructor_kwargs()
|
|
kwargs['default_headers'].update(get_shared_link_header(shared_link, shared_link_password))
|
|
return self.__class__(**kwargs)
|
|
|
|
def with_default_network_request_kwargs(self, extra_network_parameters: dict) -> 'Session':
|
|
kwargs = self.get_constructor_kwargs()
|
|
kwargs['default_network_request_kwargs'].update(extra_network_parameters)
|
|
return self.__class__(**kwargs)
|
|
|
|
# We updated our retry strategy to use exponential backoff instead of the header returned from the API response.
|
|
# This is something we can remove in latter major bumps.
|
|
# pylint: disable=unused-argument
|
|
def get_retry_after_time(self, attempt_number: int, retry_after_header: Optional[str]) -> Number:
|
|
"""
|
|
Get the amount of time to wait before retrying the API request, using the attempt number that failed to
|
|
calculate the retry time for the next retry attempt.
|
|
|
|
If the Retry-After header is supplied, use it; otherwise, use exponential backoff
|
|
For 202 Accepted (thumbnail or file not ready) and 429 (too many requests), retry later, after a delay
|
|
specified by the Retry-After header.
|
|
For 5xx Server Error, retry later, after a delay; use exponential backoff to determine the delay.
|
|
|
|
:param attempt_number: How many attempts at this request have already been tried.
|
|
:param retry_after_header: Value of the 'Retry-After` response header.
|
|
:return: Number of seconds to wait before retrying.
|
|
"""
|
|
if retry_after_header is not None:
|
|
try:
|
|
return int(retry_after_header)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
min_randomization = 1 - self._retry_randomization_factor
|
|
max_randomization = 1 + self._retry_randomization_factor
|
|
randomization = (random.uniform(0, 1) * (max_randomization - min_randomization)) + min_randomization
|
|
exponential = math.pow(2, attempt_number)
|
|
return exponential * self._retry_base_interval * randomization
|
|
|
|
@staticmethod
|
|
def _raise_on_unsuccessful_request(network_response: 'NetworkResponse', request: '_BoxRequest', raised_exception: Exception) -> None:
|
|
"""
|
|
Raise an exception if the request was unsuccessful.
|
|
|
|
:param network_response:
|
|
The network response which is being tested for success.
|
|
:param request:
|
|
The API request that could be unsuccessful.
|
|
"""
|
|
if network_response is None:
|
|
raise raised_exception
|
|
|
|
if not network_response.ok:
|
|
response_json = {}
|
|
try:
|
|
response_json = network_response.json()
|
|
except ValueError:
|
|
pass
|
|
raise BoxAPIException(
|
|
status=network_response.status_code,
|
|
headers=network_response.headers,
|
|
code=response_json.get('code', None) or response_json.get('error', None),
|
|
message=response_json.get('message', None) or response_json.get('error_description', None),
|
|
request_id=response_json.get('request_id', None),
|
|
url=request.url,
|
|
method=request.method,
|
|
context_info=response_json.get('context_info', None),
|
|
network_response=network_response
|
|
)
|
|
|
|
if not Session._is_json_response_if_expected(network_response, request):
|
|
raise BoxAPIException(
|
|
status=network_response.status_code,
|
|
headers=network_response.headers,
|
|
message='Non-json response received, while expecting json response.',
|
|
url=request.url,
|
|
method=request.method,
|
|
network_response=network_response,
|
|
)
|
|
|
|
@staticmethod
|
|
def _is_json_response_if_expected(network_response: 'NetworkResponse', request: '_BoxRequest') -> bool:
|
|
"""
|
|
Validate that the response is json if the request expects json response.
|
|
|
|
:param network_response:
|
|
The network response which is being tested for success.
|
|
:param request:
|
|
The API request that could be unsuccessful.
|
|
"""
|
|
return not request.expect_json_response or is_json_response(network_response)
|
|
|
|
def _prepare_and_send_request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
headers: dict = None,
|
|
auto_session_renewal: bool = True,
|
|
expect_json_response: bool = True,
|
|
**kwargs: Any
|
|
) -> 'NetworkResponse':
|
|
"""
|
|
Prepare a request to be sent to the Box API.
|
|
|
|
:param method:
|
|
The HTTP verb to use to make the request.
|
|
:param url:
|
|
The request URL.
|
|
:param headers:
|
|
Headers to include with the request.
|
|
:param auto_session_renewal:
|
|
Whether to automatically renew the session if the request fails due to an expired access token.
|
|
:param expect_json_response:
|
|
Whether the response content should be json.
|
|
"""
|
|
files = kwargs.get('files')
|
|
kwargs['file_stream_positions'] = None
|
|
if files:
|
|
kwargs['file_stream_positions'] = {name: file_tuple[1].tell() for name, file_tuple in files.items()}
|
|
attempt_number = 0
|
|
request_headers = self._get_request_headers()
|
|
request_headers.update(headers or {})
|
|
|
|
request = self.box_request_constructor(
|
|
url=url,
|
|
method=method,
|
|
headers=request_headers,
|
|
auto_session_renewal=auto_session_renewal,
|
|
expect_json_response=expect_json_response,
|
|
)
|
|
|
|
skip_retry_codes = kwargs.pop('skip_retry_codes', set())
|
|
|
|
raised_exception = None
|
|
try:
|
|
network_response = self._send_request(request, **kwargs)
|
|
reauthentication_needed = network_response.status_code == 401
|
|
except RequestException as request_exc:
|
|
raised_exception = request_exc
|
|
network_response = None
|
|
if 'EOF occurred in violation of protocol' in str(request_exc):
|
|
reauthentication_needed = True
|
|
elif any(text in str(request_exc) for text in [
|
|
'Connection aborted', 'Connection broken', 'Connection reset'
|
|
]):
|
|
reauthentication_needed = False
|
|
else:
|
|
raise
|
|
|
|
while True:
|
|
retry = self._get_retry_request_callable(
|
|
network_response, attempt_number, request, skip_retry_codes, reauthentication_needed, **kwargs)
|
|
|
|
if retry is None or attempt_number >= API.MAX_RETRY_ATTEMPTS:
|
|
if network_response is None:
|
|
raise raised_exception
|
|
break
|
|
|
|
attempt_number += 1
|
|
self._logger.debug('Retrying request')
|
|
network_response = retry(request, **kwargs)
|
|
|
|
self._raise_on_unsuccessful_request(network_response, request, raised_exception)
|
|
|
|
return network_response
|
|
|
|
def _get_retry_request_callable(
|
|
self,
|
|
network_response: Optional['NetworkResponse'],
|
|
attempt_number: int,
|
|
request: '_BoxRequest',
|
|
skip_retry_codes: Set[int],
|
|
session_renewal_needed: bool = False,
|
|
**kwargs: Any
|
|
) -> Optional[Callable]:
|
|
"""
|
|
Get a callable that retries a request for certain types of failure.
|
|
|
|
For 202 Accepted (thumbnail or file not ready) and 429 (too many requests), retry later, after a delay
|
|
specified by the Retry-After header.
|
|
For 5xx Server Error, retry later, after a delay; use exponential backoff to determine the delay.
|
|
|
|
Otherwise, return None.
|
|
|
|
:param network_response:
|
|
The response from the Box API.
|
|
:param attempt_number:
|
|
How many attempts at this request have already been tried. Used for exponential backoff calculations.
|
|
:param request:
|
|
The API request that could require retrying.
|
|
:return:
|
|
Callable that, when called, will retry the request. Takes the same parameters as :meth:`_send_request`.
|
|
"""
|
|
# pylint:disable=unused-argument
|
|
# pylint:disable=line-too-long
|
|
if network_response is None or (network_response.ok and request.method == 'GET' and not self._is_json_response_if_expected(network_response, request)):
|
|
return partial(
|
|
self._network_layer.retry_after,
|
|
self.get_retry_after_time(attempt_number, None),
|
|
self._send_request,
|
|
)
|
|
code = network_response.status_code
|
|
|
|
if (code in (202, 429) or code >= 500) and code not in skip_retry_codes and not self._is_server_auth_type(kwargs):
|
|
return partial(
|
|
self._network_layer.retry_after,
|
|
self.get_retry_after_time(attempt_number, network_response.headers.get('Retry-After', None)),
|
|
self._send_request,
|
|
)
|
|
return None
|
|
|
|
def _is_server_auth_type(self, kwargs: dict) -> bool:
|
|
data = kwargs.get('data', {})
|
|
grant_type = None
|
|
try:
|
|
if 'grant_type' in data:
|
|
grant_type = data['grant_type']
|
|
except TypeError:
|
|
pass
|
|
return grant_type in (self._JWT_GRANT_TYPE, self._CCG_GRANT_TYPE)
|
|
|
|
def _get_request_headers(self) -> dict:
|
|
return self._default_headers.copy()
|
|
|
|
def _prepare_proxy(self) -> Optional[dict]:
|
|
"""
|
|
Prepares basic authenticated and unauthenticated proxies for requests.
|
|
|
|
:return:
|
|
A prepared proxy dict to send along with the request. None if incorrect parameters were passed.
|
|
"""
|
|
proxy = {}
|
|
if self._proxy_config.URL is None:
|
|
return None
|
|
if self._proxy_config.AUTH and {'user', 'password'} <= set(self._proxy_config.AUTH):
|
|
host = self._proxy_config.URL
|
|
address = host.split('//')[1]
|
|
proxy_string = f'http://{self._proxy_config.AUTH.get("user", None)}:' \
|
|
f'{self._proxy_config.AUTH.get("password", None)}@{address}'
|
|
elif self._proxy_config.AUTH is None:
|
|
proxy_string = self._proxy_config.URL
|
|
else:
|
|
raise BoxException("The proxy auth dict you provided does not match pattern "
|
|
"{'user': 'example_user', 'password': 'example_password'}")
|
|
proxy['http'] = proxy_string
|
|
proxy['https'] = proxy['http']
|
|
|
|
return proxy
|
|
|
|
def _send_request(self, request: '_BoxRequest', **kwargs: Any) -> 'NetworkResponse':
|
|
"""
|
|
Make a request to the Box API.
|
|
|
|
:param request:
|
|
The API request to send.
|
|
"""
|
|
# Reset stream positions to what they were when the request was made so the same data is sent even if this
|
|
# is a retried attempt.
|
|
files, file_stream_positions, stream_file_content = (
|
|
kwargs.get('files'), kwargs.pop('file_stream_positions'), kwargs.pop('stream_file_content', True))
|
|
request_kwargs = self._default_network_request_kwargs.copy()
|
|
request_kwargs.update(kwargs)
|
|
proxy_dict = self._prepare_proxy()
|
|
if proxy_dict is not None:
|
|
request_kwargs.update({'proxies': proxy_dict})
|
|
if files and file_stream_positions:
|
|
for name, position in file_stream_positions.items():
|
|
files[name][1].seek(position)
|
|
if stream_file_content:
|
|
data = request_kwargs.pop('data', {})
|
|
multipart_stream = MultipartStream(data, files)
|
|
request_kwargs['data'] = multipart_stream
|
|
del request_kwargs['files']
|
|
request.headers['Content-Type'] = multipart_stream.content_type
|
|
request.access_token = request_kwargs.pop('access_token', None)
|
|
|
|
# send the request
|
|
network_response = self._network_layer.request(
|
|
request.method,
|
|
request.url,
|
|
access_token=request.access_token,
|
|
headers=request.headers,
|
|
log_response_content=request.expect_json_response,
|
|
**request_kwargs
|
|
)
|
|
|
|
return network_response
|
|
|
|
|
|
class AuthorizedSession(Session):
|
|
"""
|
|
Box API authorized session. Provides auth, automatic retry of failed requests, and session renewal.
|
|
"""
|
|
|
|
def __init__(self, oauth: 'OAuth2', **kwargs: Any):
|
|
"""
|
|
:param oauth:
|
|
OAuth2 object used by the session to authorize requests.
|
|
:param session:
|
|
The Box API session to wrap for authorization.
|
|
"""
|
|
super().__init__(**kwargs)
|
|
self._oauth = oauth
|
|
|
|
def get_constructor_kwargs(self) -> dict:
|
|
kwargs = super().get_constructor_kwargs()
|
|
kwargs['oauth'] = self._oauth
|
|
return kwargs
|
|
|
|
def _renew_session(self, access_token_used: Optional[str]) -> str:
|
|
"""
|
|
Renews the session by refreshing the access token.
|
|
|
|
:param access_token_used:
|
|
The access token that's currently being used by the session, that needs to be refreshed.
|
|
"""
|
|
new_access_token, _ = self._oauth.refresh(access_token_used)
|
|
return new_access_token
|
|
|
|
def _get_retry_request_callable(
|
|
self,
|
|
network_response: Optional['NetworkResponse'],
|
|
attempt_number: int,
|
|
request: '_BoxRequest',
|
|
skip_retry_codes: Set[int],
|
|
session_renewal_needed: bool = False,
|
|
**kwargs: Any
|
|
) -> Callable:
|
|
"""
|
|
Get a callable that retries a request for certain types of failure.
|
|
|
|
For 401 Unauthorized responses, renew the session by refreshing the access token; then retry.
|
|
|
|
Otherwise, defer to baseclass implementation.
|
|
|
|
:param network_response:
|
|
The response from the Box API.
|
|
:param attempt_number:
|
|
How many attempts at this request have already been tried. Used for exponential backoff calculations.
|
|
:param request:
|
|
The API request that could require retrying.
|
|
:return:
|
|
Callable that, when called, will retry the request. Takes the same parameters as :meth:`_send_request`.
|
|
"""
|
|
if request.auto_session_renewal and session_renewal_needed:
|
|
self._renew_session(request.access_token)
|
|
request.auto_session_renewal = False
|
|
return self._send_request
|
|
|
|
return super()._get_retry_request_callable(
|
|
network_response,
|
|
attempt_number,
|
|
request,
|
|
skip_retry_codes,
|
|
session_renewal_needed,
|
|
**kwargs
|
|
)
|
|
|
|
def _send_request(self, request: '_BoxRequest', **kwargs: Any) -> 'NetworkResponse':
|
|
"""
|
|
Make a request to the Box API.
|
|
|
|
:param request:
|
|
The API request to send.
|
|
"""
|
|
# Since there can be session renewal happening in the middle of preparing the request, it's important to be
|
|
# consistent with the access_token being used in the request.
|
|
access_token = self._oauth.access_token
|
|
if request.auto_session_renewal and access_token is None:
|
|
access_token = self._renew_session(None)
|
|
request.auto_session_renewal = False
|
|
authorization_header = {'Authorization': f'Bearer {access_token}'}
|
|
request.headers.update(authorization_header)
|
|
kwargs['access_token'] = access_token
|
|
return super()._send_request(request, **kwargs)
|