Repository: voxpupuli/pypuppetdb (language: python)

Issue:
ssl_verify
While connect's `ssl_verify` *can* be True or False (per the documentation), it can also be the path to a CA Certificate (PEM), or a directory with multiple CA certificates, processed with c_rehash (from OpenSSL). It actually, probably, should default to `/etc/puppetlabs/puppet/certs/ca.pem` rather than "True" ... then the documentation could be updated to no longer indicate the need to mess with the system level CA Trust.

REF: http://docs.python-requests.org/en/master/user/advanced/#ssl-cert-verification
Creating Edge objects doesn't work
The code at:

https://github.com/voxpupuli/pypuppetdb/blob/9905db582232be1626fd9b89e716f2d9802b1f44/pypuppetdb/api.py#L648-L649

...cannot work according to my knowledge (and my IDE) - `resources` is a method in `BaseAPI`.

A simple test script:

```python
#!/usr/bin/env python3
from pypuppetdb import connect
from pypuppetdb.QueryBuilder import EqualsOperator

db = connect()
query = EqualsOperator('certname', 'puppetdb01.c.foobar.internal')
edges = db.edges(query=query)
for edge in edges:
    pass
```

...does in fact fail with:

```
Traceback (most recent call last):
  File "./test_edges.py", line 8, in <module>
    for edge in edges:
  File "/home/foobar/venv/lib/python3.6/site-packages/pypuppetdb/api.py", line 648, in edges
    yield Edge(source=self.resources[identifier_source],
TypeError: 'method' object is not subscriptable
```


Discussion / hints:
@TJM can you provide the PR regarding the documentation?
I am on a different project now, but I will eventually rotate back to that customer. :)

perhaps we should update the docs to state that it supports a string, however im not sure that we should change the default as the localcacert us distro dependent e.g. on debian its /var/lib/puppet/ssl/certs/ca.pem


Fix patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1af01275..276676d5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -44,7 +44,7 @@ do this to run the tests:
 
 ```bash
 # Unit tests
-py.test
+py.test --pep8 --strict
 # Security linter
 bandit -r pypuppetdb
 ```
diff --git a/pypuppetdb/__init__.py b/pypuppetdb/__init__.py
index bc2b2959..585f2dbc 100644
--- a/pypuppetdb/__init__.py
+++ b/pypuppetdb/__init__.py
@@ -58,7 +58,7 @@
 """
 import logging
 
-from pypuppetdb.api import BaseAPI
+from pypuppetdb.api import API
 
 logging.getLogger(__name__).addHandler(logging.NullHandler())
 
@@ -109,7 +109,7 @@ def connect(host='localhost', port=8080, ssl_verify=False, ssl_key=None,
     :param token: (optional) The x-auth token to use for X-Authentication
     :type token: :obj:`None` or :obj:`string`
     """
