From f195c96752096f22604b7428101ba7774adc5cfd Mon Sep 17 00:00:00 2001 From: Gabriel Rivera Date: Thu, 13 Jan 2022 19:04:50 -0800 Subject: [PATCH 1/6] Update for Python3 compatiblity --- adp_connection/__init__.py | 1 + adp_connection/democlient/sampleApp.py | 18 ++++++++++-------- adp_connection/docs/conf.py | 1 + adp_connection/lib/__init__.py | 9 +++++---- adp_connection/lib/adpapiconnection.py | 5 +++-- adp_connection/lib/adpapiconnectionfactory.py | 3 ++- adp_connection/lib/connectionconfiguration.py | 3 ++- setup.py | 4 ++++ tests/base.py | 1 + tests/test_connections.py | 3 ++- tests/test_version.py | 1 + 11 files changed, 32 insertions(+), 17 deletions(-) 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/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..64b1104 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__ 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/connectionconfiguration.py b/adp_connection/lib/connectionconfiguration.py index dd64299..bf0bc0c 100644 --- a/adp_connection/lib/connectionconfiguration.py +++ b/adp_connection/lib/connectionconfiguration.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 import logging -from connectexceptions import * +from .connectexceptions import * class ConnectionConfiguration(object): 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__ From a9cfddb9e9fce4936080f416e351ceafb71b20b9 Mon Sep 17 00:00:00 2001 From: Gabriel Rivera Date: Thu, 13 Jan 2022 19:10:39 -0800 Subject: [PATCH 2/6] Fix for session state in disconnect --- adp_connection/lib/adpapiconnection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adp_connection/lib/adpapiconnection.py b/adp_connection/lib/adpapiconnection.py index 64b1104..1814dc3 100644 --- a/adp_connection/lib/adpapiconnection.py +++ b/adp_connection/lib/adpapiconnection.py @@ -116,7 +116,7 @@ def disconnect(self): headers=(headers)) logging.debug(r.status_code) self.connection = {'status': 'ready', 'type': 'unknown', 'token': '', - 'expires': '', 'state': ''} + 'expires': '', 'sessionState': ''} class ClientCredentialsConnection(ADPAPIConnection): From e704c79710067e90ed1a21d48af0c1264cf8e0d4 Mon Sep 17 00:00:00 2001 From: Gabriel Rivera Date: Thu, 13 Jan 2022 20:41:41 -0800 Subject: [PATCH 3/6] Implement request and reconnect methods. --- adp_connection/lib/adpapiconnection.py | 69 +++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/adp_connection/lib/adpapiconnection.py b/adp_connection/lib/adpapiconnection.py index 1814dc3..410f171 100644 --- a/adp_connection/lib/adpapiconnection.py +++ b/adp_connection/lib/adpapiconnection.py @@ -36,7 +36,7 @@ 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__ @@ -118,6 +118,73 @@ def disconnect(self): self.connection = {'status': 'ready', 'type': 'unknown', 'token': '', '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 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): """ Extends the ADPAPIConnection base class for a Client Credentials type application From c2566c5c5674bcdc803d6961a452d075ca36fac6 Mon Sep 17 00:00:00 2001 From: Gabriel Rivera Date: Thu, 13 Jan 2022 21:09:21 -0800 Subject: [PATCH 4/6] Add INI config file support --- README.md | 13 ++++++ adp_connection/example_config.ini | 9 ++++ adp_connection/lib/connectexceptions.py | 1 + adp_connection/lib/connectionconfiguration.py | 45 ++++++++++++++++++- 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 adp_connection/example_config.ini 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/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/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 bf0bc0c..74d7a42 100644 --- a/adp_connection/lib/connectionconfiguration.py +++ b/adp_connection/lib/connectionconfiguration.py @@ -20,6 +20,8 @@ from __future__ import absolute_import import logging +import os +import configparser from .connectexceptions import * @@ -34,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: @@ -103,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']) @@ -166,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 """ From 54dad077ad39a345023a27ab28ee72758eb0787f Mon Sep 17 00:00:00 2001 From: Gabriel Rivera Date: Thu, 13 Jan 2022 21:12:54 -0800 Subject: [PATCH 5/6] Support event notifications --- adp_connection/lib/adpapiconnection.py | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/adp_connection/lib/adpapiconnection.py b/adp_connection/lib/adpapiconnection.py index 410f171..48cd289 100644 --- a/adp_connection/lib/adpapiconnection.py +++ b/adp_connection/lib/adpapiconnection.py @@ -40,6 +40,7 @@ class ADPAPIConnection(object): 'expires': '', 'sessionState': ''} connectionConfiguration = None userAgent = 'adp-userinfo-python/' + __version__ + lastEventId = None def isConnectedIndicator(self): """ Returns: a boolen depending on whether the connection @@ -173,6 +174,59 @@ def request(self, url, method='get', 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) From cd94c467bcfbeefb640d14582f33f4be01938f8a Mon Sep 17 00:00:00 2001 From: Mike Hendricks Date: Mon, 24 Feb 2025 18:14:39 -0800 Subject: [PATCH 6/6] Update version to 1.0.1.post1 --- adp_connection/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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