diff --git a/README.md b/README.md
index d927f02..6f0b346 100644
--- a/README.md
+++ b/README.md
@@ -93,8 +93,21 @@ config['grantType'] = 'client_credentials'
# Since the grant type is client_credentials a
# ClientCredentialsConfiguration object is returned
try:
+
+ # To load configuration at runtime, pass the config dictionary argument to
+ # the ConnectionConfiguration:
ClientCredentialsConfiguration = ConnectionConfiguration().init(config)
+ # Note: The configuration can also be loaded from an INI config file. The
+ # default location for the config file is $HOME/adp_connection.ini. A
+ # custom location for the config file can be specified by setting the
+ # ADP_CONNECTION_CONFIG environment variable.
+
+ # To load a configuration object from a INI config file, initialize the
+ # ConnectionConfiguration without a config dictionary:
+
+ # ClientCredentialsConfiguration = ConnectionConfiguration().init()
+
# Using the new configuration object create a connection
ccConnection = ADPAPIConnectionFactory().createConnection(ClientCredentialsConfiguration)
diff --git a/adp_connection/__init__.py b/adp_connection/__init__.py
index 10b083b..13f1282 100644
--- a/adp_connection/__init__.py
+++ b/adp_connection/__init__.py
@@ -18,4 +18,5 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
+from __future__ import absolute_import
from adp_connection.version import __version__ # NOQA
diff --git a/adp_connection/democlient/sampleApp.py b/adp_connection/democlient/sampleApp.py
index 438226d..013cb3a 100755
--- a/adp_connection/democlient/sampleApp.py
+++ b/adp_connection/democlient/sampleApp.py
@@ -18,9 +18,11 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
+from __future__ import absolute_import
+from __future__ import print_function
import sys
-from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
-from urlparse import urlparse, parse_qs
+from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
+from six.moves.urllib.parse import urlparse, parse_qs
from os import curdir, sep
from adp_connection.lib import *
from adp_connection import __version__
@@ -96,7 +98,7 @@ def do_GET(self):
else:
resp = 'Not Connected!'
except ConfigError as conferr:
- print conferr.msg
+ print(conferr.msg)
except ConnectError as connecterr:
resp = 'Connection Error'
resp = resp + '
Class: ' + connecterr.cname
@@ -161,10 +163,10 @@ def do_GET(self):
self.end_headers()
return
except ConfigError as conferr:
- print conferr.msg
+ print(conferr.msg)
raise
except ConnectError as connecterr:
- print connecterr.msg
+ print(connecterr.msg)
raise
# Handle the callback request after a login attempt for an
@@ -217,7 +219,7 @@ def do_GET(self):
resp = resp + '
Error Message: ' + connecterr.msg
resp = resp + '
API Class: ' + connecterr.cname
except:
- print "Unexpected error:", str(sys.exc_info())
+ print("Unexpected error:", str(sys.exc_info()))
finally:
self.send_response(200)
self.send_header('Content-type', 'text/html')
@@ -262,11 +264,11 @@ def do_GET(self):
# Create a web server and define the handler to manage the
# incoming request
server = HTTPServer(('', PORT_NUMBER), httpHandler)
- print 'adp-connection-python version ' + __version__ + ' started httpserver on port ', PORT_NUMBER
+ print('adp-connection-python version ' + __version__ + ' started httpserver on port ', PORT_NUMBER)
# Wait forever for incoming htto requests
server.serve_forever()
except KeyboardInterrupt:
- print '^C received, shutting down the web server'
+ print('^C received, shutting down the web server')
server.socket.close()
diff --git a/adp_connection/docs/conf.py b/adp_connection/docs/conf.py
index 170826b..07ec405 100644
--- a/adp_connection/docs/conf.py
+++ b/adp_connection/docs/conf.py
@@ -12,6 +12,7 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
+from __future__ import absolute_import
import sys
import os
diff --git a/adp_connection/example_config.ini b/adp_connection/example_config.ini
new file mode 100644
index 0000000..22baf43
--- /dev/null
+++ b/adp_connection/example_config.ini
@@ -0,0 +1,9 @@
+[ADP Connection]
+clientID=<>
+clientSecret=<>
+sslCertPath=/path/to/certs/ssl.pem
+sslKeyPath=/path/to/certs/auth.key
+apiRequestURL=https://api.adp.com
+tokenServerURL=https://accounts.adp.com/auth/oauth/v2/token
+disconnectURL=https://accounts.adp.com/auth/oauth/v2/logout
+grantType=client_credentials
diff --git a/adp_connection/lib/__init__.py b/adp_connection/lib/__init__.py
index 2d4322e..8144fef 100644
--- a/adp_connection/lib/__init__.py
+++ b/adp_connection/lib/__init__.py
@@ -18,7 +18,8 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
-from connectionconfiguration import *
-from adpapiconnection import *
-from connectexceptions import *
-from adpapiconnectionfactory import *
+from __future__ import absolute_import
+from .connectionconfiguration import *
+from .adpapiconnection import *
+from .connectexceptions import *
+from .adpapiconnectionfactory import *
diff --git a/adp_connection/lib/adpapiconnection.py b/adp_connection/lib/adpapiconnection.py
index f32171c..48cd289 100644
--- a/adp_connection/lib/adpapiconnection.py
+++ b/adp_connection/lib/adpapiconnection.py
@@ -18,12 +18,13 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
+from __future__ import absolute_import
import requests
import datetime
import uuid
import logging
-from connectexceptions import *
-from connectionconfiguration import *
+from .connectexceptions import *
+from .connectionconfiguration import *
from adp_connection import __version__
@@ -35,10 +36,11 @@ class ADPAPIConnection(object):
connectionConfiguration: instance of the ConnectionConfiguration class
that was used to instantiate the connection """
- connection = {'status': 'availabe', 'type': 'unknown', 'token': '',
+ connection = {'status': 'available', 'type': 'unknown', 'token': '',
'expires': '', 'sessionState': ''}
connectionConfiguration = None
userAgent = 'adp-userinfo-python/' + __version__
+ lastEventId = None
def isConnectedIndicator(self):
""" Returns: a boolen depending on whether the connection
@@ -115,7 +117,127 @@ def disconnect(self):
headers=(headers))
logging.debug(r.status_code)
self.connection = {'status': 'ready', 'type': 'unknown', 'token': '',
- 'expires': '', 'state': ''}
+ 'expires': '', 'sessionState': ''}
+
+ def reconnect(self, url, method, headers={}, params={}, data={}):
+ """Reconnect to ADP API after token expiration."""
+ self.disconnect()
+ self.connect()
+ return self.request(url, method=method, headers=headers, params=params, data=data)
+
+ def request(self, url, method='get', headers={}, params={}, data={}):
+ """Expose an HTTP Requests object configured to work with the ADP API connection.
+ Attempt an authenticated request to the ADP API. Pass access token and
+ TLS certificate to configure bearer token authentication for request. Connect to
+ ADP automatically. Attempt to reconnect when token expiration is detected.
+
+ Args:
+ url (str): The API url endpoint.
+ method (str): The HTTP method: 'get', 'post', or 'delete' are supported.
+ headers (dict): Additional http headers to supply with the request.
+ params (dict): Query string parameters for the request.
+ data (dict): POST variables for the request.
+
+ Returns:
+ The HTTP Requests object containing the http result object.
+ """
+ if not self.isConnectedIndicator():
+ self.connect()
+
+ headers['Authorization'] = 'Bearer {}'.format(self.getAccessToken())
+ if 'roleCode' not in headers.keys():
+ headers['roleCode'] = 'practitioner'
+ cert = (
+ self.getConfig().getSSLCertPath(),
+ self.getConfig().getSSLKeyPath(),
+ )
+ requestKwargs = {
+ 'headers': headers,
+ 'verify': False,
+ 'cert': cert,
+ }
+ if method == 'post' and data:
+ requestKwargs['data'] = data
+
+ if method in ['get', 'post'] and params:
+ requestKwargs['params'] = params
+
+ apiUrl = self.connectionConfiguration.getApiRequestURL()
+ requestUrl = '{}/{}'.format(apiUrl, url)
+
+ requestMethod = getattr(requests, method)
+ res = requestMethod(requestUrl, **requestKwargs)
+
+ # Attempt reconnect when response is 401 - Unauthorized and token is expired.
+ if res.status_code == 401 and self.getExpiration() <= datetime.datetime.now():
+ return self.reconnect(url, method, headers, params, data)
+
+ return res
+
+ def loadEvent(self, delete=False, longPoll=True):
+ """ Load the next event notification from the ADP API event notification
+ system. Notifications function as a first-in-first-out queue.
+
+ Keyword arguments:
+ delete -- Whether to delete the last notification after retrieval. If True,
+ each call to this method will retrieve a new event notification, since the
+ notifications are deleted upon retrieval. If False, a subsequent call to
+ ADPAPIConnection.deleteLastEvent() is needed to increment the queue and
+ return the next event.
+ longPoll -- Whether to use the HTTP long polling functionality, where the
+ request will hang for 15 seconds waiting for an event. If no event is
+ returned after this interval, the response is returned.
+
+ Returns:
+ A Requests object containing the http response.
+ """
+ endpoint = 'core/v1/event-notification-messages'
+ headers = {}
+ messageIdKey = 'adp-msg-msgid'
+ if longPoll:
+ headers['prefer'] = '/adp/long-polling'
+
+ result = self.get(endpoint, headers=headers)
+
+ if result.status_code == 200:
+ messageId = result.headers[messageIdKey]
+ logging.debug('Event message ID: {}'.format(messageId))
+ self.lastEventId = messageId
+ if delete:
+ self.deleteLastEvent(eventId=messageId)
+ return result
+
+ def deleteLastEvent(self, eventId=None):
+ """Delete the last event notification to provide the next one in the
+ queue. Use the supplied event ID, or check for a previously stored
+ event ID if one is not supplied."""
+ endpoint = 'core/v1/event-notification-messages/{}'
+ if eventId is None:
+ lastEventId = self.lastEventId
+ else:
+ lastEventId = eventId
+ if lastEventId is None:
+ raise ValueError("No event ID was provided.")
+
+ logging.debug('Deleting Event message ID: {}'.format(lastEventId))
+ deleteUrl = endpoint.format(lastEventId)
+ deleteResult = self.delete(deleteUrl)
+ if deleteResult.status_code == 200:
+ self.lastEventId = None
+ else:
+ logging.debug('Unable to delete event {}'.format(lastEventId))
+
+ def get(self, url, headers={}, params={}):
+ """ Convenience method for creating HTTP GET requests"""
+ return self.request(url, headers=headers, params=params)
+
+ def post(self, url, headers={}, params={}, data={}):
+ """ Convenience method for creating HTTP POST requests"""
+ return self.request(url, method='post', headers=headers, params=params, data=data)
+
+ def delete(self, url, headers={}):
+ """ Convenience method for creating HTTP DELETE requests"""
+ return self.request(url, method='delete', headers=headers)
class ClientCredentialsConnection(ADPAPIConnection):
diff --git a/adp_connection/lib/adpapiconnectionfactory.py b/adp_connection/lib/adpapiconnectionfactory.py
index 07cfec3..983b2af 100644
--- a/adp_connection/lib/adpapiconnectionfactory.py
+++ b/adp_connection/lib/adpapiconnectionfactory.py
@@ -18,7 +18,8 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
-from adpapiconnection import *
+from __future__ import absolute_import
+from .adpapiconnection import *
logging.basicConfig(level=logging.DEBUG)
diff --git a/adp_connection/lib/connectexceptions.py b/adp_connection/lib/connectexceptions.py
index 7473c0f..577366a 100644
--- a/adp_connection/lib/connectexceptions.py
+++ b/adp_connection/lib/connectexceptions.py
@@ -45,6 +45,7 @@ class Error(Exception):
errDict['responseTypeBad'] = {'errCode': 'ConfErr-013', 'errMsg': 'incorrect responseType'}
errDict['scopeBad'] = {'errCode': 'ConfErr-013', 'errMsg': 'incorrect scope'}
errDict['initBad'] = {'errCode': 'ConfErr-014', 'errMsg': 'configuration not inited'}
+ errDict['configFileNotFound'] = {'errCode': 'ConfErr-015', 'errMsg': 'Unable to load configuration file from {}'}
def __init__(self, cname, code, msg):
self.cname = cname
diff --git a/adp_connection/lib/connectionconfiguration.py b/adp_connection/lib/connectionconfiguration.py
index dd64299..74d7a42 100644
--- a/adp_connection/lib/connectionconfiguration.py
+++ b/adp_connection/lib/connectionconfiguration.py
@@ -18,8 +18,11 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
+from __future__ import absolute_import
import logging
-from connectexceptions import *
+import os
+import configparser
+from .connectexceptions import *
class ConnectionConfiguration(object):
@@ -33,6 +36,8 @@ class ConnectionConfiguration(object):
config = dict({})
initDone = False
+ CONF_SECTION_KEY = 'ADP Connection'
+ CONF_FILENAME = 'adp_connection.ini'
def __init__(self):
""" Initialize the dictionary keys:
@@ -102,15 +107,21 @@ def getDisconnectURL(self):
def getGrantType(self):
return self.config['grantType']
- def init(self, configObj):
+ def init(self, configObj=None):
""" Method to initialize the common config parameters:
clientID, clientSecret, sslCertPath, sslKeyPath,
tokenServerURL, apiRequestURL, disconnectURL and grantType.
+ Load and validate config from supplied dictionary. If no config is
+ present at runtime, check for a local config file.
+
Attributes:
configObj: dictionary containing the config values to be
initialized. """
+ if configObj is None:
+ configObj = self.attemptConfigFile()
+
logging.debug('Initializing Config Object')
if ('clientID' in configObj):
self.setClientID(configObj['clientID'])
@@ -165,6 +176,39 @@ def init(self, configObj):
logging.debug('Conf Error: ' + Error.errDict['grantTypeBad']['errCode'] + ': ' + Error.errDict['grantTypeBad']['errMsg'])
raise ConfigError(self.__class__.__name__, Error.errDict['grantTypeBad']['errCode'], Error.errDict['grantTypeBad']['errMsg'])
+ def attemptConfigFile(self):
+ """Attempt to locate and parse an INI configuration file to configure ADP
+ client connection. Check for an ADP_CONNECTION_CONFIG environment variable
+ containing the filesystem location for config file. If no environment variable
+ is present, check for a configuration file in the user's home directory."""
+ homeDirectory = os.path.expanduser('~')
+ defaultName = self.CONF_FILENAME
+ defaultLocation = os.path.join(homeDirectory, defaultName)
+ confFileLocation = os.getenv('ADP_CONNECTION_CONFIG')
+
+ if confFileLocation is None:
+ confFileLocation = defaultLocation
+
+ if not os.path.exists(confFileLocation):
+ raise ConfigError(
+ self.__class__.__name__,
+ Error.errDict['configFileNotFound']['errCode'],
+ Error.errDict['configFileNotFound']['errMsg'].format(confFileLocation),
+ )
+
+ logging.debug('Loading config file: {}..'.format(confFileLocation))
+ confParser = configparser.ConfigParser()
+ confParser.read(confFileLocation)
+
+ config = dict()
+ for field in self.config.keys():
+ fileField = confParser.get(self.CONF_SECTION_KEY, field, fallback=None)
+ if fileField:
+ config[field] = fileField
+ else:
+ config[field] = self.config[field]
+ logging.debug('Successfully loaded config file.')
+ return config
class ClientCredentialsConfiguration(ConnectionConfiguration):
""" Client credentials sub class of the ConnectionConfiguration object """
diff --git a/adp_connection/version.py b/adp_connection/version.py
index dc36246..65fee36 100644
--- a/adp_connection/version.py
+++ b/adp_connection/version.py
@@ -18,4 +18,4 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
-__version__ = '1.0.1' # NOQA
+__version__ = '1.0.1.post1' # NOQA
diff --git a/setup.py b/setup.py
index 3f64e45..9e712a0 100644
--- a/setup.py
+++ b/setup.py
@@ -18,6 +18,7 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
+from __future__ import absolute_import
from setuptools import setup, find_packages
from adp_connection import __version__
@@ -51,7 +52,10 @@
'License :: OSI Approved :: Apache Software License',
'Natural Language :: English',
'Operating System :: Unix',
+ 'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: Implementation :: PyPy',
'Operating System :: OS Independent',
],
diff --git a/tests/base.py b/tests/base.py
index 65d3a8d..c327fd4 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -18,6 +18,7 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
+from __future__ import absolute_import
from unittest import TestCase as PythonTestCase
diff --git a/tests/test_connections.py b/tests/test_connections.py
index 7e28bf0..70aad86 100644
--- a/tests/test_connections.py
+++ b/tests/test_connections.py
@@ -18,8 +18,9 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
+from __future__ import absolute_import
from preggy import expect
-from urlparse import urlparse, parse_qs
+from six.moves.urllib.parse import urlparse, parse_qs
from adp_connection.lib import *
from tests.base import TestCase
diff --git a/tests/test_version.py b/tests/test_version.py
index 6475844..04cc2b2 100644
--- a/tests/test_version.py
+++ b/tests/test_version.py
@@ -18,6 +18,7 @@
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
+from __future__ import absolute_import
from preggy import expect
from adp_connection import __version__