-    return BaseAPI(host=host, port=port,
-                   timeout=timeout, ssl_verify=ssl_verify, ssl_key=ssl_key,
-                   ssl_cert=ssl_cert, protocol=protocol, url_path=url_path,
-                   username=username, password=password, token=token)
+    return API(host=host, port=port,
+               timeout=timeout, ssl_verify=ssl_verify, ssl_key=ssl_key,
+               ssl_cert=ssl_cert, protocol=protocol, url_path=url_path,
+               username=username, password=password, token=token)
diff --git a/pypuppetdb/api.py b/pypuppetdb/api.py
deleted file mode 100644
index a32d0238..00000000
--- a/pypuppetdb/api.py
+++ /dev/null
@@ -1,1034 +0,0 @@
-from __future__ import absolute_import
-from __future__ import unicode_literals
-
-import hashlib
-import json
-import logging
-from datetime import datetime, timedelta
-from urllib.parse import quote
-
-import requests
-
-from pypuppetdb.QueryBuilder import (EqualsOperator)
-from pypuppetdb.errors import (APIError, DoesNotComputeError, EmptyResponseError)
-from pypuppetdb.types import (Catalog, Edge, Event, Fact, Inventory,
-                              Node, Report, Resource)
-from pypuppetdb.utils import json_to_datetime
-
-log = logging.getLogger(__name__)
-
-ENDPOINTS = {
-    'facts': 'pdb/query/v4/facts',
-    'fact-names': 'pdb/query/v4/fact-names',
-    'nodes': 'pdb/query/v4/nodes',
-    'resources': 'pdb/query/v4/resources',
-    'catalogs': 'pdb/query/v4/catalogs',
-    'mbean': 'metrics/v1/mbeans',
-    # metrics v2 endpoint is now the jolokia library and all of its operations
-    # https://jolokia.org/reference/html/protocol.html#jolokia-operations
-    'metrics': 'metrics/v2/read',
-    'metrics-base': 'metrics/v2',
-    'metrics-exec': 'metrics/v2/exec',
-    'metrics-list': 'metrics/v2/list',
-    'metrics-search': 'metrics/v2/search',
-    'metrics-write': 'metrics/v2/write',
-    'metrics-version': 'metrics/v2/version',
-    'reports': 'pdb/query/v4/reports',
-    'events': 'pdb/query/v4/events',
-    'event-counts': 'pdb/query/v4/event-counts',
-    'aggregate-event-counts': 'pdb/query/v4/aggregate-event-counts',
-    'server-time': 'pdb/meta/v1/server-time',
-    'version': 'pdb/meta/v1/version',
-    'environments': 'pdb/query/v4/environments',
-    'factsets': 'pdb/query/v4/factsets',
-    'fact-paths': 'pdb/query/v4/fact-paths',
-    'fact-contents': 'pdb/query/v4/fact-contents',
-    'edges': 'pdb/query/v4/edges',
-    'pql': 'pdb/query/v4',
-    'inventory': 'pdb/query/v4/inventory',
-    'status': 'status/v1/services/puppetdb-status',
-    'cmd': 'pdb/cmd/v1'
-}
-
-PARAMETERS = {
-    'order_by': 'order_by',
-    'include_total': 'include_total',
-    'count_by': 'count_by',
-    'counts_filter': 'counts_filter',
-    'summarize_by': 'summarize_by',
-    'server_time': 'server_time',
-}
-
-COMMAND_VERSION = {
-    "deactivate node": 3,
-    "replace catalog": 9,
-    "replace facts": 5,
-    "store report": 8
-}
-
-ERROR_STRINGS = {
-    'timeout': 'Connection to PuppetDB timed out on',
-    'refused': 'Could not reach PuppetDB on',
-}
-
-
-class BaseAPI(object):
-    """This is a Base or Abstract class and is not meant to be instantiated
-    or used directly.
-
-    The BaseAPI object defines a set of methods that can be
-    reused across different versions of the PuppetDB API. If querying for a
-    certain resource is done in an identical fashion across different versions
-    it will be implemented here and should be overridden in their respective
-    versions if they deviate.
-
-    If :attr:`ssl` is set to `True` but either :attr:`ssl_key` or\
-            :attr:`ssl_cert` are `None` this will raise an error.
-
-    :param api_version: (Default 4) Version of the API we're initialising.
-    :type api_version: :obj:`int`
-
-    :param host: (optional) Hostname or IP of PuppetDB.
-    :type host: :obj:`string`
-
-    :param port: (optional) Port on which to talk to PuppetDB.
-    :type port: :obj:`int`
-
-    :param ssl_verify: (optional) Verify PuppetDB server certificate.
-    :type ssl_verify: :obj:`bool` or :obj:`string`
-
-    :param ssl_key: (optional) Path to our client secret key.
-    :type ssl_key: :obj:`None` or :obj:`string` representing a filesystem\
-            path.
-
-    :param ssl_cert: (optional) Path to our client certificate.
-    :type ssl_cert: :obj:`None` or :obj:`string` representing a filesystem\
-            path.
-
-    :param timeout: (optional) Number of seconds to wait for a response.
-    :type timeout: :obj:`int`
-
-    :param protocol: (optional) Explicitly specify the protocol to be used
-            (especially handy when using HTTPS with ssl_verify=False and
-            without certs)
-    :type protocol: :obj:`None` or :obj:`string`
-
-    :param url_path: (optional) The URL path where PuppetDB is served
-            (if not at the root / path)
-    :type url_path: :obj:`None` or :obj:`string`
-
-    :param username: (optional) The username to use for HTTP basic
-            authentication
-    :type username: :obj:`None` or :obj:`string`
-
-    :param password: (optional) The password to use for HTTP basic
-            authentication
-    :type password: :obj:`None` or :obj:`string`
-
-    :param token: (optional) The X-auth token to use for X-Authentication
-    :type token: :obj:`None` or :obj:`string`
-
-    :param metric_api_version: (Default 'v2') Version of the metric API we're initialising.
-    :type metric_api_version: :obj:`None` or :obj:`string`
-
-    :raises: :class:`~pypuppetdb.errors.ImproperlyConfiguredError`
-    """
-
-    def __init__(self, host='localhost', port=8080, ssl_verify=True,
-                 ssl_key=None, ssl_cert=None, timeout=10, protocol=None,
-                 url_path=None, username=None, password=None, token=None,
-                 metric_api_version=None):
-        """Initialises our BaseAPI object passing the parameters needed in
-        order to be able to create the connection strings, set up SSL and
-        timeouts and so forth."""
-
-        self.api_version = 'v4'
-        if metric_api_version is not None and metric_api_version not in ['v1', 'v2']:
-            raise ValueError("metric_api_version specified must be None, 'v1' or 'v2',"
-                             " was given: '{}'".format(metric_api_version))
-        self.metric_api_version = metric_api_version if metric_api_version else 'v2'
-        self.host = host
-        self.port = port
-        self.ssl_verify = ssl_verify
-        self.ssl_key = ssl_key
-        self.ssl_cert = ssl_cert
-        self.timeout = timeout
-        self.token = token
-
-        # Standardise the URL path to a format similar to /puppetdb
-        if url_path:
-            if not url_path.startswith('/'):
-                url_path = '/' + url_path
-            if url_path.endswith('/'):
-                url_path = url_path[:-1]
-        else:
-            url_path = ''
-
-        self.url_path = url_path
-        if username and password:
-            self.username = username
-            self.password = password
-        else:
-            self.username = None
-            self.password = None
-
-        self._session = requests.Session()
-        self._session.headers = {
-            'content-type': 'application/json',
-            'accept': 'application/json',
-            'accept-charset': 'utf-8'
-        }
-
-        if self.token:
-            self._session.headers['X-Authentication'] = self.token
-
-        if protocol is not None:
-            protocol = protocol.lower()
-            if protocol not in ['http', 'https']:
-                raise ValueError('Protocol specified must be http or https')
-            self.protocol = protocol
-        elif self.ssl_key is not None and self.ssl_cert is not None:
-            self.protocol = 'https'
-        elif self.token is not None:
-            self.protocol = 'https'
-        else:
-            self.protocol = 'http'
-
-    def disconnect(self):
-        """Close all connections that this class opened up."""
-        # If we don't explicitly close connections, we might cause other
-        # functions or libraries to hang on the open connections. This happens
-        # for example with using paramiko to tunnel PuppetDB connections
-        # through ssh.
-        self._session.close()
-
-    def __enter__(self):
-        """Set up environment for 'with' statement."""
-        # Once this class has been instantiated, there's nothing more required
-        return self
-
-    def __exit__(self, type, value, trace):
-        """Tear down connections."""
-        self.disconnect()
-
-    @property
-    def version(self):
-        """The version of the API we're querying against.
-
-        :returns: Current API version.
-        :rtype: :obj:`string`"""
-        return self.api_version
-
-    @property
-    def base_url(self):
-        """A base_url that will be used to construct the final
-        URL we're going to query against.
-
-        :returns: A URL of the form: ``proto://host:port``.
-        :rtype: :obj:`string`
-        """
-        return '{proto}://{host}:{port}{url_path}'.format(
-            proto=self.protocol,
-            host=self.host,
-            port=self.port,
-            url_path=self.url_path,
-        )
-
-    @property
-    def total(self):
-        """The total-count of the last request to PuppetDB
-        if enabled as parameter in _query method
-
-        :returns Number of total results
-        :rtype :obj:`int`
-        """
-        if self.last_total is not None:
-            return int(self.last_total)
-
-    @staticmethod
-    def _normalize_resource_type(type_):
-        """Normalizes the type passed to the api by capitalizing each part
-        of the type. For example:
-
-        sysctl::value -> Sysctl::Value
-        user -> User
-
-        """
-        return '::'.join([s.capitalize() for s in type_.split('::')])
-
-    def _url(self, endpoint, path=None):
-        """The complete URL we will end up querying. Depending on the
-        endpoint we pass in  this will result in different URL's with
-        different prefixes.
-
-        :param endpoint: The PuppetDB API endpoint we want to query.
-        :type endpoint: :obj:`string`
-        :param path: An additional path if we don't wish to query the\
-                bare endpoint.
-        :type path: :obj:`string`
-
-        :returns: A URL constructed from :func:`base_url` with the\
-                apropraite API version/prefix and the rest of the path added\
-                to it.
-        :rtype: :obj:`string`
-        """
-
-        log.debug('_url called with endpoint: {0} and path: {1}'.format(
-            endpoint, path))
-
-        try:
-            endpoint = ENDPOINTS[endpoint]
-        except KeyError:
-            # If we reach this we're trying to query an endpoint that doesn't
-            # exist. This shouldn't happen unless someone made a booboo.
-            raise APIError
-
-        url = '{base_url}/{endpoint}'.format(
-            base_url=self.base_url,
-            endpoint=endpoint,
-        )
-
-        if path is not None:
-            url = '{0}/{1}'.format(url, quote(path))
-
-        return url
-
-    def _query(self, endpoint, path=None, query=None,
-               order_by=None, limit=None, offset=None, include_total=False,
-               summarize_by=None, count_by=None, count_filter=None,
-               payload=None, request_method='GET'):
-        """This method actually querries PuppetDB. Provided an endpoint and an
-        optional path and/or query it will fire a request at PuppetDB. If
-        PuppetDB can be reached and answers within the timeout we'll decode
-        the response and give it back or raise for the HTTP Status Code
-        PuppetDB gave back.
-
-        :param endpoint: The PuppetDB API endpoint we want to query.
-        :type endpoint: :obj:`string`
-        :param path: An additional path if we don't wish to query the\
-                bare endpoint.
-        :type path: :obj:`string`
-        :param query: (optional) A query to further narrow down the resultset.
-        :type query: :obj:`string`
-        :param order_by: (optional) Set the order parameters for the resultset.
-        :type order_by: :obj:`string`
-        :param limit: (optional) Tell PuppetDB to limit it's response to this\
-                number of objects.
-        :type limit: :obj:`int`
-        :param offset: (optional) Tell PuppetDB to start it's response from\
-                the given offset. This is useful for implementing pagination\
-                but is not supported just yet.
-        :type offset: :obj:`string`
-        :param include_total: (optional) Include the total number of results
-        :type order_by: :obj:`bool`
-        :param summarize_by: (optional) Specify what type of object you'd like\
-                to see counts at the event-counts and aggregate-event-counts \
-                endpoints
-        :type summarize_by: :obj:`string`
-        :param count_by: (optional) Specify what type of object is counted
-        :type count_by: :obj:`string`
-        :param count_filter: (optional) Specify a filter for the results
-        :type count_filter: :obj:`string`
-        :param payload: (optional) Arbitrary payload to send as part of the request.
-        :type payload: :obj:`dict`
-
-        :raises: :class:`~pypuppetdb.errors.EmptyResponseError`
-
-        :returns: The decoded response from PuppetDB
-        :rtype: :obj:`dict` or :obj:`list`
-        """
-        log.debug('_query called with endpoint: {0}, path: {1}, query: {2}, '
-                  'limit: {3}, offset: {4}, summarize_by {5}, count_by {6}, '
-                  'count_filter: {7}, payload: {8}'
-                  .format(endpoint, path, query, limit,
-                          offset, summarize_by, count_by,
-                          count_filter, payload))
-
-        url = self._url(endpoint, path=path)
-        if payload is None:
-            payload = {}
-        if query is not None:
-            payload['query'] = query
-        if order_by is not None:
-            payload[PARAMETERS['order_by']] = order_by
-        if limit is not None:
-            payload['limit'] = limit
-        if include_total is True:
-            payload[PARAMETERS['include_total']] = \
-                json.dumps(include_total)
-        if offset is not None:
-            payload['offset'] = offset
-        if summarize_by is not None:
-            payload[PARAMETERS['summarize_by']] = summarize_by
-        if count_by is not None:
-            payload[PARAMETERS['count_by']] = count_by
-        if count_filter is not None:
-            payload[PARAMETERS['counts_filter']] = count_filter
-
-        if not payload:
-            payload = None
-
-        if not self.token:
-            auth = (self.username, self.password)
-        else:
-            auth = None
-
-        try:
-            if request_method.upper() == 'GET':
-                r = self._session.get(url, params=payload,
-                                      verify=self.ssl_verify,
-                                      cert=(self.ssl_cert, self.ssl_key),
-                                      timeout=self.timeout,
-                                      auth=auth)
-            elif request_method.upper() == 'POST':
-                r = self._session.post(url,
-                                       data=json.dumps(payload, default=str),
-                                       verify=self.ssl_verify,
-                                       cert=(self.ssl_cert, self.ssl_key),
-                                       timeout=self.timeout,
-                                       auth=auth)
-            else:
-                log.error("Only GET or POST supported, {0} unsupported".format(
-                    request_method))
-                raise APIError
-            r.raise_for_status()
-
-            # get total number of results if requested with include-total
-            # just a quick hack - needs improvement
-            if 'X-Records' in r.headers:
-                self.last_total = r.headers['X-Records']
-            else:
-                self.last_total = None
-
-            json_body = r.json()
-            if json_body is not None:
-                return json_body
-            else:
-                del json_body
-                raise EmptyResponseError
-
-        except requests.exceptions.Timeout:
-            log.error("{0} {1}:{2} over {3}.".format(ERROR_STRINGS['timeout'],
-                                                     self.host, self.port,
-                                                     self.protocol.upper()))
-            raise
-        except requests.exceptions.ConnectionError:
-            log.error("{0} {1}:{2} over {3}.".format(ERROR_STRINGS['refused'],
-                                                     self.host, self.port,
-                                                     self.protocol.upper()))
-            raise
-        except requests.exceptions.HTTPError as err:
-            log.error("{0} {1}:{2} over {3}.".format(err.response.text,
-                                                     self.host, self.port,
-                                                     self.protocol.upper()))
-            raise
-
-    def _cmd(self, command, payload):
-        """This method posts commands to PuppetDB. Provided a command and payload
-        it will fire a request at PuppetDB. If PuppetDB can be reached and
-        answers within the timeout we'll decode the response and give it back
-        or raise for the HTTP Status Code yesPuppetDB gave back.
-
-        :param command: The PuppetDB Command we want to execute.
-        :type command: :obj:`string`
-        :param command: The payload, in wire format, specific to the command.
-        :type path: :obj:`dict`
-
-        :raises: :class:`~pypuppetdb.errors.EmptyResponseError`
-
-        :returns: The decoded response from PuppetDB
-        :rtype: :obj:`dict` or :obj:`list`
-        """
-        log.debug('_cmd called with command: {0}, data: {1}'.format(
-            command, payload))
-
-        url = self._url('cmd')
-
-        if command not in COMMAND_VERSION:
-            log.error("Only {0} supported, {1} unsupported".format(
-                list(COMMAND_VERSION.keys()), command))
-            raise APIError
-
-        params = {
-            "command": command,
-            "version": COMMAND_VERSION[command],
-            "certname": payload['certname'],
-            "checksum": hashlib.sha1(str(payload)  # nosec
-                                     .encode('utf-8')).hexdigest()
-        }
-
-        if not self.token:
-            auth = (self.username, self.password)
-        else:
-            auth = None
-
-        try:
-            r = self._session.post(url,
-                                   params=params,
-                                   data=json.dumps(payload, default=str),
-                                   verify=self.ssl_verify,
-                                   cert=(self.ssl_cert, self.ssl_key),
-                                   timeout=self.timeout,
-                                   auth=auth)
-
-            r.raise_for_status()
-
-            json_body = r.json()
-            if json_body is not None:
-                return json_body
-            else:
-                del json_body
-                raise EmptyResponseError
-
-        except requests.exceptions.Timeout:
-            log.error("{0} {1}:{2} over {3}.".format(ERROR_STRINGS['timeout'],
-                                                     self.host, self.port,
-                                                     self.protocol.upper()))
-            raise
-        except requests.exceptions.ConnectionError:
-            log.error("{0} {1}:{2} over {3}.".format(ERROR_STRINGS['refused'],
-                                                     self.host, self.port,
-                                                     self.protocol.upper()))
-            raise
-        except requests.exceptions.HTTPError as err:
-            log.error("{0} {1}:{2} over {3}.".format(err.response.text,
-                                                     self.host, self.port,
-                                                     self.protocol.upper()))
-            raise
-
-    # Method stubs
-
-    def nodes(self, unreported=2, with_status=False, with_event_numbers=True,
-              **kwargs):
-        """Query for nodes by either name or query. If both aren't
-        provided this will return a list of all nodes. This method
-        also (optionally) fetches the nodes status and (optionally)
-        event counts of the latest report from puppetdb.
-
-        :param with_status: (optional) include the node status in the\
-                           returned nodes
-        :type with_status: :bool:
-        :param unreported: (optional) amount of hours when a node gets
-                           marked as unreported
-        :type unreported: :obj:`None` or integer
-        :param with_event_numbers: (optional) include the exact number of\
-                           changed/unchanged/failed/noop events when\
-                           with_status is set to True. If set to False
-                           only "some" string is provided if there are
-                           resources with such status in the last report.
-                           This provides performance benefits as potentially
-                           slow event-counts query is omitted completely.
-        :type with_event_numbers: :bool:
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function
-
-        :returns: A generator yieling Nodes.
-        :rtype: :class:`pypuppetdb.types.Node`
-        """
-        nodes = self._query('nodes', **kwargs)
-        now = datetime.utcnow()
-        # If we happen to only get one node back it
-        # won't be inside a list so iterating over it
-        # goes boom. Therefor we wrap a list around it.
-        if type(nodes) == dict:
-            nodes = [nodes, ]
-
-        if with_status and with_event_numbers:
-            latest_events = self.event_counts(
-                query=EqualsOperator("latest_report?", True),
-                summarize_by='certname'
-            )
-
-        for node in nodes:
-            node['status_report'] = None
-            node['events'] = None
-
-            if with_status:
-                if with_event_numbers:
-                    status = [s for s in latest_events
-                              if s['subject']['title'] == node['certname']]
-
-                    try:
-                        node['status_report'] = node['latest_report_status']
-
-                        if status:
-                            node['events'] = status[0]
-                    except KeyError:
-                        if status:
-                            node['events'] = status = status[0]
-                            if status['successes'] > 0:
-                                node['status_report'] = 'changed'
-                            if status['noops'] > 0:
-                                node['status_report'] = 'noop'
-                            if status['failures'] > 0:
-                                node['status_report'] = 'failed'
-                        else:
-                            node['status_report'] = 'unchanged'
-                else:
-                    node['status_report'] = node['latest_report_status']
-                    node['events'] = {
-                        'successes': 0,
-                        'failures': 0,
-                        'noops': 0,
-                    }
-                    if node['status_report'] == 'changed':
-                        node['events']['successes'] = 'some'
-                    elif node['status_report'] == 'noop':
-                        node['events']['noops'] = 'some'
-                    elif node['status_report'] == 'failed':
-                        node['events']['failures'] = 'some'
-
-                # node report age
-                if node['report_timestamp'] is not None:
-                    try:
-                        last_report = json_to_datetime(
-                            node['report_timestamp'])
-                        last_report = last_report.replace(tzinfo=None)
-                        unreported_border = now - timedelta(hours=unreported)
-                        if last_report < unreported_border:
-                            delta = (now - last_report)
-                            node['unreported'] = True
-                            node['unreported_time'] = '{0}d {1}h {2}m'.format(
-                                delta.days,
-                                int(delta.seconds / 3600),
-                                int((delta.seconds % 3600) / 60)
-                            )
-                    except AttributeError:
-                        node['unreported'] = True
-
-                if not node['report_timestamp']:
-                    node['unreported'] = True
-
-            yield Node(self,
-                       name=node['certname'],
-                       deactivated=node['deactivated'],
-                       expired=node['expired'],
-                       report_timestamp=node['report_timestamp'],
-                       catalog_timestamp=node['catalog_timestamp'],
-                       facts_timestamp=node['facts_timestamp'],
-                       status_report=node['status_report'],
-                       noop=node.get('latest_report_noop'),
-                       noop_pending=node.get('latest_report_noop_pending'),
-                       events=node['events'],
-                       unreported=node.get('unreported'),
-                       unreported_time=node.get('unreported_time'),
-                       report_environment=node['report_environment'],
-                       catalog_environment=node['catalog_environment'],
-                       facts_environment=node['facts_environment'],
-                       latest_report_hash=node.get('latest_report_hash'),
-                       cached_catalog_status=node.get('cached_catalog_status')
-                       )
-
-    def node(self, name):
-        """Gets a single node from PuppetDB.
-
-        :param name: The name of the node search.
-        :type name: :obj:`string`
-
-        :return: An instance of Node
-        :rtype: :class:`pypuppetdb.types.Node`
-        """
-        nodes = self.nodes(path=name)
-        return next(node for node in nodes)
-
-    def edges(self, **kwargs):
-        """Get the known catalog edges, formed between two resources.
-
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function.
-
-        :returns: A generating yielding Edges.
-        :rtype: :class:`pypuppetdb.types.Edge`
-        """
-        edges = self._query('edges', **kwargs)
-
-        for edge in edges:
-            identifier_source = edge['source_type'] + '[' + edge['source_title'] + ']'
-            identifier_target = edge['target_type'] + '[' + edge['target_title'] + ']'
-            yield Edge(source=self.resources[identifier_source],
-                       target=self.resources[identifier_target],
-                       relationship=edge['relationship'],
-                       node=edge['certname'])
-
-    def environments(self, **kwargs):
-        """Get all known environments from Puppetdb.
-
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function.
-
-        :returns: A list of dictionaries containing the results.
-        :rtype: :obj:`list` of :obj:`dict`
-        """
-        return self._query('environments', **kwargs)
-
-    def facts(self, name=None, value=None, **kwargs):
-        """Query for facts limited by either name, value and/or query.
-
-        :param name: (Optional) Only return facts that match this name.
-        :type name: :obj:`string`
-        :param value: (Optional) Only return facts of `name` that\
-            match this value. Use of this parameter requires the `name`\
-            parameter be set.
-        :type value: :obj:`string`
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-            to the _query function
-
-        :returns: A generator yielding Facts.
-        :rtype: :class:`pypuppetdb.types.Fact`
-        """
-        if name is not None and value is not None:
-            path = '{0}/{1}'.format(name, value)
-        elif name is not None and value is None:
-            path = name
-        else:
-            path = None
-
-        facts = self._query('facts', path=path, **kwargs)
-        for fact in facts:
-            yield Fact(
-                node=fact['certname'],
-                name=fact['name'],
-                value=fact['value'],
-                environment=fact['environment']
-            )
-
-    def factsets(self, **kwargs):
-        """Returns a set of all facts or for a single certname.
-
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function.
-
-        :returns: A list of dictionaries containg the results.
-        :rtype: :obj:`list` of :obj:`dict`
-        """
-        return self._query('factsets', **kwargs)
-
-    def fact_contents(self, **kwargs):
-        """To complement fact_paths(), this endpoint provides the capability
-        to descend into structured facts and retreive the values associated
-        with fact paths.
-
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function.
-
-        :returns: A list of dictionaries containg the results.
-        :rtype: :obj:`list` of :obj:`dict`
-        """
-        return self._query('fact-contents', **kwargs)
-
-    def fact_paths(self, **kwargs):
-        """Fact Paths are intended to be a counter-part of the fact-names
-        endpoint. It provides increased granularity around structured
-        facts and may be used for building GUI autocompletions or other
-        applications that require a basic top-level view of fact paths.
-
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function.
-
-        :returns: A list of dictionaries containg the results.
-        :rtype: :obj:`list` of :obj:`dict`
-        """
-        return self._query('fact-paths', **kwargs)
-
-    def resources(self, type_=None, title=None, **kwargs):
-        """Query for resources limited by either type and/or title or query.
-        This will yield a Resources object for every returned resource.
-
-        :param type_: (Optional) The resource type. This can be any resource
-            type referenced in\
-            'https://docs.puppetlabs.com/references/latest/type.html'
-        :type type_: :obj:`string`
-        :param title: (Optional) The name of the resource as declared as the
-            'namevar' in the Puppet Manifests. This parameter requires the\
-            `type_` parameter be set.
-        :type title: :obj:`string`
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-            to the _query function
-
-        :returns: A generator yielding Resources
-        :rtype: :class:`pypuppetdb.types.Resource`
-        """
-        path = None
-
-        if type_ is not None:
-            type_ = self._normalize_resource_type(type_)
-
-            if title is not None:
-                path = '{0}/{1}'.format(type_, title)
-            elif title is None:
-                path = type_
-
-        resources = self._query('resources', path=path, **kwargs)
-        for resource in resources:
-            yield Resource(
-                node=resource['certname'],
-                name=resource['title'],
-                type_=resource['type'],
-                tags=resource['tags'],
-                exported=resource['exported'],
-                sourcefile=resource['file'],
-                sourceline=resource['line'],
-                parameters=resource['parameters'],
-                environment=resource['environment'],
-            )
-
-    def catalog(self, node):
-        """Get the available catalog for a given node.
-
-        :param node: (Required) The name of the PuppetDB node.
-        :type: :obj:`string`
-
-        :returns: An instance of Catalog
-        :rtype: :class:`pypuppetdb.types.Catalog`
-        """
-        catalogs = self.catalogs(path=node)
-        return next(x for x in catalogs)
-
-    def catalogs(self, **kwargs):
-        """Get the catalog information from the infrastructure based on path
-        and/or query results. It is strongly recommended to include query
-        and/or paging parameters for this endpoint to prevent large result
-        sets or PuppetDB performance bottlenecks.
-
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function.
-
-        :returns: A generator yielding Catalogs
-        :rtype: :class:`pypuppetdb.types.Catalog`
-        """
-        catalogs = self._query('catalogs', **kwargs)
-
-        if type(catalogs) == dict:
-            catalogs = [catalogs, ]
-
-        for catalog in catalogs:
-            yield Catalog(node=catalog['certname'],
-                          edges=catalog['edges']['data'],
-                          resources=catalog['resources']['data'],
-                          version=catalog['version'],
-                          transaction_uuid=catalog['transaction_uuid'],
-                          environment=catalog['environment'],
-                          code_id=catalog.get('code_id'),
-                          catalog_uuid=catalog.get('catalog_uuid'))
-
-    def events(self, **kwargs):
-        """A report is made up of events which can be queried either
-        individually or based on their associated report hash. It is strongly
-        recommended to include query and/or paging parameters for this
-        endpoint to prevent large result sets or PuppetDB performance
-        bottlenecks.
-
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function
-
-        :returns: A generator yielding Events
-        :rtype: :class:`pypuppetdb.types.Event`
-        """
-        events = self._query('events', **kwargs)
-        for event in events:
-            yield Event(
-                node=event['certname'],
-                status=event['status'],
-                timestamp=event['timestamp'],
-                hash_=event['report'],
-                title=event['resource_title'],
-                property_=event['property'],
-                message=event['message'],
-                new_value=event['new_value'],
-                old_value=event['old_value'],
-                type_=event['resource_type'],
-                class_=event['containing_class'],
-                execution_path=event['containment_path'],
-                source_file=event['file'],
-                line_number=event['line'],
-            )
-
-    def event_counts(self, summarize_by, **kwargs):
-        """Get event counts from puppetdb.
-
-        :param summarize_by: (Required) The object type to be counted on.
-                             Valid values are 'containing_class', 'resource'
-                             and 'certname'.
-        :type summarize_by: :obj:`string`
-        :param count_by: (Optional) The object type that is counted when
-                         building the counts of 'successes', 'failures',
-                         'noops' and 'skips'. Support values are 'certname'
-                         and 'resource' (default)
-        :type count_by: :obj:`string`
-        :param count_filter: (Optional) A JSON query that is applied to the
-                             event-counts output but before the results are
-                             aggregated. Supported operators are `=`, `>`,
-                             `<`, `>=`, and `<=`. Supported fields are
-                             `failures`, `successes`, `noops`, and `skips`.
-        :type count_filter: :obj:`string`
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function.
-
-        :returns: A list of dictionaries containing the results.
-        :rtype: :obj:`list`
-        """
-        return self._query('event-counts',
-                           summarize_by=summarize_by,
-                           **kwargs)
-
-    def aggregate_event_counts(self, summarize_by, query=None,
-                               count_by=None, count_filter=None):
-        """Get event counts from puppetdb aggregated into a single map.
-
-        :param summarize_by: (Required) The object type to be counted on.
-                             Valid values are 'containing_class', 'resource'
-                             and 'certname' or any comma-separated value
-                             thereof.
-        :type summarize_by: :obj:`string`
-        :param query: (Optional) The PuppetDB query to filter the results.
-                      This query is passed to the `events` endpoint.
-        :type query: :obj:`string`
-        :param count_by: (Optional) The object type that is counted when
-                         building the counts of 'successes', 'failures',
-                         'noops' and 'skips'. Support values are 'certname'
-                         and 'resource' (default)
-        :type count_by: :obj:`string`
-        :param count_filter: (Optional) A JSON query that is applied to the
-                             event-counts output but before the results are
-                             aggregated. Supported operators are `=`, `>`,
-                             `<`, `>=`, and `<=`. Supported fields are
-                             `failures`, `successes`, `noops`, and `skips`.
-        :type count_filter: :obj:`string`
-
-        :returns: A dictionary of name/value results.
-        :rtype: :obj:`dict`
-        """
-        return self._query('aggregate-event-counts',
-                           query=query, summarize_by=summarize_by,
-                           count_by=count_by, count_filter=count_filter)
-
-    def server_time(self):
-        """Get the current time of the clock on the PuppetDB server.
-        :returns: An ISO-8091 formatting timestamp.
-        :rtype: :obj:`string`
-        """
-        return self._query('server-time')[self.parameters['server_time']]
-
-    def current_version(self):
-        """Get version information about the running PuppetDB server.
-
-        :returns: A string representation of the PuppetDB version.
-        :rtype: :obj:`string`
-        """
-        return self._query('version')['version']
-
-    def fact_names(self):
-        """Get a list of all known facts."""
-        return self._query('fact-names')
-
-    def metric(self, metric=None, version=None):
-        """Query for a specific metrc.
-
-        :param metric: The name of the metric we want.
-        :type metric: :obj:`string`
-        :param version: The version of the metric API to query. Valid values: 'v1', 'v2'
-                        If not specified, then the value of self.metric_api_version
-                        will be used.
-        :type version: :obj:`string`
-
-        :returns: The return of :meth:`~pypuppetdb.api.BaseAPI._query`.
-        """
-        version = version if version else self.metric_api_version
-        if version is None or version == 'v2':
-            if metric is None:
-                res = self._query('metrics-list')
-            else:
-                res = self._query('metrics', path=self.escape_metric_name(metric))
-
-            if 'error' in res:
-                raise DoesNotComputeError(res['error'])
-            return res['value']
-        elif version == 'v1':
-            return self._query('mbean', path=metric)
-        else:
-            raise ValueError("Version specified must be 'v1' or 'v2', was given: '{}'"
-                             .format(version))
-
-    def escape_metric_name(self, metric):
-        """Escapes metric names so they can be used in GET requests as part of the URL.
-        The new (as of v2) metrics API is backed by the Jolokia library.
-        The escpaing rules for Jolokia GET requests can be found here:
-        https://jolokia.org/reference/html/protocol.html#escape-rules
-
-        :param metric: The name of the metric we want to escape.
-        :type metric: :obj:`string`
-
-        :returns: The escaped version of the metric name, safe for use in metric GET queries.
-        """
-        metric = metric.replace('!', r'!!')
-        metric = metric.replace('/', r'!/')
-        metric = metric.replace('"', r'!"')
-        return metric
-
-    def reports(self, **kwargs):
-        """Get reports for our infrastructure. It is strongly recommended
-        to include query and/or paging parameters for this endpoint to
-        prevent large result sets and potential PuppetDB performance
-        bottlenecks.
-
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function
-
-        :returns: A generating yielding Reports
-        :rtype: :class:`pypuppetdb.types.Report`
-        """
-        reports = self._query('reports', **kwargs)
-        for report in reports:
-            yield Report(
-                api=self,
-                node=report['certname'],
-                hash_=report['hash'],
-                start=report['start_time'],
-                end=report['end_time'],
-                received=report['receive_time'],
-                version=report['configuration_version'],
-                format_=report['report_format'],
-                agent_version=report['puppet_version'],
-                transaction=report['transaction_uuid'],
-                environment=report['environment'],
-                status=report['status'],
-                noop=report.get('noop'),
-                noop_pending=report.get('noop_pending'),
-                metrics=report['metrics']['data'],
-                logs=report['logs']['data'],
-                code_id=report.get('code_id'),
-                catalog_uuid=report.get('catalog_uuid'),
-                cached_catalog_status=report.get('cached_catalog_status')
-            )
-
-    def inventory(self, **kwargs):
-        """Get Node and Fact information with an alternative query syntax
-        for structured facts instead of using the facts, fact-contents and
-        factsets endpoints for many fact-related queries.
-
-        :param \*\*kwargs: The rest of the keyword arguments are passed
-                           to the _query function.
-
-        :returns: A generator yielding Inventory
-        :rtype: :class:`pypuppetdb.types.Inventory`
-        """
-        inventory = self._query('inventory', **kwargs)
-        for inv in inventory:
-            yield Inventory(
-                node=inv['certname'],
-                time=inv['timestamp'],
-                environment=inv['environment'],
-                facts=inv['facts'],
-                trusted=inv['trusted']
-            )
-
-    def status(self):
-        """Get PuppetDB server status.
-
-        :returns: A dict with the PuppetDB status information
-        :rtype: :obj:`dict`
-        """
-        return self._query('status')
-
-    def command(self, command, payload):
-        return self._cmd(command, payload)
diff --git a/pypuppetdb/api/__init__.py b/pypuppetdb/api/__init__.py
new file mode 100644
index 00000000..d6a7870c
--- /dev/null
+++ b/pypuppetdb/api/__init__.py
@@ -0,0 +1,17 @@
+from pypuppetdb.api.command import CommandAPI
+from pypuppetdb.api.metadata import MetadataAPI
+from pypuppetdb.api.metrics import MetricsAPI
+from pypuppetdb.api.pql import PqlAPI
+from pypuppetdb.api.query import QueryAPI
+from pypuppetdb.api.status import StatusAPI
+
+
+class API(
+    CommandAPI,
+    MetadataAPI,
+    MetricsAPI,
+    PqlAPI,
+    QueryAPI,
+    StatusAPI,
+):
+    pass
diff --git a/pypuppetdb/api/base.py b/pypuppetdb/api/base.py
new file mode 100644
index 00000000..887a091f
--- /dev/null
+++ b/pypuppetdb/api/base.py
@@ -0,0 +1,426 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import json
+import logging
+from urllib.parse import quote
+
+import requests
+
+from pypuppetdb.errors import (APIError, EmptyResponseError)
+
+log = logging.getLogger(__name__)
+
+ENDPOINTS = {
+    'facts': 'pdb/query/v4/facts',
+    'fact-names': 'pdb/query/v4/fact-names',
+    'nodes': 'pdb/query/v4/nodes',
+    'resources': 'pdb/query/v4/resources',
+    'catalogs': 'pdb/query/v4/catalogs',
+    'mbean': 'metrics/v1/mbeans',
+    # metrics v2 endpoint is now the jolokia library and all of its operations
+    # https://jolokia.org/reference/html/protocol.html#jolokia-operations
+    'metrics': 'metrics/v2/read',
+    'metrics-base': 'metrics/v2',
+    'metrics-exec': 'metrics/v2/exec',
+    'metrics-list': 'metrics/v2/list',
+    'metrics-search': 'metrics/v2/search',
+    'metrics-write': 'metrics/v2/write',
+    'metrics-version': 'metrics/v2/version',
+    'reports': 'pdb/query/v4/reports',
+    'events': 'pdb/query/v4/events',
+    'event-counts': 'pdb/query/v4/event-counts',
+    'aggregate-event-counts': 'pdb/query/v4/aggregate-event-counts',
+    'server-time': 'pdb/meta/v1/server-time',
+    'version': 'pdb/meta/v1/version',
+    'environments': 'pdb/query/v4/environments',
+    'factsets': 'pdb/query/v4/factsets',
+    'fact-paths': 'pdb/query/v4/fact-paths',
+    'fact-contents': 'pdb/query/v4/fact-contents',
+    'edges': 'pdb/query/v4/edges',
+    'pql': 'pdb/query/v4',
+    'inventory': 'pdb/query/v4/inventory',
+    'status': 'status/v1/services/puppetdb-status',
+    'cmd': 'pdb/cmd/v1'
+}
+
+PARAMETERS = {
+    'order_by': 'order_by',
+    'include_total': 'include_total',
+    'count_by': 'count_by',
+    'counts_filter': 'counts_filter',
+    'summarize_by': 'summarize_by',
+    'server_time': 'server_time',
+}
+
+COMMAND_VERSION = {
+    "deactivate node": 3,
+    "replace catalog": 9,
+    "replace facts": 5,
+    "store report": 8
+}
+
+ERROR_STRINGS = {
+    'timeout': 'Connection to PuppetDB timed out on',
+    'refused': 'Could not reach PuppetDB on',
+}
+
+
+class BaseAPI(object):
+    """This is a Base or Abstract class and is not meant to be instantiated
+    or used directly.
+
+    The BaseAPI object defines a set of methods that can be
+    reused across different versions of the PuppetDB API. If querying for a
+    certain resource is done in an identical fashion across different versions
+    it will be implemented here and should be overridden in their respective
+    versions if they deviate.
+
+    If :attr:`ssl` is set to `True` but either :attr:`ssl_key` or\
+            :attr:`ssl_cert` are `None` this will raise an error.
+
+    :param host: (optional) Hostname or IP of PuppetDB.
+    :type host: :obj:`string`
+
+    :param port: (optional) Port on which to talk to PuppetDB.
+    :type port: :obj:`int`
+
+    :param ssl_verify: (optional) Verify PuppetDB server certificate.
+    :type ssl_verify: :obj:`bool` or :obj:`string`
+
+    :param ssl_key: (optional) Path to our client secret key.
+    :type ssl_key: :obj:`None` or :obj:`string` representing a filesystem\
+            path.
+
+    :param ssl_cert: (optional) Path to our client certificate.
+    :type ssl_cert: :obj:`None` or :obj:`string` representing a filesystem\
+            path.
+
+    :param timeout: (optional) Number of seconds to wait for a response.
+    :type timeout: :obj:`int`
+
+    :param protocol: (optional) Explicitly specify the protocol to be used
+            (especially handy when using HTTPS with ssl_verify=False and
+            without certs)
+    :type protocol: :obj:`None` or :obj:`string`
+
+    :param url_path: (optional) The URL path where PuppetDB is served
+            (if not at the root / path)
+    :type url_path: :obj:`None` or :obj:`string`
+
+    :param username: (optional) The username to use for HTTP basic
+            authentication
+    :type username: :obj:`None` or :obj:`string`
+
+    :param password: (optional) The password to use for HTTP basic
+            authentication
+    :type password: :obj:`None` or :obj:`string`
+
+    :param token: (optional) The X-auth token to use for X-Authentication
+    :type token: :obj:`None` or :obj:`string`
+
+    :param metric_api_version: (Default 'v2') Version of the metric API we're initialising.
+    :type metric_api_version: :obj:`None` or :obj:`string`
+
+    :raises: :class:`~pypuppetdb.errors.ImproperlyConfiguredError`
+    """
+
+    def __init__(self, host='localhost', port=8080, ssl_verify=True,
+                 ssl_key=None, ssl_cert=None, timeout=10, protocol=None,
+                 url_path=None, username=None, password=None, token=None,
+                 metric_api_version=None):
+        """Initialises our BaseAPI object passing the parameters needed in
+        order to be able to create the connection strings, set up SSL and
+        timeouts and so forth."""
+
+        self.api_version = 'v4'
+        if metric_api_version is not None and metric_api_version not in ['v1', 'v2']:
+            raise ValueError("metric_api_version specified must be None, 'v1' or 'v2',"
+                             " was given: '{}'".format(metric_api_version))
+        self.metric_api_version = metric_api_version if metric_api_version else 'v2'
+        self.host = host
+        self.port = port
+        self.ssl_verify = ssl_verify
+        self.ssl_key = ssl_key
+        self.ssl_cert = ssl_cert
+        self.timeout = timeout
+        self.token = token
+
+        # Standardise the URL path to a format similar to /puppetdb
+        if url_path:
+            if not url_path.startswith('/'):
+                url_path = '/' + url_path
+            if url_path.endswith('/'):
+                url_path = url_path[:-1]
+        else:
+            url_path = ''
+
+        self.url_path = url_path
+
+        self.session = requests.Session()
+
+        if username and password:
+            self.session.auth = (username, password)
+
+        self.session.headers = {
+            'content-type': 'application/json',
+            'accept': 'application/json',
+            'accept-charset': 'utf-8'
+        }
+
+        if self.token:
+            self.session.headers['X-Authentication'] = self.token
+
+        if protocol is not None:
+            protocol = protocol.lower()
+            if protocol not in ['http', 'https']:
+                raise ValueError('Protocol specified must be http or https')
+            self.protocol = protocol
+        elif self.ssl_key is not None and self.ssl_cert is not None:
+            self.protocol = 'https'
+        elif self.token is not None:
+            self.protocol = 'https'
+        else:
+            self.protocol = 'http'
+
+    def disconnect(self):
+        """Close all connections that this class opened up."""
+        # If we don't explicitly close connections, we might cause other
+        # functions or libraries to hang on the open connections. This happens
+        # for example with using paramiko to tunnel PuppetDB connections
+        # through ssh.
+        self.session.close()
+
+    def __enter__(self):
+        """Set up environment for 'with' statement."""
+        # Once this class has been instantiated, there's nothing more required
+        return self
+
+    def __exit__(self, type, value, trace):
+        """Tear down connections."""
+        self.disconnect()
+
+    @property
+    def version(self):
+        """The version of the API we're querying against.
+
+        :returns: Current API version.
+        :rtype: :obj:`string`"""
+        return self.api_version
+
+    @property
+    def base_url(self):
+        """A base_url that will be used to construct the final
+        URL we're going to query against.
+
+        :returns: A URL of the form: ``proto://host:port``.
+        :rtype: :obj:`string`
+        """
+        return '{proto}://{host}:{port}{url_path}'.format(
+            proto=self.protocol,
+            host=self.host,
+            port=self.port,
+            url_path=self.url_path,
+        )
+
+    @property
+    def total(self):
+        """The total-count of the last request to PuppetDB
+        if enabled as parameter in _query method
+
+        :returns Number of total results
+        :rtype :obj:`int`
+        """
+        if self.last_total is not None:
+            return int(self.last_total)
+
+    @staticmethod
+    def _normalize_resource_type(type_):
+        """Normalizes the type passed to the api by capitalizing each part
+        of the type. For example:
+
+        sysctl::value -> Sysctl::Value
+        user -> User
+
+        """
+        return '::'.join([s.capitalize() for s in type_.split('::')])
+
+    def _url(self, endpoint, path=None):
+        """The complete URL we will end up querying. Depending on the
+        endpoint we pass in  this will result in different URL's with
+        different prefixes.
+
+        :param endpoint: The PuppetDB API endpoint we want to query.
+        :type endpoint: :obj:`string`
+        :param path: An additional path if we don't wish to query the\
+                bare endpoint.
+        :type path: :obj:`string`
+
+        :returns: A URL constructed from :func:`base_url` with the\
+                apropraite API version/prefix and the rest of the path added\
+                to it.
+        :rtype: :obj:`string`
+        """
+
+        log.debug('_url called with endpoint: {0} and path: {1}'.format(
+            endpoint, path))
+
+        try:
+            endpoint = ENDPOINTS[endpoint]
+        except KeyError:
+            # If we reach this we're trying to query an endpoint that doesn't
+            # exist. This shouldn't happen unless someone made a booboo.
+            raise APIError
+
+        url = '{base_url}/{endpoint}'.format(
+            base_url=self.base_url,
+            endpoint=endpoint,
+        )
+
+        if path is not None:
+            url = '{0}/{1}'.format(url, quote(path))
+
+        return url
+
+    def _query(self, endpoint=None, path=None, query=None,
+               order_by=None, limit=None, offset=None, include_total=False,
+               summarize_by=None, count_by=None, count_filter=None,
+               payload=None, request_method='GET'):
+        """This method prepares a non-PQL query to PuppetDB. Actual making
+        the HTTP request is done by _make_request().
+
+        :param endpoint: (optional) The PuppetDB API endpoint we want to query.
+                        Unnecessary when using PQL.
+        :type endpoint: :obj:`string`
+        :param path: An additional path if we don't wish to query the\
+                bare endpoint.
+        :type path: :obj:`string`
+        :param query: (optional) An AST query to further narrow down the resultset.
+        :type query: :obj:`string`
+        :param order_by: (optional) Set the order parameters for the resultset.
+        :type order_by: :obj:`string`
+        :param limit: (optional) Tell PuppetDB to limit it's response to this\
+                number of objects.
+        :type limit: :obj:`int`
+        :param offset: (optional) Tell PuppetDB to start it's response from\
+                the given offset. This is useful for implementing pagination\
+                but is not supported just yet.
+        :type offset: :obj:`string`
+        :param include_total: (optional) Include the total number of results
+        :type order_by: :obj:`bool`
+        :param summarize_by: (optional) Specify what type of object you'd like\
+                to see counts at the event-counts and aggregate-event-counts \
+                endpoints
+        :type summarize_by: :obj:`string`
+        :param count_by: (optional) Specify what type of object is counted
+        :type count_by: :obj:`string`
+        :param count_filter: (optional) Specify a filter for the results
+        :type count_filter: :obj:`string`
+        :param payload: (optional) Arbitrary payload to send as part of the request.
+        :type payload: :obj:`dict`
+
+        :raises: :class:`~pypuppetdb.errors.EmptyResponseError`
+
+        :returns: The decoded response from PuppetDB
+        :rtype: :obj:`dict` or :obj:`list`
+        """
+
+        log.debug(f"_query called with ",
+                  # comma-separated list of method arguments with their values
+                  ", ".join([f"{arg}: {locals().get(arg, 'None')}"
+                             for arg in locals().keys() if arg != 'self'])
+                  )
+
+        if not endpoint:
+            log.error("Endpoint is required!")
+            raise APIError
+
+        if payload is None:
+            payload = {}
+
+        url = self._url(endpoint, path=path)
+        if query is not None:
+            payload['query'] = query
+        if order_by is not None:
+            payload[PARAMETERS['order_by']] = order_by
+        if limit is not None:
+            payload['limit'] = limit
+        if include_total is True:
+            payload[PARAMETERS['include_total']] = \
+                json.dumps(include_total)
+        if offset is not None:
+            payload['offset'] = offset
+        if summarize_by is not None:
+            payload[PARAMETERS['summarize_by']] = summarize_by
+        if count_by is not None:
+            payload[PARAMETERS['count_by']] = count_by
+        if count_filter is not None:
+            payload[PARAMETERS['counts_filter']] = count_filter
+
+        return self._make_request(url, request_method, payload)
+
+    def _make_request(self, url, request_method, payload):
+        """
+        Makes a GET or POST HTTP request to PuppetDB. If PuppetDB can be
+        reached and answers within the timeout we'll decode the response
+        and give it back or raise for the HTTP Status Code PuppetDB gave back.
+
+        :param url: Complete URL to call
+        :param request_method: GET or POST
+        :param payload: data to send as parameters (GET) or in the body (POST)
+        :return: response body as JSON
+                 or raises an EmptyResponseError exception if it's empty
+        """
+
+        if request_method.upper() not in ['GET', 'POST']:
+            log.error(f"Only GET or POST supported, {request_method} unsupported")
+            raise APIError
+
+        try:
+            if request_method.upper() == 'GET':
+                r = self.session.get(url, params=payload,
+                                     verify=self.ssl_verify,
+                                     cert=(self.ssl_cert, self.ssl_key),
+                                     timeout=self.timeout,
+                                     )
+            else:
+                r = self.session.post(url,
+                                      data=json.dumps(payload, default=str),
+                                      verify=self.ssl_verify,
+                                      cert=(self.ssl_cert, self.ssl_key),
+                                      timeout=self.timeout,
+                                      )
+
+            r.raise_for_status()
+
+            # get total number of results if requested with include-total
+            # just a quick hack - needs improvement
+            if 'X-Records' in r.headers:
+                self.last_total = r.headers['X-Records']
+            else:
+                self.last_total = None
+
+            json_body = r.json()
+            if json_body is not None:
+                return json_body
+            else:
+                del json_body
+                raise EmptyResponseError
+
+        except requests.exceptions.Timeout:
+            log.error("{0} {1}:{2} over {3}.".format(ERROR_STRINGS['timeout'],
+                                                     self.host, self.port,
+                                                     self.protocol.upper()))
+            raise
+
+        except requests.exceptions.ConnectionError:
+            log.error("{0} {1}:{2} over {3}.".format(ERROR_STRINGS['refused'],
+                                                     self.host, self.port,
+                                                     self.protocol.upper()))
+            raise
+
+        except requests.exceptions.HTTPError as err:
+            log.error("{0} {1}:{2} over {3}.".format(err.response.text,
+                                                     self.host, self.port,
+                                                     self.protocol.upper()))
+            raise
diff --git a/pypuppetdb/api/command.py b/pypuppetdb/api/command.py
new file mode 100644
index 00000000..9f8fc2f2
--- /dev/null
+++ b/pypuppetdb/api/command.py
@@ -0,0 +1,92 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import hashlib
+import json
+import logging
+
+import requests
+
+from pypuppetdb.api.base import BaseAPI, COMMAND_VERSION, ERROR_STRINGS
+from pypuppetdb.errors import (APIError, EmptyResponseError)
+
+log = logging.getLogger(__name__)
+
+
+class CommandAPI(BaseAPI):
+    """
+    This class provides methods that interact with the "pdb/cmd/*"
+    PuppetDB API endpoints.
+    """
+
+    def command(self, command, payload):
+        return self._cmd(command, payload)
+
+    def _cmd(self, command, payload):
+        """This method posts commands to PuppetDB. Provided a command and payload
+        it will fire a request at PuppetDB. If PuppetDB can be reached and
+        answers within the timeout we'll decode the response and give it back
+        or raise for the HTTP Status Code PuppetDB gave back.
+
+        :param command: The PuppetDB Command we want to execute.
+        :type command: :obj:`string`
+
+        :param command: The payload, in wire format, specific to the command.
+        :type command: :obj:`dict`
+
+        :raises: :class:`~pypuppetdb.errors.EmptyResponseError`
+
+        :returns: The decoded response from PuppetDB
+        :rtype: :obj:`dict` or :obj:`list`
+        """
+        log.debug('_cmd called with command: {0}, data: {1}'.format(
+            command, payload))
+
+        url = self._url('cmd')
+
+        if command not in COMMAND_VERSION:
+            log.error("Only {0} supported, {1} unsupported".format(
+                list(COMMAND_VERSION.keys()), command))
+            raise APIError
+
+        params = {
+            "command": command,
+            "version": COMMAND_VERSION[command],
+            "certname": payload['certname'],
+            "checksum": hashlib.sha1(str(payload)  # nosec
+                                     .encode('utf-8')).hexdigest()
+        }
+
+        try:
+            r = self.session.post(url,
+                                  params=params,
+                                  data=json.dumps(payload, default=str),
+                                  verify=self.ssl_verify,
+                                  cert=(self.ssl_cert, self.ssl_key),
+                                  timeout=self.timeout,
+                                  )
+
+            r.raise_for_status()
+
+            json_body = r.json()
+            if json_body is not None:
+                return json_body
+            else:
+                del json_body
+                raise EmptyResponseError
+
+        except requests.exceptions.Timeout:
+            log.error("{0} {1}:{2} over {3}.".format(ERROR_STRINGS['timeout'],
+                                                     self.host, self.port,
+                                                     self.protocol.upper()))
+            raise
+        except requests.exceptions.ConnectionError:
+            log.error("{0} {1}:{2} over {3}.".format(ERROR_STRINGS['refused'],
+                                                     self.host, self.port,
+                                                     self.protocol.upper()))
+            raise
+        except requests.exceptions.HTTPError as err:
+            log.error("{0} {1}:{2} over {3}.".format(err.response.text,
+                                                     self.host, self.port,
+                                                     self.protocol.upper()))
+            raise
diff --git a/pypuppetdb/api/metadata.py b/pypuppetdb/api/metadata.py
new file mode 100644
index 00000000..bf757b5b
--- /dev/null
+++ b/pypuppetdb/api/metadata.py
@@ -0,0 +1,30 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import logging
+
+from pypuppetdb.api.base import BaseAPI
+
+log = logging.getLogger(__name__)
+
+
+class MetadataAPI(BaseAPI):
+    """
+    This class provides methods that interact with the "pdb/meta/*"
+    PuppetDB API endpoints.
+    """
+
+    def server_time(self):
+        """Get the current time of the clock on the PuppetDB server.
+        :returns: An ISO-8091 formatting timestamp.
+        :rtype: :obj:`string`
+        """
+        return self._query('server-time')[self.parameters['server_time']]
+
+    def current_version(self):
+        """Get version information about the running PuppetDB server.
+
+        :returns: A string representation of the PuppetDB version.
+        :rtype: :obj:`string`
+        """
+        return self._query('version')['version']
diff --git a/pypuppetdb/api/metrics.py b/pypuppetdb/api/metrics.py
new file mode 100644
index 00000000..50c25538
--- /dev/null
+++ b/pypuppetdb/api/metrics.py
@@ -0,0 +1,61 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import logging
+
+from pypuppetdb.api.base import BaseAPI
+from pypuppetdb.errors import (DoesNotComputeError)
+
+log = logging.getLogger(__name__)
+
+
+class MetricsAPI(BaseAPI):
+    """
+    This class provides methods that interact with the "metrics/*"
+    PuppetDB API endpoints.
+    """
+
+    def metric(self, metric=None, version=None):
+        """Query for a specific metric.
+
+        :param metric: The name of the metric we want.
+        :type metric: :obj:`string`
+        :param version: The version of the metric API to query. Valid values: 'v1', 'v2'
+                        If not specified, then the value of self.metric_api_version
+                        will be used.
+        :type version: :obj:`string`
+
+        :returns: The return of :meth:`~pypuppetdb.api.BaseAPI._query`.
+        """
+        version = version if version else self.metric_api_version
+        if version is None or version == 'v2':
+            if metric is None:
+                res = self._query('metrics-list')
+            else:
+                res = self._query('metrics', path=self._escape_metric_name(metric))
+
+            if 'error' in res:
+                raise DoesNotComputeError(res['error'])
+            return res['value']
+        elif version == 'v1':
+            return self._query('mbean', path=metric)
+        else:
+            raise ValueError("Version specified must be 'v1' or 'v2', was given: '{}'"
+                             .format(version))
+
+    @staticmethod
+    def _escape_metric_name(metric):
+        """Escapes metric names so they can be used in GET requests as part of the URL.
+        The new (as of v2) metrics API is backed by the Jolokia library.
+        The escpaing rules for Jolokia GET requests can be found here:
+        https://jolokia.org/reference/html/protocol.html#escape-rules
+
+        :param metric: The name of the metric we want to escape.
+        :type metric: :obj:`string`
+
+        :returns: The escaped version of the metric name, safe for use in metric GET queries.
+        """
+        metric = metric.replace('!', r'!!')
+        metric = metric.replace('/', r'!/')
+        metric = metric.replace('"', r'!"')
+        return metric
diff --git a/pypuppetdb/api/pql.py b/pypuppetdb/api/pql.py
new file mode 100644
index 00000000..4c369262
--- /dev/null
+++ b/pypuppetdb/api/pql.py
@@ -0,0 +1,156 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import logging
+import re
+from datetime import datetime
+
+import pypuppetdb
+from pypuppetdb.QueryBuilder import (EqualsOperator)
+from pypuppetdb.api.base import BaseAPI
+from pypuppetdb.errors import (APIError)
+from pypuppetdb.types import (Catalog, Node, Report)
+
+log = logging.getLogger(__name__)
+
+
+class PqlAPI(BaseAPI):
+    """
+    This class provides methods that interact with the "pdb/query/v4"
+    PuppetDB API endpoint.
+    """
+
+    def _pql(self, pql, request_method='GET'):
+        """This method prepares a PQL query to PuppetDB. Actual making
+        the HTTP request is done by _make_request().
+
+        :param pql: PQL query
+        :type pql: :obj:`string`
+
+        :param request_method: (optional) GET or POST, the default is GET
+
+        :raises: :class:`~pypuppetdb.errors.EmptyResponseError`
+
+        :returns: The decoded response from PuppetDB
+        :rtype: :obj:`dict` or :obj:`list`
+        """
+
+        log.debug(f"_pql called with pql={pql}, request_method={request_method}")
+
+        pql = pql.strip()
+        if not pql:
+            log.error("Non-empty PQL query is required!")
+            raise APIError
+
+        payload = {}
+
+        # PQL queries are made to the same endpoint regardless of the queried entities
+        url = self._url('pql')
+        payload['query'] = pql
+
+        return self._make_request(url, request_method, payload)
+
+    # TODO: deduplicate this - see QueryAPI.nodes()
+    def pql(self, pql, with_status=False, unreported=2, with_event_numbers=True):
+        """Makes a PQL (Puppet Query Language) and tries to cast results
+         to a rich type. If it won't work, returns plain dicts.
+
+        :param pql: PQL query
+        :type pql: :obj:`string`
+
+        :param with_status: (optional, only for queries for nodes) include
+                           the node status in the returned nodes
+        :type with_status: :bool:
+        :param unreported: (optional, only for queries for nodes) amount
+                           of hours when a node gets marked as unreported
+        :type unreported: :obj:`None` or integer
+        :param with_event_numbers: (optional, only for queries for nodes)
+                           include the exact number of
+                           changed/unchanged/failed/noop events when
+                           with_status is set to True. If set to False
+                           only "some" string is provided if there are
+                           resources with such status in the last report.
+                           This provides performance benefits as potentially
+                           slow event-counts query is omitted completely.
+        :type with_event_numbers: :bool:
+
+        :returns: A generator yielding elements of a rich type or plain dicts
+        """
+
+        type_class = self._get_type_from_query(pql)
+
+        if type_class == Node and (with_status or unreported != 2 or not with_event_numbers):
+            log.error("with_status, unreported and with_event_numbers are used only"
+                      " for queries for nodes!")
+            raise APIError
+
+        for element in self._pql(pql=pql):
+            if type_class == Node:
+
+                # TODO: deduplicate this - see QueryAPI.nodes()
+
+                now = datetime.utcnow()
+
+                latest_events = None
+                if with_status and with_event_numbers:
+                    latest_events = self._query(
+                        'event-counts',
+                        query=EqualsOperator("latest_report?", True),
+                        summarize_by='certname',
+                    )
+
+                yield Node.create_from_dict(self, element, with_status, with_event_numbers,
+                                            latest_events, now, unreported)
+
+            elif type_class == Report:
+                yield Report.create_from_dict(self, element)
+            elif type_class:
+                yield type_class.create_from_dict(element)
+            else:
+                yield element
+
+    @staticmethod
+    def _get_type_from_query(pql):
+        """Gets a rich type of the entities returned by the given
+        PQL query.
+
+        :param pql: PQL query
+        :type pql: :obj:`string`
+
+        :return: a rich type, if this library supports it
+                 otherwise - None
+        """
+
+        pql = pql.strip()
+        if not pql:
+            log.error("Non-empty PQL query is required!")
+            raise APIError
+
+        # in PQL the beginning of the query is the type of returned entities
+        # but only if the projection is empty ([]) or there is no projection
+        pattern = re.compile(r'([a-z]*?)\s*(\[])?\s*{')
+        match = pattern.match(pql)
+
+        if match:
+            type_name_lowercase = match.group(1)
+
+            # class name is capitalized
+            type_name = type_name_lowercase.capitalize()
+
+            # depluralize - remove trailing "s"
+            if type_name.endswith("s"):
+                type_name_singular = type_name[:-1]
+            else:
+                type_name_singular = type_name
+
+            log.debug(f"Type name: {type_name_singular}")
+            try:
+                type_class = getattr(pypuppetdb.types, type_name_singular)
+                return type_class
+            except AttributeError:
+                log.debug(f"PQL returns entities of a type {type_name_singular},"
+                          f" but it is not supported by this library yet.")
+                return None
+        else:
+            log.debug(f"No match!")
+            return None
diff --git a/pypuppetdb/api/query.py b/pypuppetdb/api/query.py
new file mode 100644
index 00000000..c6ed5b81
--- /dev/null
+++ b/pypuppetdb/api/query.py
@@ -0,0 +1,346 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import logging
+from datetime import datetime
+
+import pypuppetdb
+from pypuppetdb.QueryBuilder import (EqualsOperator)
+from pypuppetdb.api.base import BaseAPI
+from pypuppetdb.types import (Catalog, Edge, Event, Fact, Inventory,
+                              Node, Report, Resource)
+
+log = logging.getLogger(__name__)
+
+
+class QueryAPI(BaseAPI):
+    """
+    This class provides methods that interact with the "pdb/query/v4/*"
+    PuppetDB API endpoints.
+    """
+
+    def nodes(self, unreported=2, with_status=False, with_event_numbers=True,
+              **kwargs):
+        """Query for nodes by either name or query. If both aren't
+        provided this will return a list of all nodes. This method
+        also (optionally) fetches the nodes status and (optionally)
+        event counts of the latest report from puppetdb.
+
+        :param with_status: (optional) include the node status in the\
+                           returned nodes
+        :type with_status: :bool:
+        :param unreported: (optional) amount of hours when a node gets
+                           marked as unreported
+        :type unreported: :obj:`None` or integer
+        :param with_event_numbers: (optional) include the exact number of\
+                           changed/unchanged/failed/noop events when\
+                           with_status is set to True. If set to False
+                           only "some" string is provided if there are
+                           resources with such status in the last report.
+                           This provides performance benefits as potentially
+                           slow event-counts query is omitted completely.
+        :type with_event_numbers: :bool:
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function
+
+        :returns: A generator yieling Nodes.
+        :rtype: :class:`pypuppetdb.types.Node`
+        """
+        nodes = self._query('nodes', **kwargs)
+        now = datetime.utcnow()
+
+        # If we happen to only get one node back it
+        # won't be inside a list so iterating over it
+        # goes boom. Therefor we wrap a list around it.
+        if type(nodes) == dict:
+            nodes = [nodes, ]
+
+        latest_events = None
+        if with_status and with_event_numbers:
+            latest_events = self._query(
+                'event-counts',
+                query=EqualsOperator("latest_report?", True),
+                summarize_by='certname',
+            )
+
+        for node in nodes:
+            yield Node.create_from_dict(self, node, with_status, with_event_numbers, latest_events,
+                                        now, unreported)
+
+    def node(self, name):
+        """Gets a single node from PuppetDB.
+
+        :param name: The name of the node search.
+        :type name: :obj:`string`
+
+        :return: An instance of Node
+        :rtype: :class:`pypuppetdb.types.Node`
+        """
+        nodes = self.nodes(path=name)
+        return next(node for node in nodes)
+
+    def edges(self, **kwargs):
+        """Get the known catalog edges, formed between two resources.
+
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function.
+
+        :returns: A generating yielding Edges.
+        :rtype: :class:`pypuppetdb.types.Edge`
+        """
+        edges = self._query('edges', **kwargs)
+
+        for edge in edges:
+            yield Edge.create_from_dict(edge)
+
+    def environments(self, **kwargs):
+        """Get all known environments from Puppetdb.
+
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function.
+
+        :returns: A list of dictionaries containing the results.
+        :rtype: :obj:`list` of :obj:`dict`
+        """
+        return self._query('environments', **kwargs)
+
+    def facts(self, name=None, value=None, **kwargs):
+        """Query for facts limited by either name, value and/or query.
+
+        :param name: (Optional) Only return facts that match this name.
+        :type name: :obj:`string`
+        :param value: (Optional) Only return facts of `name` that\
+            match this value. Use of this parameter requires the `name`\
+            parameter be set.
+        :type value: :obj:`string`
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+            to the _query function
+
+        :returns: A generator yielding Facts.
+        :rtype: :class:`pypuppetdb.types.Fact`
+        """
+        if name is not None and value is not None:
+            path = '{0}/{1}'.format(name, value)
+        elif name is not None and value is None:
+            path = name
+        else:
+            path = None
+
+        facts = self._query('facts', path=path, **kwargs)
+        for fact in facts:
+            yield Fact.create_from_dict(fact)
+
+    def factsets(self, **kwargs):
+        """Returns a set of all facts or for a single certname.
+
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function.
+
+        :returns: A list of dictionaries containg the results.
+        :rtype: :obj:`list` of :obj:`dict`
+        """
+        return self._query('factsets', **kwargs)
+
+    def fact_contents(self, **kwargs):
+        """To complement fact_paths(), this endpoint provides the capability
+        to descend into structured facts and retreive the values associated
+        with fact paths.
+
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function.
+
+        :returns: A list of dictionaries containg the results.
+        :rtype: :obj:`list` of :obj:`dict`
+        """
+        return self._query('fact-contents', **kwargs)
+
+    def fact_paths(self, **kwargs):
+        """Fact Paths are intended to be a counter-part of the fact-names
+        endpoint. It provides increased granularity around structured
+        facts and may be used for building GUI autocompletions or other
+        applications that require a basic top-level view of fact paths.
+
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function.
+
+        :returns: A list of dictionaries containg the results.
+        :rtype: :obj:`list` of :obj:`dict`
+        """
+        return self._query('fact-paths', **kwargs)
+
+    def resources(self, type_=None, title=None, **kwargs):
+        """Query for resources limited by either type and/or title or query.
+        This will yield a Resources object for every returned resource.
+
+        :param type_: (Optional) The resource type. This can be any resource
+            type referenced in\
+            'https://docs.puppetlabs.com/references/latest/type.html'
+        :type type_: :obj:`string`
+        :param title: (Optional) The name of the resource as declared as the
+            'namevar' in the Puppet Manifests. This parameter requires the\
+            `type_` parameter be set.
+        :type title: :obj:`string`
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+            to the _query function
+
+        :returns: A generator yielding Resources
+        :rtype: :class:`pypuppetdb.types.Resource`
+        """
+
+        path = None
+
+        if type_ is not None:
+            type_ = self._normalize_resource_type(type_)
+
+            if title is not None:
+                path = '{0}/{1}'.format(type_, title)
+            elif title is None:
+                path = type_
+
+        resources = self._query('resources', path=path, **kwargs)
+        for resource in resources:
+            yield Resource.create_from_dict(resource)
+
+    def catalog(self, node):
+        """Get the available catalog for a given node.
+
+        :param node: (Required) The name of the PuppetDB node.
+        :type: :obj:`string`
+
+        :returns: An instance of Catalog
+        :rtype: :class:`pypuppetdb.types.Catalog`
+        """
+        catalogs = self.catalogs(path=node)
+        return next(catalog for catalog in catalogs)
+
+    def catalogs(self, **kwargs):
+        """Get the catalog information from the infrastructure based on path
+        and/or query results. It is strongly recommended to include query
+        and/or paging parameters for this endpoint to prevent large result
+        sets or PuppetDB performance bottlenecks.
+
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function.
+
+        :returns: A generator yielding Catalogs
+        :rtype: :class:`pypuppetdb.types.Catalog`
+        """
+
+        catalogs = self._query('catalogs', **kwargs)
+
+        if type(catalogs) == dict:
+            catalogs = [catalogs, ]
+
+        for catalog in catalogs:
+            yield Catalog.create_from_dict(catalog)
+
+    def events(self, **kwargs):
+        """A report is made up of events which can be queried either
+        individually or based on their associated report hash. It is strongly
+        recommended to include query and/or paging parameters for this
+        endpoint to prevent large result sets or PuppetDB performance
+        bottlenecks.
+
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function
+
+        :returns: A generator yielding Events
+        :rtype: :class:`pypuppetdb.types.Event`
+        """
+        events = self._query('events', **kwargs)
+        for event in events:
+            yield Event.create_from_dict(event)
+
+    def event_counts(self, summarize_by, **kwargs):
+        """Get event counts from puppetdb.
+
+        :param summarize_by: (Required) The object type to be counted on.
+                             Valid values are 'containing_class', 'resource'
+                             and 'certname'.
+        :type summarize_by: :obj:`string`
+        :param count_by: (Optional) The object type that is counted when
+                         building the counts of 'successes', 'failures',
+                         'noops' and 'skips'. Support values are 'certname'
+                         and 'resource' (default)
+        :type count_by: :obj:`string`
+        :param count_filter: (Optional) A JSON query that is applied to the
+                             event-counts output but before the results are
+                             aggregated. Supported operators are `=`, `>`,
+                             `<`, `>=`, and `<=`. Supported fields are
+                             `failures`, `successes`, `noops`, and `skips`.
+        :type count_filter: :obj:`string`
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function.
+
+        :returns: A list of dictionaries containing the results.
+        :rtype: :obj:`list`
+        """
+        return self._query('event-counts',
+                           summarize_by=summarize_by,
+                           **kwargs)
+
+    def aggregate_event_counts(self, summarize_by, query=None,
+                               count_by=None, count_filter=None):
+        """Get event counts from puppetdb aggregated into a single map.
+
+        :param summarize_by: (Required) The object type to be counted on.
+                             Valid values are 'containing_class', 'resource'
+                             and 'certname' or any comma-separated value
+                             thereof.
+        :type summarize_by: :obj:`string`
+        :param query: (Optional) The PuppetDB query to filter the results.
+                      This query is passed to the `events` endpoint.
+        :type query: :obj:`string`
+        :param count_by: (Optional) The object type that is counted when
+                         building the counts of 'successes', 'failures',
+                         'noops' and 'skips'. Support values are 'certname'
+                         and 'resource' (default)
+        :type count_by: :obj:`string`
+        :param count_filter: (Optional) A JSON query that is applied to the
+                             event-counts output but before the results are
+                             aggregated. Supported operators are `=`, `>`,
+                             `<`, `>=`, and `<=`. Supported fields are
+                             `failures`, `successes`, `noops`, and `skips`.
+        :type count_filter: :obj:`string`
+
+        :returns: A dictionary of name/value results.
+        :rtype: :obj:`dict`
+        """
+        return self._query('aggregate-event-counts',
+                           query=query, summarize_by=summarize_by,
+                           count_by=count_by, count_filter=count_filter)
+
+    def fact_names(self):
+        """Get a list of all known facts."""
+        return self._query('fact-names')
+
+    def reports(self, **kwargs):
+        """Get reports for our infrastructure. It is strongly recommended
+        to include query and/or paging parameters for this endpoint to
+        prevent large result sets and potential PuppetDB performance
+        bottlenecks.
+
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function
+
+        :returns: A generating yielding Reports
+        :rtype: :class:`pypuppetdb.types.Report`
+        """
+        reports = self._query('reports', **kwargs)
+        for report in reports:
+            yield Report.create_from_dict(self, report)
+
+    def inventory(self, **kwargs):
+        """Get Node and Fact information with an alternative query syntax
+        for structured facts instead of using the facts, fact-contents and
+        factsets endpoints for many fact-related queries.
+
+        :param \*\*kwargs: The rest of the keyword arguments are passed
+                           to the _query function.
+
+        :returns: A generator yielding Inventory
+        :rtype: :class:`pypuppetdb.types.Inventory`
+        """
+        inventory = self._query('inventory', **kwargs)
+        for inv in inventory:
+            yield Inventory.create_from_dict(inv)
diff --git a/pypuppetdb/api/status.py b/pypuppetdb/api/status.py
new file mode 100644
index 00000000..c68c76b0
--- /dev/null
+++ b/pypuppetdb/api/status.py
@@ -0,0 +1,23 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import logging
+
+from pypuppetdb.api.base import BaseAPI
+
+log = logging.getLogger(__name__)
+
+
+class StatusAPI(BaseAPI):
+    """
+    This class provides methods that interact with the "status/*"
+    PuppetDB API endpoints.
+    """
+
+    def status(self):
+        """Get PuppetDB server status.
+
+        :returns: A dict with the PuppetDB status information
+        :rtype: :obj:`dict`
+        """
+        return self._query('status')
diff --git a/pypuppetdb/types.py b/pypuppetdb/types.py
index 34328bf8..1354e799 100644
--- a/pypuppetdb/types.py
+++ b/pypuppetdb/types.py
@@ -2,6 +2,7 @@
 from __future__ import unicode_literals
 
 import logging
