Skip to content

Commit

Permalink
Add support for non-standard API endpoint paths (Fixes #37) (#38)
Browse files Browse the repository at this point in the history
- Allows users to leverage this client when qBittorrent is configured behind a reverse proxy.
  - For instance, if the Web API is being exposed at "http://localhost/qbt/", then users can instantiate via Client(host='localhost/qbt') and all API endpoint paths will be prefixed with "/qbt".
 - Additionally, the scheme (i.e. http or https) from the user will now be respected as the first choice for which scheme is used to communicate with qBittorrent.
  - However, users still don't need to even specify a scheme; it'll be automatically determined on the first connection to qBittorrent.
 - Neither of these should be breaking changes, but if you're instantiating with an incorrect scheme or an irrelevant path, you may need to prevent doing that now.
  • Loading branch information
rmartin16 authored Dec 6, 2020
1 parent eec3062 commit 22d68d6
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 92 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
Version 2020.12.14 (6 dec 2020)
- Add support for non-standard API endpoint paths (Fixes #37)
- Allows users to leverage this client when qBittorrent is configured behind a reverse proxy.
- For instance, if the Web API is being exposed at "http://localhost/qbt/", then users can instantiate via Client(host='localhost/qbt') and all API endpoint paths will be prefixed with "/qbt".
- Additionally, the scheme (i.e. http or https) from the user will now be respected as the first choice for which scheme is used to communicate with qBittorrent.
- However, users still don't need to even specify a scheme; it'll be automatically determined on the first connection to qBittorrent.
- Neither of these should be breaking changes, but if you're instantiating with an incorrect scheme or an irrelevant path, you may need to prevent doing that now.

Version 2020.11.13 (29 nov 2020)
- Bump for Web API v2.6.1 release
- Path of torrent content now available via content_path from torrents/info
Expand Down
9 changes: 5 additions & 4 deletions qbittorrentapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# Implementation
# Required API parameters
# - To avoid runtime errors, required API parameters are not explicitly
# enforced in the code. Instead, I found if qBittorent returns HTTP400
# enforced in the code. Instead, I found if qBittorrent returns HTTP400
# without am error message, at least one required parameter is missing.
# This raises a MissingRequiredParameters400 error.
# - Alternatively, if a parameter is malformatted, HTTP400 is returned
Expand All @@ -23,7 +23,8 @@
# API Peculiarities
# app/setPreferences
# - This was endlessly frustrating since it requires data in the
# form of {'json': dumps({'dht': True})}...
# form of {'json': dumps({'dht': True})}...this way, Requests sends the
# JSON dump as a key/value pair for "json" via x-www-form-urlencoded.
# - Sending an empty string for 'banned_ips' drops the useless message
# below in to the log file (same for WebUI):
# ' is not a valid IP address and was rejected while applying the list of banned addresses.'
Expand Down Expand Up @@ -70,8 +71,8 @@ class Client(AppAPIMixIn,
:param VERIFY_WEBUI_CERTIFICATE: Set to False to skip verify certificate for HTTPS connections;
for instance, if the connection is using a self-signed certificate. Not setting this to False for self-signed
certs will cause a APIConnectionError exception to be raised.
:param RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: Some Endpoints may not be implemented in older
versions of qBittorrent. Setting this to True will raise a UnimplementedError instead of just returning None.
:param RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: Some Endpoints may not be implemented in older
versions of qBittorrent. Setting this to True will raise a NotImplementedError instead of just returning None.
:param DISABLE_LOGGING_DEBUG_OUTPUT: Turn off debug output from logging for this package as well as Requests & urllib3.
"""

Expand Down
160 changes: 90 additions & 70 deletions qbittorrentapi/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,12 @@ def __init__(self, host='', port=None, username=None, password=None, **kwargs):
self._password = password or ''

# defaults that should not change
self._API_URL_BASE_PATH = 'api'
self._API_URL_API_VERSION = 'v2'
self._API_URL_PATH_NAMESPACE = 'api'
self._API_URL_PATH_VERSION = 'v2'
self._API_BASE_PATH = self._API_URL_PATH_NAMESPACE + '/' + self._API_URL_PATH_VERSION

# allow users to further qualify API path
self._USER_URL_BASE_PATH = None

# state, context, and caching variables
# These variables are deleted if the connection to qBittorrent is reset
Expand All @@ -61,7 +65,7 @@ def __init__(self, host='', port=None, username=None, password=None, **kwargs):
self._sync = None
self._rss = None
self._search = None
self._API_URL_BASE = None
self._API_BASE_URL = None

# Configuration variables
self._VERIFY_WEBUI_CERTIFICATE = kwargs.pop('VERIFY_WEBUI_CERTIFICATE', True)
Expand Down Expand Up @@ -95,6 +99,10 @@ def __init__(self, host='', port=None, username=None, password=None, **kwargs):
# Mocking variables until better unit testing exists
self._MOCK_WEB_API_VERSION = kwargs.pop('MOCK_WEB_API_VERSION', None)

# turn off console-printed warnings about SSL certificate issues (e.g. untrusted since it is self-signed)
if not self._VERIFY_WEBUI_CERTIFICATE:
disable_warnings(InsecureRequestWarning)

########################################
# Authorization Endpoints
########################################
Expand Down Expand Up @@ -194,7 +202,7 @@ def _initialize_context(self):
self._cached_web_api_version = None

# reset URL so the full URL is derived again (primarily allows for switching scheme for WebUI: HTTP <-> HTTPS)
self._API_URL_BASE = None
self._API_BASE_URL = None

# reinitialize interaction layers
self._application = None
Expand All @@ -208,10 +216,10 @@ def _initialize_context(self):
self._search = None

def _get(self, _name=APINames.EMPTY, _method='', **kwargs):
return self._request_wrapper(http_method='get', api_name=_name, api_method=_method, **kwargs)
return self._request_wrapper(http_method='get', api_namespace=_name, api_method=_method, **kwargs)

def _post(self, _name=APINames.EMPTY, _method='', **kwargs):
return self._request_wrapper(http_method='post', api_name=_name, api_method=_method, **kwargs)
return self._request_wrapper(http_method='post', api_namespace=_name, api_method=_method, **kwargs)

def _request_wrapper(self, _retries=2, _retry_backoff_factor=.3, **kwargs):
"""
Expand Down Expand Up @@ -257,20 +265,15 @@ def _request_wrapper(self, _retries=2, _retry_backoff_factor=.3, **kwargs):
logger.debug('Retry attempt %d' % (retry+1))
self._initialize_context()

def _request(self, http_method, api_name, api_method,
def _request(self, http_method, api_namespace, api_method,
data=None, params=None, files=None, headers=None, requests_params=None, **kwargs):
_ = kwargs.pop('SIMPLE_RESPONSES', kwargs.pop('SIMPLE_RESPONSE', False)) # ensure SIMPLE_RESPONSE(S) isn't sent

if isinstance(api_name, APINames):
api_name = api_name.value
api_path_list = (self._API_URL_BASE_PATH, self._API_URL_API_VERSION, api_name, api_method)
url = self._build_url(base_url=self._API_URL_BASE,
host=self.host,
port=self.port,
api_path_list=api_path_list)

# preserve URL without the path so we don't have to rebuild it next time
self._API_URL_BASE = url._replace(path='')
self._API_BASE_URL = self._build_base_url(base_url=self._API_BASE_URL, host=self.host, port=self.port)
url = self._build_url_path(base_url=self._API_BASE_URL,
api_base_path=self._API_BASE_PATH,
api_namespace=api_namespace,
api_method=api_method)

# mechanism to send additional arguments to Requests for individual API calls
requests_params = requests_params or dict()
Expand All @@ -286,8 +289,8 @@ def _request(self, http_method, api_name, api_method,

# set up headers
headers = headers or dict()
headers['Referer'] = self._API_URL_BASE.geturl()
headers['Origin'] = self._API_URL_BASE.geturl()
headers['Referer'] = self._API_BASE_URL.geturl()
headers['Origin'] = self._API_BASE_URL.geturl()
# send Content-Length zero for empty POSTs
# Requests will not send Content-Length if data is empty
if http_method == 'post' and not any(filter(None, data.values())):
Expand All @@ -296,10 +299,6 @@ def _request(self, http_method, api_name, api_method,
# include the SID auth cookie unless we're trying to log in and get a SID
cookies = {'SID': self._SID if 'auth/login' not in url.path else ''}

# turn off console-printed warnings about SSL certificate issues (e.g. untrusted since it is self-signed)
if not self._VERIFY_WEBUI_CERTIFICATE:
disable_warnings(InsecureRequestWarning)

response = requests.request(method=http_method,
url=url.geturl(),
headers=headers,
Expand All @@ -314,6 +313,74 @@ def _request(self, http_method, api_name, api_method,
self.handle_error_responses(data, params, response)
return response

@staticmethod
def _build_base_url(base_url=None, host='', port=None):
"""
Determine the Base URL for the Web API endpoints.
If the user doesn't provide a scheme for the URL, it will try HTTP first and fall back to
HTTPS if that doesn't work. While this is probably backwards, qBittorrent or an intervening
proxy can simply redirect to HTTPS and that'll be respected.
Additionally, if users want to augment the path to the API endpoints, any path provided here
will be preserved in the returned Base URL and prefixed to all subsequent API calls.
:param base_url: if the URL was already built, this is the base URL
:param host: user provided hostname for WebUI
:return: base URL for Web API endpoint
"""
# build full URL if the base URL isn't currently known.
# it will not be known when it's the first time we're here...or if the context was re-initialized.
if base_url is None:
# urlparse requires some sort of schema for parsing to work at all
if not host.lower().startswith(('http:', 'https:', '//')):
host = '//' + host
base_url = urlparse(url=host)
logger.debug('Parsed user URL: %s' % repr(base_url))
# default to HTTP if user didn't specify
base_url = base_url._replace(scheme='http') if not base_url.scheme else base_url
alt_scheme = 'https' if base_url.scheme == 'http' else 'http'
# add port number if host doesn't contain one
if port is not None and not isinstance(base_url.port, int):
base_url = base_url._replace(netloc='%s:%s' % (base_url.netloc, port))

# detect whether Web API is configured for HTTP or HTTPS
logger.debug('Detecting scheme for URL...')
try:
# skip verification here...if there's a problem, we'll catch it during the actual API call
r = requests.head(base_url.geturl(), allow_redirects=True, verify=False)
# if WebUI eventually supports sending a redirect from HTTP to HTTPS then
# Requests will automatically provide a URL using HTTPS.
# For instance, the URL returned below will use the HTTPS scheme.
# >>> requests.head('http://grc.com', allow_redirects=True).url
scheme = urlparse(r.url).scheme
except requests.exceptions.RequestException:
# assume alternative scheme will work...we'll fail later if neither are working
scheme = alt_scheme

# use detected scheme
logger.debug('Using %s scheme' % scheme.upper())
base_url = base_url._replace(scheme=scheme)

logger.debug('Base URL: %s' % base_url.geturl())

return base_url

@staticmethod
def _build_url_path(base_url, api_base_path, api_namespace, api_method):
"""
Determine the full URL path for the API endpoint.
:param base_url: base URL for API (e.g. http://localhost:8080 or http://example.com/qbt/)
:param api_base_path: qBittorrent defined API path prefix (i.e. api/v2/)
:param api_namespace: the namespace for the API endpoint (e.g. torrents)
:param api_method: the specific method for the API endpoint (e.g. info)
:return: full URL for API endpoint (e.g. http://localhost:8080/api/v2/torrents/info or http://example.com/qbt/api/v2/torrents/info)
"""
api_namespace = api_namespace.value if isinstance(api_namespace, APINames) else api_namespace
user_base_path = base_url.path or None
api_path_list = list(filter(None, (user_base_path, api_base_path, api_namespace, api_method)))
url = base_url._replace(path='/'.join(map(lambda s: s.strip('/'), map(str, api_path_list))))
return url

@staticmethod
def handle_error_responses(data, params, response):
"""Raise proper exception if qBittorrent returns Error HTTP Status."""
Expand Down Expand Up @@ -390,50 +457,3 @@ def verbose_logging(self, http_method, response, url):
if self._PRINT_STACK_FOR_EACH_REQUEST:
from traceback import print_stack
print_stack()

@staticmethod
def _build_url(base_url=None, host='', port=None, api_path_list=None):
"""
Create a fully qualified URL (minus query parameters that Requests will add later).
Supports detecting whether HTTPS is enabled for WebUI.
:param base_url: if the URL was already built, this is the base URL
:param host: user provided hostname for WebUI
:param api_path_list: list of strings for API endpoint path (e.g. ['api', 'v2', 'app', 'version'])
:return: full URL for WebUI API endpoint
"""
# build full URL if it's the first time we're here
if base_url is None:
if not host.lower().startswith(('http:', 'https:', '//')):
host = '//' + host
base_url = urlparse(url=host)
# force scheme to HTTP even if host was provided with HTTPS scheme
base_url = base_url._replace(scheme='http')
# add port number if host doesn't contain one
if port is not None and not isinstance(base_url.port, int):
base_url = base_url._replace(netloc='%s:%s' % (base_url.netloc, port))

# detect whether Web API is configured for HTTP or HTTPS
logger.debug('Detecting scheme for URL...')
try:
r = requests.head(base_url.geturl(), allow_redirects=True)
# if WebUI eventually supports sending a redirect from HTTP to HTTPS then
# Requests will automatically provide a URL using HTTPS.
# For instance, the URL returned below will use the HTTPS scheme.
# >>> requests.head('http://grc.com', allow_redirects=True).url
scheme = urlparse(r.url).scheme
except requests.exceptions.RequestException:
# qBittorrent will reject the connection if WebUI is configured for HTTPS.
# If something else caused this exception, we'll properly handle that
# later during the actual API request.
scheme = 'https'

# use detected scheme
logger.debug('Using %s scheme' % scheme.upper())
base_url = base_url._replace(scheme=scheme)

logger.debug('Base URL: %s' % base_url.geturl())

# add the full API path to complete the URL
return base_url._replace(path='/'.join(map(lambda s: s.strip('/'), map(str, api_path_list))))
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setup(
name='qbittorrent-api',
version='2020.11.13',
version='2020.12.14',
packages=find_packages(exclude=['*.tests', '*.tests.*', 'tests.*', 'tests']),
include_package_data=True,
install_requires=['attrdict>=2.0.0',
Expand Down
8 changes: 5 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,15 @@ def abort_if_qbittorrent_crashes(client):
def client():
"""qBittorrent Client for testing session"""
try:
client = Client(RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=True, VERBOSE_RESPONSE_LOGGING=True)
client = Client(RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=True,
VERBOSE_RESPONSE_LOGGING=True,
VERIFY_WEBUI_CERTIFICATE=False)
client.auth_log_in()
# add orig_torrent to qBittorrent
client.torrents_add(urls=_orig_torrent_url, upload_limit=10, download_limit=10)
return client
except APIConnectionError:
pytest.exit('qBittorrent was not running when tests started')
except APIConnectionError as e:
pytest.exit('qBittorrent was not running when tests started: %s' % repr(e))


@pytest.fixture(scope='session')
Expand Down
2 changes: 1 addition & 1 deletion tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def test_is_version_less_than():


def test_login_required(caplog, app_version):
client = Client(RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=True)
client = Client(RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=True, VERIFY_WEBUI_CERTIFICATE=False)
with caplog.at_level(logging.DEBUG, logger='qbittorrentapi'):
qbt_version = client.app.version
assert 'Not logged in...attempting login' in caplog.text
Expand Down
Loading

0 comments on commit 22d68d6

Please sign in to comment.