+from datetime import timedelta
 
 from pypuppetdb.QueryBuilder import (EqualsOperator)
 from pypuppetdb.utils import json_to_datetime
@@ -80,6 +81,25 @@ def __repr__(self):
     def __str__(self):
         return str('{0}').format(self.__string)
 
+    @staticmethod
+    def create_from_dict(event):
+        return Event(
+            node=event['certname'],
+            status=event['status'],
+            timestamp=event['timestamp'],
+            hash_=event['report'],
+            title=event['resource_title'],
+            property_=event['property'],
+            message=event['message'],
+            new_value=event['new_value'],
+            old_value=event['old_value'],
+            type_=event['resource_type'],
+            class_=event['containing_class'],
+            execution_path=event['containment_path'],
+            source_file=event['file'],
+            line_number=event['line'],
+        )
+
 
 class Report(object):
     """This object represents a report. Unless otherwise specified all
@@ -205,6 +225,30 @@ def events(self, **kwargs):
         return self.__api.events(query=EqualsOperator("report", self.hash_),
                                  **kwargs)
 
+    @staticmethod
+    def create_from_dict(query_api, report):
+        return Report(
+            api=query_api,
+            node=report['certname'],
+            hash_=report['hash'],
+            start=report['start_time'],
+            end=report['end_time'],
+            received=report['receive_time'],
+            version=report['configuration_version'],
+            format_=report['report_format'],
+            agent_version=report['puppet_version'],
+            transaction=report['transaction_uuid'],
+            environment=report['environment'],
+            status=report['status'],
+            noop=report.get('noop'),
+            noop_pending=report.get('noop_pending'),
+            metrics=report['metrics']['data'],
+            logs=report['logs']['data'],
+            code_id=report.get('code_id'),
+            catalog_uuid=report.get('catalog_uuid'),
+            cached_catalog_status=report.get('cached_catalog_status')
+        )
+
 
 class Fact(object):
     """This object represents a fact. Unless otherwise specified all
@@ -227,6 +271,15 @@ class Fact(object):
     :ivar environment: :obj:`string` holding the fact's environment
     """
 
+    @staticmethod
+    def create_from_dict(fact):
+        return Fact(
+            node=fact['certname'],
+            name=fact['name'],
+            value=fact['value'],
+            environment=fact['environment']
+        )
+
     def __init__(self, node, name, value, environment=None):
         self.node = node
         self.name = name
@@ -299,6 +352,20 @@ def __repr__(self):
     def __str__(self):
         return str('{0}').format(self.__string)
 
+    @staticmethod
+    def create_from_dict(resource):
+        return Resource(
+            node=resource['certname'],
+            name=resource['title'],
+            type_=resource['type'],
+            tags=resource['tags'],
+            exported=resource['exported'],
+            sourcefile=resource['file'],
+            sourceline=resource['line'],
+            parameters=resource['parameters'],
+            environment=resource['environment'],
+        )
+
 
 class Node(object):
     """This object represents a node. It additionally has some helper methods
@@ -489,6 +556,89 @@ def reports(self, **kwargs):
             query=EqualsOperator("certname", self.name),
             **kwargs)
 
+    @staticmethod
+    def create_from_dict(query_api, node, with_status, with_event_numbers, latest_events, now,
+                         unreported):
+
+        node['status_report'] = None
+        node['events'] = None
+
+        if with_status:
+            if with_event_numbers:
+                status = [s for s in latest_events
+                          if s['subject']['title'] == node['certname']]
+
+                try:
+                    node['status_report'] = node['latest_report_status']
+
+                    if status:
+                        node['events'] = status[0]
+                except KeyError:
+                    if status:
+                        node['events'] = status = status[0]
+                        if status['successes'] > 0:
+                            node['status_report'] = 'changed'
+                        if status['noops'] > 0:
+                            node['status_report'] = 'noop'
+                        if status['failures'] > 0:
+                            node['status_report'] = 'failed'
+                    else:
+                        node['status_report'] = 'unchanged'
+            else:
+                node['status_report'] = node['latest_report_status']
+                node['events'] = {
+                    'successes': 0,
+                    'failures': 0,
+                    'noops': 0,
+                }
+                if node['status_report'] == 'changed':
+                    node['events']['successes'] = 'some'
+                elif node['status_report'] == 'noop':
+                    node['events']['noops'] = 'some'
+                elif node['status_report'] == 'failed':
+                    node['events']['failures'] = 'some'
+
+            # node report age
+            if node['report_timestamp'] is not None:
+                try:
+                    last_report = json_to_datetime(
+                        node['report_timestamp'])
+                    last_report = last_report.replace(tzinfo=None)
+                    unreported_border = now - timedelta(hours=unreported)
+                    if last_report < unreported_border:
+                        delta = (now - last_report)
+                        node['unreported'] = True
+                        node['unreported_time'] = '{0}d {1}h {2}m'.format(
+                            delta.days,
+                            int(delta.seconds / 3600),
+                            int((delta.seconds % 3600) / 60)
+                        )
+                except AttributeError:
+                    node['unreported'] = True
+
+            if not node['report_timestamp']:
+                node['unreported'] = True
+
+        return Node(query_api,
+                    name=node['certname'],
+                    deactivated=node['deactivated'],
+                    expired=node['expired'],
+                    report_timestamp=node['report_timestamp'],
+                    catalog_timestamp=node['catalog_timestamp'],
+                    facts_timestamp=node['facts_timestamp'],
+                    status_report=node['status_report'],
+                    noop=node.get('latest_report_noop'),
+                    noop_pending=node.get('latest_report_noop_pending'),
+                    events=node['events'],
+                    unreported=node.get('unreported'),
+                    unreported_time=node.get('unreported_time'),
+                    report_environment=node['report_environment'],
+                    catalog_environment=node['catalog_environment'],
+                    facts_environment=node['facts_environment'],
+                    latest_report_hash=node.get('latest_report_hash'),
+                    cached_catalog_status=node.get('cached_catalog_status')
+                    )
+
 
 class Catalog(object):
     """
@@ -596,6 +746,17 @@ def get_resource(self, resource_type, resource_title):
     def get_edges(self):
         return iter(self.edges)
 
+    @staticmethod
+    def create_from_dict(catalog):
+        return Catalog(node=catalog['certname'],
+                       edges=catalog['edges']['data'],
+                       resources=catalog['resources']['data'],
+                       version=catalog['version'],
+                       transaction_uuid=catalog['transaction_uuid'],
+                       environment=catalog['environment'],
+                       code_id=catalog.get('code_id'),
+                       catalog_uuid=catalog.get('catalog_uuid'))
+
 
 class Edge(object):
     """
@@ -632,6 +793,15 @@ def __repr__(self):
     def __str__(self):
         return str('{0}').format(self.__string)
 
+    @staticmethod
+    def create_from_dict(edge):
+        identifier_source = edge['source_type'] + '[' + edge['source_title'] + ']'
+        identifier_target = edge['target_type'] + '[' + edge['target_title'] + ']'
+        return Edge(source=identifier_source,
+                    target=identifier_target,
+                    relationship=edge['relationship'],
+                    node=edge['certname'])
+
 
 class Inventory(object):
     """This object represents a Node Inventory entry returned from
@@ -674,3 +844,13 @@ def __repr__(self):
 
     def __str__(self):
         return str("{0}").format(self.__string)
+
+    @staticmethod
+    def create_from_dict(inv):
+        return Inventory(
+            node=inv['certname'],
+            time=inv['timestamp'],
+            environment=inv['environment'],
+            facts=inv['facts'],
+            trusted=inv['trusted']
+        )
diff --git a/setup.cfg b/setup.cfg
index c21eb22e..06a2760d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -3,11 +3,6 @@ max-line-length=100
 exclude=venv,dist,build
 ignore=E402
 
-[nosetests]
-with-coverage = 1
-with-xunit = 1
-cover-package = pypuppetdb
-
 [flake8]
 exclude=venv
 
@@ -22,7 +17,3 @@ ignore_errors = True
 
 [bdist_wheel]
 universal = 1
-
-[bdist_rpm]
-build_requires = python-setuptools
-requires = python-requests


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 3715a79e..15cc072c 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -23,7 +23,7 @@ jobs:
           pip install -r requirements-test.txt
       - name: Test
         run: |
-          pytest --pep8 --mypy --strict
+          pytest --pep8 --strict
       - name: Run coveralls
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/conftest.py b/conftest.py
index 330d6012..f4a65a26 100644
--- a/conftest.py
+++ b/conftest.py
@@ -4,13 +4,13 @@
 
 # Set up our API objects
 @pytest.fixture
-def baseapi():
-    return pypuppetdb.api.BaseAPI()
+def api():
+    return pypuppetdb.api.API()
 
 
 @pytest.fixture
-def token_baseapi():
-    return pypuppetdb.api.BaseAPI(token='tokenstring')
+def token_api():
+    return pypuppetdb.api.API(token='tokenstring')
 
 
 @pytest.fixture
diff --git a/tests/test_api_base_init.py b/tests/test_api_base_init.py
new file mode 100644
index 00000000..7395f1c1
--- /dev/null
+++ b/tests/test_api_base_init.py
@@ -0,0 +1,162 @@
+import json
+
+import httpretty
+import pytest
+
+import pypuppetdb
+
+
+def stub_request(url, data=None, method=httpretty.GET, status=200, **kwargs):
+    if data is None:
+        body = '[]'
+    else:
+        with open(data, 'r') as d:
+            body = json.load(d.read())
+    return httpretty.register_uri(method, url, body=body, status=status,
+                                  **kwargs)
+
+
+@pytest.fixture(params=['string', 'QueryBuilder'])
+def query(request):
+    key = 'certname'
+    value = 'node1'
+    if request.param == 'string':
+        return '["{0}", "=", "{1}"]'.format(key, value)
+    elif request.param == 'QueryBuilder':
+        return pypuppetdb.QueryBuilder.EqualsOperator(key, value)
+
+
+class TestBaseAPIVersion(object):
+    def test_init_defaults(self):
+        v4 = pypuppetdb.api.API()
+        assert v4.api_version == 'v4'
+
+
+class TestBaseAPIInitOptions(object):
+    def test_defaults(self, api):
+        assert api.host == 'localhost'
+        assert api.port == 8080
+        assert api.ssl_verify is True
+        assert api.ssl_key is None
+        assert api.ssl_cert is None
+        assert api.timeout == 10
+        assert api.token is None
+        assert api.protocol == 'http'
+        assert api.url_path == ''
+        assert api.session.auth is None
+        assert api.metric_api_version is 'v2'
+
+    def test_host(self):
+        api = pypuppetdb.api.API(host='127.0.0.1')
+        assert api.host == '127.0.0.1'
+
+    def test_port(self):
+        api = pypuppetdb.api.API(port=8081)
+        assert api.port == 8081
+
+    def test_ssl_verify(self):
+        api = pypuppetdb.api.API(ssl_verify=False)
+        assert api.ssl_verify is False
+        assert api.protocol == 'http'
+
+    def test_token(self):
+        test_token = 'tokenstring'  # nosec
+        api = pypuppetdb.api.API(token=test_token)
+        assert api.token == test_token
+        assert api.protocol == 'https'
+
+    def test_ssl_key(self):
+        api = pypuppetdb.api.API(ssl_key='/a/b/c.pem')
+        assert api.ssl_key == '/a/b/c.pem'
+        assert api.protocol == 'http'
+
+    def test_ssl_cert(self):
+        api = pypuppetdb.api.API(ssl_cert='/d/e/f.pem')
+        assert api.ssl_cert == '/d/e/f.pem'
+        assert api.protocol == 'http'
+
+    def test_ssl_key_and_cert(self):
+        api = pypuppetdb.api.API(ssl_cert='/d/e/f.pem',
+                                 ssl_key='/a/b/c.pem')
+        assert api.ssl_key == '/a/b/c.pem'
+        assert api.ssl_cert == '/d/e/f.pem'
+        assert api.protocol == 'https'
+
+    def test_timeout(self):
+        api = pypuppetdb.api.API(timeout=20)
+        assert api.timeout == 20
+
+    def test_protocol(self):
+        api = pypuppetdb.api.API(protocol='https')
+        assert api.protocol == 'https'
+
+    def test_uppercase_protocol(self):
+        api = pypuppetdb.api.API(protocol='HTTP')
+        assert api.protocol == 'http'
+
+    def test_override_protocol(self):
+        api = pypuppetdb.api.API(protocol='http',
+                                 ssl_cert='/d/e/f.pem',
+                                 ssl_key='/a/b/c.pem')
+        assert api.protocol == 'http'
+
+    def test_invalid_protocol(self):
+        with pytest.raises(ValueError):
+            api = pypuppetdb.api.API(protocol='ftp')
+
+    def test_url_path(self):
+        api = pypuppetdb.api.API(url_path='puppetdb')
+        assert api.url_path == '/puppetdb'
+
+    def test_url_path_leading_slash(self):
+        api = pypuppetdb.api.API(url_path='/puppetdb')
+        assert api.url_path == '/puppetdb'
+
+    def test_url_path_trailing_slash(self):
+        api = pypuppetdb.api.API(url_path='puppetdb/')
+        assert api.url_path == '/puppetdb'
+
+    def test_url_path_longer_with_both_slashes(self):
+        api = pypuppetdb.api.API(url_path='/puppet/db/')
+        assert api.url_path == '/puppet/db'
+
+    def test_username(self):
+        api = pypuppetdb.api.API(username='puppetdb')
+        assert api.session.auth is None
+
+    def test_password(self):
+        api = pypuppetdb.api.API(password='password123')  # nosec
+        assert api.session.auth is None
+
+    def test_username_and_password(self):
+        api = pypuppetdb.api.API(username='puppetdb',  # nosec
+                                 password='password123')
+        assert api.session.auth == ('puppetdb', 'password123')
+
+    def test_metric_api_version_v1(self):
+        api = pypuppetdb.api.API(metric_api_version='v1')
+        assert api.metric_api_version == 'v1'
+
+    def test_metric_api_version_v2(self):
+        api = pypuppetdb.api.API(metric_api_version='v2')
+        assert api.metric_api_version == 'v2'
+
+    def test_metric_api_version_invalid_raises(self):
+        with pytest.raises(ValueError):
+            pypuppetdb.api.API(metric_api_version='bad')
+
+
+class TestBaseAPIProperties(object):
+    def test_version(self, api):
+        assert api.version == 'v4'
+
+    def test_base_url(self, api):
+        assert api.base_url == 'http://localhost:8080'
+
+    def test_base_url_ssl(self, api):
+        api.protocol = 'https'  # slightly evil
+        assert api.base_url == 'https://localhost:8080'
+
+    def test_total(self, api):
+        api.last_total = 10  # slightly evil
+        assert api.total == 10
diff --git a/tests/test_api_base_query.py b/tests/test_api_base_query.py
new file mode 100644
index 00000000..e18dc1ba
--- /dev/null
+++ b/tests/test_api_base_query.py
@@ -0,0 +1,270 @@
+import base64
+import json
+
+import httpretty
+import mock
+import pytest
+import requests
+
+import pypuppetdb
+
+
+def stub_request(url, data=None, method=httpretty.GET, status=200, **kwargs):
+    if data is None:
+        body = '[]'
+    else:
+        with open(data, 'r') as d:
+            body = json.load(d.read())
+    return httpretty.register_uri(method, url, body=body, status=status,
+                                  **kwargs)
+
+
+@pytest.fixture(params=['string', 'QueryBuilder'])
+def query(request):
+    key = 'certname'
+    value = 'node1'
+    if request.param == 'string':
+        return '["{0}", "=", "{1}"]'.format(key, value)
+    elif request.param == 'QueryBuilder':
+        return pypuppetdb.QueryBuilder.EqualsOperator(key, value)
+
+
+class TestBaseAPIQuery(object):
+    @mock.patch.object(requests.Session, 'request')
+    def test_timeout(self, get, api):
+        get.side_effect = requests.exceptions.Timeout
+        with pytest.raises(requests.exceptions.Timeout):
+            api._query('nodes')
+        with pytest.raises(requests.exceptions.Timeout):
+            api._cmd('deactivate node', {'certname': ''})
+
+    @mock.patch.object(requests.Session, 'request')
+    def test_connectionerror(self, get, api):
+        get.side_effect = requests.exceptions.ConnectionError
+        with pytest.raises(requests.exceptions.ConnectionError):
+            api._query('nodes')
+        with pytest.raises(requests.exceptions.ConnectionError):
+            api._cmd('deactivate node', {'certname': ''})
+
+    @mock.patch.object(requests.Session, 'request')
+    def test_httperror(self, get, api):
+        get.side_effect = requests.exceptions.HTTPError(
+            response=requests.Response())
+        with pytest.raises(requests.exceptions.HTTPError):
+            api._query('nodes')
+        with pytest.raises(requests.exceptions.HTTPError):
+            api._cmd('deactivate node', {'certname': ''})
+
+    def test_setting_headers_without_token(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api._query('nodes')  # need to query some endpoint
+        request_headers = dict(httpretty.last_request().headers)
+        assert request_headers['accept'] == 'application/json'
+        assert request_headers['content-type'] == 'application/json'
+        assert request_headers['accept-charset'] == 'utf-8'
+        host_val = request_headers.get('host', request_headers.get('Host'))
+        assert host_val == 'localhost:8080'
+        assert httpretty.last_request().path == '/pdb/query/v4/nodes'
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_setting_headers_with_token(self, token_api):
+        httpretty.enable()
+        stub_request('https://localhost:8080/pdb/query/v4/nodes')
+        token_api._query('nodes')  # need to query some endpoint
+        request_headers = dict(httpretty.last_request().headers)
+        print(request_headers)
+        assert request_headers['accept'] == 'application/json'
+        assert request_headers['content-type'] == 'application/json'
+        assert request_headers['accept-charset'] == 'utf-8'
+        host_val = request_headers.get('host', request_headers.get('Host'))
+        assert host_val == 'localhost:8080'
+        x_val = request_headers.get('X-Authentication',
+                                    request_headers.get('x-authentication'))
+        assert x_val == 'tokenstring'
+        assert httpretty.last_request().path == '/pdb/query/v4/nodes'
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_path(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes/node1')
+        api._query('nodes', path='node1')
+        assert httpretty.last_request().path == '/pdb/query/v4/nodes/node1'
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_url_path(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/puppetdb/pdb/query/v4/nodes')
+        api.url_path = '/puppetdb'
+        api._query('nodes')
+        assert httpretty.last_request().path == '/puppetdb/pdb/query/v4/nodes'
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_password_authorization(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api.session.auth = ('puppetdb', 'password123')
+        api._query('nodes')
+        assert httpretty.last_request().path == '/pdb/query/v4/nodes'
+        encoded_cred = 'puppetdb:password123'.encode('utf-8')
+        bs_authheader = base64.b64encode(encoded_cred).decode('utf-8')
+        assert httpretty.last_request().headers['Authorization'] == \
+            'Basic {0}'.format(bs_authheader)
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_token_authorization(self, token_api):
+        httpretty.enable()
+        stub_request('https://localhost:8080/pdb/query/v4/nodes')
+        token_api._query('nodes')
+        assert httpretty.last_request().path == '/pdb/query/v4/nodes'
+        assert httpretty.last_request().headers['X-Authentication'] == \
+            'tokenstring'
+
+    def test_with_query(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api._query('nodes', query='["certname", "=", "node1"]')
+        assert httpretty.last_request().querystring == {
+            'query': ['["certname", "=", "node1"]']}
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_order(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api._query('nodes', order_by='ted')
+        assert httpretty.last_request().querystring == {
+            'order_by': ['ted']}
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_limit(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api._query('nodes', limit=1)
+        assert httpretty.last_request().querystring == {
+            'limit': ['1']}
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_include_total(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api._query('nodes', include_total=True)
+        assert httpretty.last_request().querystring == {
+            'include_total': ['true']}
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_offset(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api._query('nodes', offset=1)
+        assert httpretty.last_request().querystring == {
+            'offset': ['1']}
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_summarize_by(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api._query('nodes', summarize_by=1)
+        assert httpretty.last_request().querystring == {
+            'summarize_by': ['1']}
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_count_by(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api._query('nodes', count_by=1)
+        assert httpretty.last_request().querystring == {
+            'count_by': ['1']}
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_count_filter(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api._query('nodes', count_filter=1)
+        assert httpretty.last_request().querystring == {
+            'counts_filter': ['1']}
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_payload_get(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        api._query('nodes',
+                   payload={'foo': 'bar'},
+                   count_by=1)
+        assert httpretty.last_request().querystring == {
+            'foo': ['bar'],
+            'count_by': ['1']}
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_with_payload_post(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes',
+                     method=httpretty.POST)
+        api._query('nodes',
+                   payload={'foo': 'bar'},
+                   count_by=1,
+                   request_method='POST')
+        assert httpretty.last_request().body == json.dumps({'foo': 'bar',
+                                                            'count_by': 1}).encode("latin-1")
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_response_empty(self, api):
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET,
+                               'http://localhost:8080/pdb/query/v4/nodes',
+                               body=json.dumps(None))
+        with pytest.raises(pypuppetdb.errors.EmptyResponseError):
+            api._query('nodes')
+
+    def test_response_x_records(self, api):
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET,
+                               'http://localhost:8080/pdb/query/v4/nodes',
+                               adding_headers={
+                                   'X-Records': 256},
+                               body='[]',
+                               )
+        api._query('nodes', include_total=True)
+        assert api.total == 256
+
+    def test_query_bad_request_type(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes')
+        with pytest.raises(pypuppetdb.errors.APIError):
+            api._query('nodes',
+                       query='["certname", "=", "node1"]',
+                       request_method='DELETE')
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_query_with_post(self, api, query):
+        httpretty.reset()
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/nodes',
+                     method=httpretty.POST)
+        api._query('nodes',
+                   query=query,
+                   count_by=1,
+                   request_method='POST')
+        last_request = httpretty.last_request()
+        assert last_request.querystring == {}
+        assert last_request.headers['Content-Type'] == 'application/json'
+        assert last_request.method == 'POST'
+        assert last_request.body == json.dumps({'query': str(query),
+                                                'count_by': 1}).encode("latin-1")
+        httpretty.disable()
+        httpretty.reset()
diff --git a/tests/test_api_base_url.py b/tests/test_api_base_url.py
new file mode 100644
index 00000000..2d64101d
--- /dev/null
+++ b/tests/test_api_base_url.py
@@ -0,0 +1,25 @@
+import pytest
+
+import pypuppetdb
+
+
+class TestBaseAPIURL(object):
+
+    def test_without_path(self, api):
+        assert api._url('nodes') == \
+               'http://localhost:8080/pdb/query/v4/nodes'
+
+    def test_with_invalid_endpoint(self, api):
+        with pytest.raises(pypuppetdb.errors.APIError):
+            api._url('this_will-Never+Ex1s7')
+
+    def test_with_path(self, api):
+        url = api._url('nodes', path='node1.example.com')
+        assert url == \
+            'http://localhost:8080/pdb/query/v4/nodes/node1.example.com'
+
+    def test_quote(self, api):
+        url = api._url("facts", path="macaddress/02:42:ec:94:80:f0")
+        assert url == \
+            'http://localhost:8080/pdb/query/v4/' \
+            'facts/macaddress/02%3A42%3Aec%3A94%3A80%3Af0'
diff --git a/tests/test_api_metrics.py b/tests/test_api_metrics.py
new file mode 100644
index 00000000..efe6fa89
--- /dev/null
+++ b/tests/test_api_metrics.py
@@ -0,0 +1,183 @@
+import json
+
+import httpretty
+import pytest
+
+import pypuppetdb
+
+
+def stub_request(url, data=None, method=httpretty.GET, status=200, **kwargs):
+    if data is None:
+        body = '[]'
+    else:
+        with open(data, 'r') as d:
+            body = json.load(d.read())
+    return httpretty.register_uri(method, url, body=body, status=status,
+                                  **kwargs)
+
+
+class TestMetricsAPI(object):
+    def test_metric_v1(self, api):
+        httpretty.enable()
+        httpretty.enable()
+        stub_request('http://localhost:8080/metrics/v1/mbeans/test')
+        api.metric('test', version='v1')
+        assert httpretty.last_request().path == '/metrics/v1/mbeans/test'
+
+    def test_metric_v1_list(self, api):
+        httpretty.enable()
+        httpretty.enable()
+        stub_request('http://localhost:8080/metrics/v1/mbeans')
+        api.metric(version='v1')
+        assert httpretty.last_request().path == '/metrics/v1/mbeans'
+
+    def test_metric_v1_version_constructor(self):
+        api = pypuppetdb.api.API(metric_api_version='v1')
+        httpretty.enable()
+        httpretty.enable()
+        stub_request('http://localhost:8080/metrics/v1/mbeans/test')
+        api.metric('test')
+        assert httpretty.last_request().path == '/metrics/v1/mbeans/test'
+
+    def test_metric_v2(self, api):
+        metrics_body = {
+            'request': {
+                'mbean': 'test:name=Num',
+                'type': 'read'
+            },
+            'value': {
+                'Value': 0
+            },
+            'timestamp': 0,
+            'status': 200
+        }
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET, 'http://localhost:8080/metrics/v2/read/test:name=Num',
+                               body=json.dumps(metrics_body))
+        metric = api.metric('test:name=Num')
+        assert httpretty.last_request().path == '/metrics/v2/read/test%3Aname%3DNum'
+        assert metric['Value'] == 0
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_metric_v2_version_constructor(self):
+        api = pypuppetdb.api.API(metric_api_version='v2')
+        metrics_body = {
+            'request': {
+                'mbean': 'test:name=Num',
+                'type': 'read'
+            },
+            'value': {
+                'Value': 0
+            },
+            'timestamp': 0,
+            'status': 200
+        }
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET, 'http://localhost:8080/metrics/v2/read/test:name=Num',
+                               body=json.dumps(metrics_body))
+        metric = api.metric('test:name=Num')
+        assert httpretty.last_request().path == '/metrics/v2/read/test%3Aname%3DNum'
+        assert metric['Value'] == 0
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_metric_v2_version_string(self, api):
+        metrics_body = {
+            'request': {
+                'mbean': 'test:name=Num',
+                'type': 'read'
+            },
+            'value': {
+                'Value': 0
+            },
+            'timestamp': 0,
+            'status': 200
+        }
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET, 'http://localhost:8080/metrics/v2/read/test:name=Num',
+                               body=json.dumps(metrics_body))
+        metric = api.metric('test:name=Num', version='v2')
+        assert httpretty.last_request().path == '/metrics/v2/read/test%3Aname%3DNum'
+        assert metric['Value'] == 0
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_metric_v2_error(self, api):
+        metrics_body = {
+            'request': {
+                'mbean': 'test:name=Num',
+                'type': 'read'
+            },
+            'error_type': 'javax.management.InstanceNotFoundException',
+            'error': 'javax.management.InstanceNotFoundException : test:name=Num',
+            'status': 404
+        }
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET, 'http://localhost:8080/metrics/v2/read/test:name=Num',
+                               body=json.dumps(metrics_body))
+        with pytest.raises(pypuppetdb.errors.APIError):
+            api.metric('test:name=Num')
+        assert httpretty.last_request().path == '/metrics/v2/read/test%3Aname%3DNum'
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_metric_v2_escape_special_characters(self, api):
+        metrics_body = {
+            'request': {
+                'mbean': 'test:name=Num',
+                'type': 'read'
+            },
+            'value': {
+                'Value': 0
+            },
+            'timestamp': 0,
+            'status': 200
+        }
+
+        httpretty.enable()
+        metric_name = 'test:special/chars!metric"name'
+        metric_escaped = 'test:special!/chars!!metric!"name'
+        metric_escaped_urlencoded = 'test%3Aspecial%21/chars%21%21metric%21%22name'
+        httpretty.register_uri(httpretty.GET,
+                               ('http://localhost:8080/metrics/v2/read/' + metric_escaped),
+                               body=json.dumps(metrics_body))
+        metric = api.metric(metric_name)
+        assert httpretty.last_request().path == ('/metrics/v2/read/' + metric_escaped_urlencoded)
+        assert metric['Value'] == 0
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_metric_v2_list(self, api):
+        # test metric() (no arguments)
+        metrics_body = {
+            'request': {
+                'type': 'list'
+            },
+            'value': {
+                'java.util.logging': {
+                    'type=Logging': {
+                    }
+                },
+            },
+            'timestamp': 0,
+            'status': 200
+        }
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET,
+                               'http://localhost:8080/metrics/v2/list',
+                               body=json.dumps(metrics_body))
+        metric = api.metric()
+        assert httpretty.last_request().path == '/metrics/v2/list'
+        assert metric == {'java.util.logging': {'type=Logging': {}}}
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_metric_bad_version(self, api):
+        with pytest.raises(ValueError):
+            api.metric('test', version='bad')
diff --git a/tests/test_api_other.py b/tests/test_api_other.py
new file mode 100644
index 00000000..09615aa2
--- /dev/null
+++ b/tests/test_api_other.py
@@ -0,0 +1,93 @@
+import json
+
+import httpretty
+import pytest
+
+import pypuppetdb
+
+
+def stub_request(url, data=None, method=httpretty.GET, status=200, **kwargs):
+    if data is None:
+        body = '[]'
+    else:
+        with open(data, 'r') as d:
+            body = json.load(d.read())
+    return httpretty.register_uri(method, url, body=body, status=status,
+                                  **kwargs)
+
+
+@pytest.fixture(params=['string', 'QueryBuilder'])
+def query(request):
+    key = 'certname'
+    value = 'node1'
+    if request.param == 'string':
+        return '["{0}", "=", "{1}"]'.format(key, value)
+    elif request.param == 'QueryBuilder':
+        return pypuppetdb.QueryBuilder.EqualsOperator(key, value)
+
+
+class TestCommandAPI(object):
+
+    def test_command(self, api):
+        httpretty.enable()
+        stub_request(
+            'http://localhost:8080/pdb/cmd/v1',
+            method=httpretty.POST
+        )
+        api.command('deactivate node', {'certname': 'testnode'})
+        assert httpretty.last_request().path.startswith(
+            '/pdb/cmd/v1'
+        )
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_cmd(self, api, query):
+        httpretty.reset()
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/cmd/v1',
+                     method=httpretty.POST)
+        node_name = 'testnode'
+        api._cmd('deactivate node', {'certname': node_name})
+        last_request = httpretty.last_request()
+        assert last_request.querystring == {
+            "certname": [node_name],
+            "command": ['deactivate node'],
+            "version": ['3'],
+            "checksum": ['b93d474970e54943aec050ee399dfb85d21e143a']
+        }
+        assert last_request.headers['Content-Type'] == 'application/json'
+        assert last_request.method == 'POST'
+        assert last_request.body == json.dumps({'certname': node_name}).encode("latin-1")
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_cmd_bad_command(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/cmd/v1')
+        with pytest.raises(pypuppetdb.errors.APIError):
+            api._cmd('incorrect command', {})
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_cmd_with_token_authorization(self, token_api):
+        httpretty.enable()
+        stub_request('https://localhost:8080/pdb/cmd/v1',
+                     method=httpretty.POST)
+        token_api._cmd('deactivate node', {'certname': ''})
+        assert httpretty.last_request().path.startswith('/pdb/cmd/v1')
+        assert httpretty.last_request().headers['X-Authentication'] == \
+            'tokenstring'
+
+
+class TestStatusAPI(object):
+
+    def test_status(self, api):
+        httpretty.enable()
+        stub_request(
+            'http://localhost:8080/status/v1/services/puppetdb-status'
+        )
+        api.status()
+        assert httpretty.last_request().path == \
+            '/status/v1/services/puppetdb-status'
+        httpretty.disable()
+        httpretty.reset()
diff --git a/tests/test_api_pql.py b/tests/test_api_pql.py
new file mode 100644
index 00000000..cd21f9b8
--- /dev/null
+++ b/tests/test_api_pql.py
@@ -0,0 +1,137 @@
+import json
+
+import httpretty
+
+from pypuppetdb.types import Node, Inventory, Fact
+
+
+class TestPqlAPI(object):
+
+    def test_pql_casting(self, api):
+        pql_query = """
+          nodes {
+            facts {
+              name = "operatingsystem" and
+              value = "Debian"
+            }
+          }
+        """
+        pql_body = [
+            {
+                "cached_catalog_status": "not_used",
+                "catalog_environment": "production",
+                "catalog_timestamp": "2016-08-15T11:06:26.275Z",
+                "certname": "greenserver.vm",
+                "deactivated": None,
+                "expired": None,
+                "facts_environment": "production",
+                "facts_timestamp": "2016-08-15T11:06:26.140Z",
+                "latest_report_hash": "4a956674b016d95a7b77c99513ba26e4a744f8d1",
+                "latest_report_noop": False,
+                "latest_report_noop_pending": None,
+                "latest_report_status": "changed",
+                "report_environment": "production",
+                "report_timestamp": "2016-08-15T11:06:18.393Z"
+            },
+            {
+                "cached_catalog_status": "not_used",
+                "catalog_environment": "production",
+                "catalog_timestamp": "2016-08-15T11:06:26.275Z",
+                "certname": "blueserver.vm",
+                "deactivated": None,
+                "expired": None,
+                "facts_environment": "production",
+                "facts_timestamp": "2016-08-15T11:06:26.140Z",
+                "latest_report_hash": "4a956674b016d95a7b77c99513ba26e4a744f8d1",
+                "latest_report_noop": False,
+                "latest_report_noop_pending": None,
+                "latest_report_status": "changed",
+                "report_environment": "production",
+                "report_timestamp": "2016-08-15T11:06:18.393Z"
+            }
+        ]
+        pql_url = 'http://localhost:8080/pdb/query/v4'
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET, pql_url,
+                               body=json.dumps(pql_body))
+
+        nodes = list(api.pql(pql_query))
+
+        assert type(nodes[0]) == Node
+        assert nodes[0].name == "greenserver.vm"
+
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_pql_no_casting(self, api):
+        pql_query = """
+          nodes[certname] {
+            facts {
+              name = "operatingsystem" and
+              value = "Debian"
+            }
+          }
+        """
+        pql_body = [
+            {'certname': 'foo.example.com'},
+            {'certname': 'bar.example.com'},
+        ]
+        pql_url = 'http://localhost:8080/pdb/query/v4'
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET, pql_url,
+                               body=json.dumps(pql_body))
+
+        elements = list(api.pql(pql_query))
+
+        assert httpretty.last_request().path.startswith('/pdb/query/v4')
+        assert elements[0]["certname"] == 'foo.example.com'
+
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_get_type_from_query_matching(self, api):
+        pql = """
+              nodes {
+                facts {
+                  name = "operatingsystem" and
+                  value = "Debian"
+                }
+              }
+            """
+        type = api._get_type_from_query(pql)
+
+        assert type == Node
+
+    def test_get_type_from_query_matching_empty_projection(self, api):
+        pql = """
+              inventory[] {}
+            """
+        type = api._get_type_from_query(pql)
+
+        assert type == Inventory
+
+    def test_get_type_from_query_matching_no_whitespace(self, api):
+        pql = """
+              facts{}
+            """
+        type = api._get_type_from_query(pql)
+
+        assert type == Fact
+
+    def test_get_type_from_query_matching_but_unsupported(self, api):
+        pql = """
+              producers {}
+            """
+        type = api._get_type_from_query(pql)
+
+        assert type is None
+
+    def test_get_type_from_query_not_matching_projection(self, api):
+        pql = """
+              nodes[certname, count()] {}
+            """
+        type = api._get_type_from_query(pql)
+
+        assert type is None
diff --git a/tests/test_api_query.py b/tests/test_api_query.py
new file mode 100644
index 00000000..7c38c2fc
--- /dev/null
+++ b/tests/test_api_query.py
@@ -0,0 +1,125 @@
+import json
+
+import httpretty
+import pytest
+
+import pypuppetdb
+
+
+def stub_request(url, data=None, method=httpretty.GET, status=200, **kwargs):
+    if data is None:
+        body = '[]'
+    else:
+        with open(data, 'r') as d:
+            body = json.load(d.read())
+    return httpretty.register_uri(method, url, body=body, status=status,
+                                  **kwargs)
+
+
+@pytest.fixture(params=['string', 'QueryBuilder'])
+def query(request):
+    key = 'certname'
+    value = 'node1'
+    if request.param == 'string':
+        return '["{0}", "=", "{1}"]'.format(key, value)
+    elif request.param == 'QueryBuilder':
+        return pypuppetdb.QueryBuilder.EqualsOperator(key, value)
+
+
+class TestQueryAPI(object):
+
+    def test_facts(self, api):
+        facts_body = [{
+            'certname': 'test_certname',
+            'name': 'test_name',
+            'value': 'test_value',
+            'environment': 'test_environment',
+        }]
+        facts_url = 'http://localhost:8080/pdb/query/v4/facts'
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET, facts_url,
+                               body=json.dumps(facts_body))
+
+        for fact in api.facts():
+            pass
+
+        assert httpretty.last_request().path == '/pdb/query/v4/facts'
+
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_fact_names(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/fact-names')
+        api.fact_names()
+        assert httpretty.last_request().path == '/pdb/query/v4/fact-names'
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_normalize_resource_type(self, api):
+        assert api._normalize_resource_type('sysctl::value') == \
+               'Sysctl::Value'
+        assert api._normalize_resource_type('user') == 'User'
+
+    def test_environments(self, api):
+        httpretty.enable()
+        stub_request('http://localhost:8080/pdb/query/v4/environments')
+        api.environments()
+        assert httpretty.last_request().path == '/pdb/query/v4/environments'
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_inventory(self, api):
+        inventory_body = [{
+            'certname': 'test_certname',
+            'timestamp': '2017-06-05T20:18:23.374Z',
+            'environment': 'test_environment',
+            'facts': 'test_facts',
+            'trusted': 'test_trusted'
+        }]
+        inventory_url = 'http://localhost:8080/pdb/query/v4/inventory'
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET, inventory_url,
+                               body=json.dumps(inventory_body))
+        for inv in api.inventory():
+            pass
+
+        assert httpretty.last_request().path == '/pdb/query/v4/inventory'
+
+        httpretty.disable()
+        httpretty.reset()
+
+    def test_nodes_single(self, api):
+        body = {
+            "cached_catalog_status": "not_used",
+            "catalog_environment": "production",
+            "catalog_timestamp": "2016-08-15T11:06:26.275Z",
+            "certname": "greenserver.vm",
+            "deactivated": None,
+            "expired": None,
+            "facts_environment": "production",
+            "facts_timestamp": "2016-08-15T11:06:26.140Z",
+            "latest_report_hash": "4a956674b016d95a7b77c99513ba26e4a744f8d1",
+            "latest_report_noop": False,
+            "latest_report_noop_pending": None,
+            "latest_report_status": "changed",
+            "report_environment": "production",
+            "report_timestamp": "2016-08-15T11:06:18.393Z"
+        }
+        url = 'http://localhost:8080/pdb/query/v4/nodes'
+
+        httpretty.enable()
+        httpretty.register_uri(httpretty.GET, url,
+                               body=json.dumps(body))
+
+        nodes = list(api.nodes(query='["=","certname","greenserver.vm"'))
+
+        assert len(nodes) == 1
+        assert nodes[0].name == "greenserver.vm"
+
+        assert httpretty.last_request().path.startswith('/pdb/query/v4/nodes')
+
+        httpretty.disable()
+        httpretty.reset()
diff --git a/tests/test_baseapi.py b/tests/test_baseapi.py
deleted file mode 100644
index 31cff7a3..00000000
--- a/tests/test_baseapi.py
+++ /dev/null
@@ -1,721 +0,0 @@
-import base64
-import json
-import mock
-import httpretty
-import pytest
-import requests
-import pypuppetdb
-
-
-def stub_request(url, data=None, method=httpretty.GET, status=200, **kwargs):
-    if data is None:
-        body = '[]'
-    else:
-        with open(data, 'r') as d:
-            body = json.load(d.read())
-    return httpretty.register_uri(method, url, body=body, status=status,
-                                  **kwargs)
-
-
-@pytest.fixture(params=['string', 'QueryBuilder'])
-def query(request):
-    key = 'certname'
-    value = 'node1'
-    if request.param == 'string':
-        return '["{0}", "=", "{1}"]'.format(key, value)
-    elif request.param == 'QueryBuilder':
-        return pypuppetdb.QueryBuilder.EqualsOperator(key, value)
-
-
-class TestBaseAPIVersion(object):
-    def test_init_defaults(self):
-        v4 = pypuppetdb.api.BaseAPI()
-        assert v4.api_version == 'v4'
-
-
-class TestBaseAPIInitOptions(object):
-    def test_defaults(self, baseapi):
-        assert baseapi.host == 'localhost'
-        assert baseapi.port == 8080
-        assert baseapi.ssl_verify is True
-        assert baseapi.ssl_key is None
-        assert baseapi.ssl_cert is None
-        assert baseapi.timeout == 10
-        assert baseapi.token is None
-        assert baseapi.protocol == 'http'
-        assert baseapi.url_path == ''
-        assert baseapi.username is None
-        assert baseapi.password is None
-        assert baseapi.metric_api_version is 'v2'
-
-    def test_host(self):
-        api = pypuppetdb.api.BaseAPI(host='127.0.0.1')
-        assert api.host == '127.0.0.1'
-
-    def test_port(self):
-        api = pypuppetdb.api.BaseAPI(port=8081)
-        assert api.port == 8081
-
-    def test_ssl_verify(self):
-        api = pypuppetdb.api.BaseAPI(ssl_verify=False)
-        assert api.ssl_verify is False
-        assert api.protocol == 'http'
-
-    def test_token(self):
-        test_token = 'tokenstring'  # nosec
-        api = pypuppetdb.api.BaseAPI(token=test_token)
-        assert api.token == test_token
-        assert api.protocol == 'https'
-
-    def test_ssl_key(self):
-        api = pypuppetdb.api.BaseAPI(ssl_key='/a/b/c.pem')
-        assert api.ssl_key == '/a/b/c.pem'
-        assert api.protocol == 'http'
-
-    def test_ssl_cert(self):
-        api = pypuppetdb.api.BaseAPI(ssl_cert='/d/e/f.pem')
-        assert api.ssl_cert == '/d/e/f.pem'
-        assert api.protocol == 'http'
-
-    def test_ssl_key_and_cert(self):
-        api = pypuppetdb.api.BaseAPI(ssl_cert='/d/e/f.pem',
-                                     ssl_key='/a/b/c.pem')
-        assert api.ssl_key == '/a/b/c.pem'
-        assert api.ssl_cert == '/d/e/f.pem'
-        assert api.protocol == 'https'
-
-    def test_timeout(self):
-        api = pypuppetdb.api.BaseAPI(timeout=20)
-        assert api.timeout == 20
-
-    def test_protocol(self):
-        api = pypuppetdb.api.BaseAPI(protocol='https')
-        assert api.protocol == 'https'
-
-    def test_uppercase_protocol(self):
-        api = pypuppetdb.api.BaseAPI(protocol='HTTP')
-        assert api.protocol == 'http'
-
-    def test_override_protocol(self):
-        api = pypuppetdb.api.BaseAPI(protocol='http',
-                                     ssl_cert='/d/e/f.pem',
-                                     ssl_key='/a/b/c.pem')
-        assert api.protocol == 'http'
-
-    def test_invalid_protocol(self):
-        with pytest.raises(ValueError):
-            api = pypuppetdb.api.BaseAPI(protocol='ftp')
-
-    def test_url_path(self):
-        api = pypuppetdb.api.BaseAPI(url_path='puppetdb')
-        assert api.url_path == '/puppetdb'
-
-    def test_url_path_leading_slash(self):
-        api = pypuppetdb.api.BaseAPI(url_path='/puppetdb')
-        assert api.url_path == '/puppetdb'
-
-    def test_url_path_trailing_slash(self):
-        api = pypuppetdb.api.BaseAPI(url_path='puppetdb/')
-        assert api.url_path == '/puppetdb'
-
-    def test_url_path_longer_with_both_slashes(self):
-        api = pypuppetdb.api.BaseAPI(url_path='/puppet/db/')
-        assert api.url_path == '/puppet/db'
-
-    def test_username(self):
-        api = pypuppetdb.api.BaseAPI(username='puppetdb')
-        assert api.username is None
-        assert api.password is None
-
-    def test_password(self):
-        api = pypuppetdb.api.BaseAPI(password='password123')  # nosec
-        assert api.username is None
-        assert api.password is None
-
-    def test_username_and_password(self):
-        api = pypuppetdb.api.BaseAPI(username='puppetdb',  # nosec
-                                     password='password123')
-        assert api.username == 'puppetdb'
-        assert api.password == 'password123'
-
-    def test_metric_api_version_v1(self):
-        api = pypuppetdb.api.BaseAPI(metric_api_version='v1')
-        assert api.metric_api_version == 'v1'
-
-    def test_metric_api_version_v2(self):
-        api = pypuppetdb.api.BaseAPI(metric_api_version='v2')
-        assert api.metric_api_version == 'v2'
-
-    def test_metric_api_version_invalid_raises(self):
-        with pytest.raises(ValueError):
-            pypuppetdb.api.BaseAPI(metric_api_version='bad')
-
-
-class TestBaseAPIProperties(object):
-    def test_version(self, baseapi):
-        assert baseapi.version == 'v4'
-
-    def test_base_url(self, baseapi):
-        assert baseapi.base_url == 'http://localhost:8080'
-
-    def test_base_url_ssl(self, baseapi):
-        baseapi.protocol = 'https'  # slightly evil
-        assert baseapi.base_url == 'https://localhost:8080'
-
-    def test_total(self, baseapi):
-        baseapi.last_total = 10  # slightly evil
-        assert baseapi.total == 10
-
-
-class TestBaseAPIURL(object):
-    def test_without_path(self, baseapi):
-        assert baseapi._url('nodes') == \
-            'http://localhost:8080/pdb/query/v4/nodes'
-
-    def test_with_invalid_endpoint(self, baseapi):
-        with pytest.raises(pypuppetdb.errors.APIError):
-            baseapi._url('this_will-Never+Ex1s7')
-
-    def test_with_path(self, baseapi):
-        url = baseapi._url('nodes', path='node1.example.com')
-        assert url == \
-            'http://localhost:8080/pdb/query/v4/nodes/node1.example.com'
-
-    def test_quote(self, baseapi):
-        url = baseapi._url("facts", path="macaddress/02:42:ec:94:80:f0")
-        assert url == \
-            'http://localhost:8080/pdb/query/v4/' \
-            + 'facts/macaddress/02%3A42%3Aec%3A94%3A80%3Af0'
-
-
-class TestAPIQuery(object):
-    @mock.patch.object(requests.Session, 'request')
-    def test_timeout(self, get, baseapi):
-        get.side_effect = requests.exceptions.Timeout
-        with pytest.raises(requests.exceptions.Timeout):
-            baseapi._query('nodes')
-        with pytest.raises(requests.exceptions.Timeout):
-            baseapi._cmd('deactivate node', {'certname': ''})
-
-    @mock.patch.object(requests.Session, 'request')
-    def test_connectionerror(self, get, baseapi):
-        get.side_effect = requests.exceptions.ConnectionError
-        with pytest.raises(requests.exceptions.ConnectionError):
-            baseapi._query('nodes')
-        with pytest.raises(requests.exceptions.ConnectionError):
-            baseapi._cmd('deactivate node', {'certname': ''})
-
-    @mock.patch.object(requests.Session, 'request')
-    def test_httperror(self, get, baseapi):
-        get.side_effect = requests.exceptions.HTTPError(
-            response=requests.Response())
-        with pytest.raises(requests.exceptions.HTTPError):
-            baseapi._query('nodes')
-        with pytest.raises(requests.exceptions.HTTPError):
-            baseapi._cmd('deactivate node', {'certname': ''})
-
-    def test_setting_headers_without_token(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi._query('nodes')  # need to query some endpoint
-        request_headers = dict(httpretty.last_request().headers)
-        assert request_headers['accept'] == 'application/json'
-        assert request_headers['content-type'] == 'application/json'
-        assert request_headers['accept-charset'] == 'utf-8'
-        host_val = request_headers.get('host', request_headers.get('Host'))
-        assert host_val == 'localhost:8080'
-        assert httpretty.last_request().path == '/pdb/query/v4/nodes'
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_setting_headers_with_token(self, token_baseapi):
-        httpretty.enable()
-        stub_request('https://localhost:8080/pdb/query/v4/nodes')
-        token_baseapi._query('nodes')  # need to query some endpoint
-        request_headers = dict(httpretty.last_request().headers)
-        print(request_headers)
-        assert request_headers['accept'] == 'application/json'
-        assert request_headers['content-type'] == 'application/json'
-        assert request_headers['accept-charset'] == 'utf-8'
-        host_val = request_headers.get('host', request_headers.get('Host'))
-        assert host_val == 'localhost:8080'
-        x_val = request_headers.get('X-Authentication',
-                                    request_headers.get('x-authentication'))
-        assert x_val == 'tokenstring'
-        assert httpretty.last_request().path == '/pdb/query/v4/nodes'
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_path(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes/node1')
-        baseapi._query('nodes', path='node1')
-        assert httpretty.last_request().path == '/pdb/query/v4/nodes/node1'
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_url_path(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/puppetdb/pdb/query/v4/nodes')
-        baseapi.url_path = '/puppetdb'
-        baseapi._query('nodes')
-        assert httpretty.last_request().path == '/puppetdb/pdb/query/v4/nodes'
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_password_authorization(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi.username = 'puppetdb'
-        baseapi.password = 'password123'
-        baseapi._query('nodes')
-        assert httpretty.last_request().path == '/pdb/query/v4/nodes'
-        encoded_cred = 'puppetdb:password123'.encode('utf-8')
-        bs_authheader = base64.b64encode(encoded_cred).decode('utf-8')
-        assert httpretty.last_request().headers['Authorization'] == \
-            'Basic {0}'.format(bs_authheader)
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_token_authorization(self, token_baseapi):
-        httpretty.enable()
-        stub_request('https://localhost:8080/pdb/query/v4/nodes')
-        token_baseapi._query('nodes')
-        assert httpretty.last_request().path == '/pdb/query/v4/nodes'
-        assert httpretty.last_request().headers['X-Authentication'] == \
-            'tokenstring'
-
-    def test_with_query(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi._query('nodes', query='["certname", "=", "node1"]')
-        assert httpretty.last_request().querystring == {
-            'query': ['["certname", "=", "node1"]']}
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_order(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi._query('nodes', order_by='ted')
-        assert httpretty.last_request().querystring == {
-            'order_by': ['ted']}
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_limit(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi._query('nodes', limit=1)
-        assert httpretty.last_request().querystring == {
-            'limit': ['1']}
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_include_total(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi._query('nodes', include_total=True)
-        assert httpretty.last_request().querystring == {
-            'include_total': ['true']}
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_offset(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi._query('nodes', offset=1)
-        assert httpretty.last_request().querystring == {
-            'offset': ['1']}
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_summarize_by(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi._query('nodes', summarize_by=1)
-        assert httpretty.last_request().querystring == {
-            'summarize_by': ['1']}
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_count_by(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi._query('nodes', count_by=1)
-        assert httpretty.last_request().querystring == {
-            'count_by': ['1']}
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_count_filter(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi._query('nodes', count_filter=1)
-        assert httpretty.last_request().querystring == {
-            'counts_filter': ['1']}
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_payload_get(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        baseapi._query('nodes',
-                       payload={'foo': 'bar'},
-                       count_by=1)
-        assert httpretty.last_request().querystring == {
-            'foo': ['bar'],
-            'count_by': ['1']}
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_with_payload_post(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes',
-                     method=httpretty.POST)
-        baseapi._query('nodes',
-                       payload={'foo': 'bar'},
-                       count_by=1,
-                       request_method='POST')
-        assert httpretty.last_request().body == json.dumps({'foo': 'bar',
-                                                            'count_by': 1}).encode("latin-1")
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_response_empty(self, baseapi):
-        httpretty.enable()
-        httpretty.register_uri(httpretty.GET,
-                               'http://localhost:8080/pdb/query/v4/nodes',
-                               body=json.dumps(None))
-        with pytest.raises(pypuppetdb.errors.EmptyResponseError):
-            baseapi._query('nodes')
-
-    def test_response_x_records(self, baseapi):
-        httpretty.enable()
-        httpretty.register_uri(httpretty.GET,
-                               'http://localhost:8080/pdb/query/v4/nodes',
-                               adding_headers={
-                                   'X-Records': 256},
-                               body='[]',
-                               )
-        baseapi._query('nodes', include_total=True)
-        assert baseapi.total == 256
-
-    def test_query_bad_request_type(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes')
-        with pytest.raises(pypuppetdb.errors.APIError):
-            baseapi._query('nodes',
-                           query='["certname", "=", "node1"]',
-                           request_method='DELETE')
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_query_with_post(self, baseapi, query):
-        httpretty.reset()
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/nodes',
-                     method=httpretty.POST)
-        baseapi._query('nodes',
-                       query=query,
-                       count_by=1,
-                       request_method='POST')
-        last_request = httpretty.last_request()
-        assert last_request.querystring == {}
-        assert last_request.headers['Content-Type'] == 'application/json'
-        assert last_request.method == 'POST'
-        assert last_request.body == json.dumps({'query': str(query),
-                                                'count_by': 1}).encode("latin-1")
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_cmd(self, baseapi, query):
-        httpretty.reset()
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/cmd/v1',
-                     method=httpretty.POST)
-        node_name = 'testnode'
-        baseapi._cmd('deactivate node', {'certname': node_name})
-        last_request = httpretty.last_request()
-        assert last_request.querystring == {
-            "certname": [node_name],
-            "command": ['deactivate node'],
-            "version": ['3'],
-            "checksum": ['b93d474970e54943aec050ee399dfb85d21e143a']
-        }
-        assert last_request.headers['Content-Type'] == 'application/json'
-        assert last_request.method == 'POST'
-        assert last_request.body == json.dumps({'certname': node_name}).encode("latin-1")
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_cmd_bad_command(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/cmd/v1')
-        with pytest.raises(pypuppetdb.errors.APIError):
-            baseapi._cmd('incorrect command', {})
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_cmd_with_token_authorization(self, token_baseapi):
-        httpretty.enable()
-        stub_request('https://localhost:8080/pdb/cmd/v1',
-                     method=httpretty.POST)
-        token_baseapi._cmd('deactivate node', {'certname': ''})
-        assert httpretty.last_request().path.startswith('/pdb/cmd/v1')
-        assert httpretty.last_request().headers['X-Authentication'] == \
-            'tokenstring'
-
-
-class TestAPIMethods(object):
-    def test_metric_v1(self, baseapi):
-        httpretty.enable()
-        httpretty.enable()
-        stub_request('http://localhost:8080/metrics/v1/mbeans/test')
-        baseapi.metric('test', version='v1')
-        assert httpretty.last_request().path == '/metrics/v1/mbeans/test'
-
-    def test_metric_v1_list(self, baseapi):
-        httpretty.enable()
-        httpretty.enable()
-        stub_request('http://localhost:8080/metrics/v1/mbeans')
-        baseapi.metric(version='v1')
-        assert httpretty.last_request().path == '/metrics/v1/mbeans'
-
-    def test_metric_v1_version_constructor(self):
-        api = pypuppetdb.api.BaseAPI(metric_api_version='v1')
-        httpretty.enable()
-        httpretty.enable()
-        stub_request('http://localhost:8080/metrics/v1/mbeans/test')
-        api.metric('test')
-        assert httpretty.last_request().path == '/metrics/v1/mbeans/test'
-
-    def test_metric_v2(self, baseapi):
-        metrics_body = {
-            'request': {
-                'mbean': 'test:name=Num',
-                'type': 'read'
-            },
-            'value': {
-                'Value': 0
-            },
-            'timestamp': 0,
-            'status': 200
-        }
-
-        httpretty.enable()
-        httpretty.register_uri(httpretty.GET, 'http://localhost:8080/metrics/v2/read/test:name=Num',
-                               body=json.dumps(metrics_body))
-        metric = baseapi.metric('test:name=Num')
-        assert httpretty.last_request().path == '/metrics/v2/read/test%3Aname%3DNum'
-        assert metric['Value'] == 0
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_metric_v2_version_constructor(self):
-        api = pypuppetdb.api.BaseAPI(metric_api_version='v2')
-        metrics_body = {
-            'request': {
-                'mbean': 'test:name=Num',
-                'type': 'read'
-            },
-            'value': {
-                'Value': 0
-            },
-            'timestamp': 0,
-            'status': 200
-        }
-
-        httpretty.enable()
-        httpretty.register_uri(httpretty.GET, 'http://localhost:8080/metrics/v2/read/test:name=Num',
-                               body=json.dumps(metrics_body))
-        metric = api.metric('test:name=Num')
-        assert httpretty.last_request().path == '/metrics/v2/read/test%3Aname%3DNum'
-        assert metric['Value'] == 0
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_metric_v2_version_string(self, baseapi):
-        metrics_body = {
-            'request': {
-                'mbean': 'test:name=Num',
-                'type': 'read'
-            },
-            'value': {
-                'Value': 0
-            },
-            'timestamp': 0,
-            'status': 200
-        }
-
-        httpretty.enable()
-        httpretty.register_uri(httpretty.GET, 'http://localhost:8080/metrics/v2/read/test:name=Num',
-                               body=json.dumps(metrics_body))
-        metric = baseapi.metric('test:name=Num', version='v2')
-        assert httpretty.last_request().path == '/metrics/v2/read/test%3Aname%3DNum'
-        assert metric['Value'] == 0
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_metric_v2_error(self, baseapi):
-        metrics_body = {
-            'request': {
-                'mbean': 'test:name=Num',
-                'type': 'read'
-            },
-            'error_type': 'javax.management.InstanceNotFoundException',
-            'error': 'javax.management.InstanceNotFoundException : test:name=Num',
-            'status': 404
-        }
-
-        httpretty.enable()
-        httpretty.register_uri(httpretty.GET, 'http://localhost:8080/metrics/v2/read/test:name=Num',
-                               body=json.dumps(metrics_body))
-        with pytest.raises(pypuppetdb.errors.APIError):
-            baseapi.metric('test:name=Num')
-        assert httpretty.last_request().path == '/metrics/v2/read/test%3Aname%3DNum'
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_metric_v2_escape_special_characters(self, baseapi):
-        metrics_body = {
-            'request': {
-                'mbean': 'test:name=Num',
-                'type': 'read'
-            },
-            'value': {
-                'Value': 0
-            },
-            'timestamp': 0,
-            'status': 200
-        }
-
-        httpretty.enable()
-        metric_name = 'test:special/chars!metric"name'
-        metric_escaped = 'test:special!/chars!!metric!"name'
-        metric_escaped_urlencoded = 'test%3Aspecial%21/chars%21%21metric%21%22name'
-        httpretty.register_uri(httpretty.GET,
-                               ('http://localhost:8080/metrics/v2/read/' + metric_escaped),
-                               body=json.dumps(metrics_body))
-        metric = baseapi.metric(metric_name)
-        assert httpretty.last_request().path == ('/metrics/v2/read/' + metric_escaped_urlencoded)
-        assert metric['Value'] == 0
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_metric_v2_list(self, baseapi):
-        # test metric() (no arguments)
-        metrics_body = {
-            'request': {
-                'type': 'list'
-            },
-            'value': {
-                'java.util.logging': {
-                    'type=Logging': {
-                    }
-                },
-            },
-            'timestamp': 0,
-            'status': 200
-        }
-
-        httpretty.enable()
-        httpretty.register_uri(httpretty.GET,
-                               'http://localhost:8080/metrics/v2/list',
-                               body=json.dumps(metrics_body))
-        metric = baseapi.metric()
-        assert httpretty.last_request().path == '/metrics/v2/list'
-        assert metric == {'java.util.logging': {'type=Logging': {}}}
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_metric_bad_version(self, baseapi):
-        with pytest.raises(ValueError):
-            baseapi.metric('test', version='bad')
-
-    def test_facts(self, baseapi):
-        facts_body = [{
-            'certname': 'test_certname',
-            'name': 'test_name',
-            'value': 'test_value',
-            'environment': 'test_environment',
-        }]
-        facts_url = 'http://localhost:8080/pdb/query/v4/facts'
-
-        httpretty.enable()
-        httpretty.register_uri(httpretty.GET, facts_url,
-                               body=json.dumps(facts_body))
-
-        for fact in baseapi.facts():
-            pass
-
-        assert httpretty.last_request().path == '/pdb/query/v4/facts'
-
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_fact_names(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/fact-names')
-        baseapi.fact_names()
-        assert httpretty.last_request().path == '/pdb/query/v4/fact-names'
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_normalize_resource_type(self, baseapi):
-        assert baseapi._normalize_resource_type('sysctl::value') == \
-            'Sysctl::Value'
-        assert baseapi._normalize_resource_type('user') == 'User'
-
-    def test_environments(self, baseapi):
-        httpretty.enable()
-        stub_request('http://localhost:8080/pdb/query/v4/environments')
-        baseapi.environments()
-        assert httpretty.last_request().path == '/pdb/query/v4/environments'
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_inventory(self, baseapi):
-        inventory_body = [{
-            'certname': 'test_certname',
-            'timestamp': '2017-06-05T20:18:23.374Z',
-            'environment': 'test_environment',
-            'facts': 'test_facts',
-            'trusted': 'test_trusted'
-        }]
-        inventory_url = 'http://localhost:8080/pdb/query/v4/inventory'
-
-        httpretty.enable()
-        httpretty.register_uri(httpretty.GET, inventory_url,
-                               body=json.dumps(inventory_body))
-        for inv in baseapi.inventory():
-            pass
-
-        assert httpretty.last_request().path == '/pdb/query/v4/inventory'
-
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_status(self, baseapi):
-        httpretty.enable()
-        stub_request(
-            'http://localhost:8080/status/v1/services/puppetdb-status'
-            )
-        baseapi.status()
-        assert httpretty.last_request().path == \
-            '/status/v1/services/puppetdb-status'
-        httpretty.disable()
-        httpretty.reset()
-
-    def test_command(self, baseapi):
-        httpretty.enable()
-        stub_request(
-            'http://localhost:8080/pdb/cmd/v1',
-            method=httpretty.POST
-        )
-        baseapi.command('deactivate node', {'certname': 'testnode'})
-        assert httpretty.last_request().path.startswith(
-               '/pdb/cmd/v1'
-        )
-        httpretty.disable()
-        httpretty.reset()
diff --git a/tests/test_connect.py b/tests/test_connect.py
index f8759f57..2aff3640 100644
--- a/tests/test_connect.py
+++ b/tests/test_connect.py
@@ -3,7 +3,7 @@
 import pypuppetdb
 
 
-@mock.patch('pypuppetdb.api.requests.Session.close')
+@mock.patch('pypuppetdb.api.base.requests.Session.close')
 def test_connect_api(session_close):
     puppetdb = pypuppetdb.connect()
     assert puppetdb.version == 'v4'
@@ -12,7 +12,7 @@ def test_connect_api(session_close):
     assert session_close.called
 
 
-@mock.patch('pypuppetdb.api.requests.Session.close')
+@mock.patch('pypuppetdb.api.base.requests.Session.close')
 def test_connect_with_statement(session_close):
     with pypuppetdb.connect() as puppetdb:
         assert puppetdb.version == 'v4'
diff --git a/tests/test_querybuilder.py b/tests/test_querybuilder.py
index b55ec692..cc3ed3d6 100644
--- a/tests/test_querybuilder.py
+++ b/tests/test_querybuilder.py
@@ -7,7 +7,7 @@
                                      InOperator, LessEqualOperator, LessOperator, NotOperator,
                                      NullOperator, OrOperator, RegexArrayOperator, RegexOperator,
                                      SubqueryOperator)
-from pypuppetdb.api import APIError
+from pypuppetdb.errors import APIError
 
 
 class TestBinaryOperator(object):