Skip to content

Instantly share code, notes, and snippets.

@ruairif
Created February 10, 2014 17:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ruairif/8920570 to your computer and use it in GitHub Desktop.
Save ruairif/8920570 to your computer and use it in GitHub Desktop.
Interface with OnePageCRM REST API
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from hashlib import sha256
from hashlib import sha1
from time import time
import hmac
from base64 import b64decode
from datetime import datetime
import re
from restclient import RESTClient, base_string
try:
import json
except ImportError:
import simplejson as json
class OnePageCRM(RESTClient):
'''\
This class can be used to authenticate your application and then make
requests to the API. You will need to use this in conjunction with the
docs at http://www.onepagecrm.com/api/api_3.0.html as it is more of
a wrapper around a REST api than specifically for dealing with
OnePageCRM.
To call any api functions you first need to create a client. A client
is just an instance of this class that that has a means of
authentication with OnePageCRM.
There are currently 2 different authentication methods available for
use through this class
- user_id and auth_key: These can be gotten in advance and then passed
through this class
- user_name and password: You initialise the class with your username
(email address) and password and thenit will
try to authenticate for you
Once the client has been authenticated you can start making calls to
the API. For example to get contacts for the authenticated user call
client.get_contacts()
This will return a OnePageCRM.ResponseDict object which is just like
a regulat dict but with a few additional functions for parsing input.
This object can be referenced by dot notation so if you get contacts
returned in a variable 'contacts' it is possible to view the list of
contacts returned through
contacts.data.contacts
This is just a list containing other OnePageCRM.ResponseDict objects
which contain contacts returned from OnePageCRM.
To perform a more advanced query you can use either
OnePageCRM.make_request or OnePageCRM.HTTP-METHOD_RESOURCE. While the
second function doesn't technically exist once called it forms a
wrapper around OnePageCRM.make_request function filling in HTTP-METHOD
and RESOURCE for the appropriate 'method' and resource parameters of
the function.
Initialise OnePageCRM client by authenticating with the server using
a combination of a user_id and auth_key or a user_name and password.
You can also specify which base_url you wish to use
:param user_id: Api user's user_id for matching Auth key
:type user_id: str
:param auth_key: Api user API key match user_id, as encoded base64
:type auth_key: str
:param base_url: default "https://app.onepagecrm.com/api/v3"
:type base_url: str
:param user_name: OnePageCRM username
:type user_name: str
:param password: Matching OnePageCRM password
:type password: str
:returns: OnePageCRM object\
'''
url = 'http://staging.onepagecrm.com/api/v3'
request_data_format = 'json'
mime_type = 'application/json'
append_request_data_format = True
@classmethod
def login(cls, user_name=None, password=None, base_url=None):
if user_name and password:
cls = OnePageCRM()
response = cls.make_request(
resource='login',
method='POST',
request_body={'login': user_name,
'password': password})
auth_key = response.data.auth_key
user_id = response.data.user_id
return type(cls)(user_id, auth_key, base_url)
else:
raise ValueError(
'Need User id and API key or username and password '
'to make requests')
def _construct_headers(self, method, url, request_body):
'''\
Compile necessary headers for communicating with OnePageCRM\
'''
timestamp = time()
signature = self.signature(timestamp, method, url, request_body)
return {'X-OnePageCRM-UID': self.user_id,
'X-OnePageCRM-TS': '%0.f' % (timestamp),
'X-OnePageCRM-Auth': str(signature),
'Content-Type': self.mime_type,
'accept': self.mime_type}
def _make_request_object(self, request_body):
'''\
Take dictionary object and convert it to JSON request object\
'''
try:
return json.dumps(self.OnePageDict(request_body))
except TypeError:
raise TypeError('Response Body needs to be a dictionary')
def _make_response_object(self, response_data):
'''\
Take JSON response from the server and change it into referencable
python data structures
'''
response = self.OnePageDict()
info = self.OnePageDict()
return_data = self.OnePageDict()
if 'data' in response_data:
data = response_data.pop('data')
for key, value in data.items():
setattr(return_data, key, value)
for key, value in response_data.items():
setattr(info, key, value)
response.data = return_data
response.info = info
return response
def signature(self, timestamp, method, request_url, request_body=None):
'''\
Generate OnePageCRM authentication signature from timestamp, http
method, request url and request body
>>> user_id = '4e0046526381906f7e000002'
>>> auth_key = 'AJfSRLr7uhsa9lOIgKQ4Vu72zzg3QTE7pJL2iSeA6Mo='
>>> client = OnePageCRM(user_id=user_id, auth_key=auth_key)
>>> timestamp = 1308640873
>>> method = 'PUT'
>>> url = 'https://app.onepagecrm.com/api/v3/contacts/4d91d3ea6381904e44000026.xml'
>>> request_body = 'partial=1&firstname=John&lastname=Doe'
>>> client.signature(timestamp, method, url, request_body)
'5ed5f846d55e6cc19f8ae3fd23b9e4991e649729896cbf2c9fd99bcc0ff0f74f'\
'''
if not self.auth_key:
return ''
decoded_api_key = b64decode(self.auth_key)
request_url_hash = sha1(request_url.encode('utf-8')).hexdigest()
signature_message = "%s.%0.f.%s.%s" % (self.user_id,
timestamp,
method.upper(),
request_url_hash)
if request_body:
request_body_hash = sha1(request_body.encode('utf-8')).hexdigest()
signature_message = '%s.%s' % (signature_message,
request_body_hash)
return hmac.new(decoded_api_key,
signature_message.encode('utf-8'),
sha256).hexdigest()
class OnePageDict(RESTClient.ResponseDict):
'''\
This is a dict that automatically converts oter dicts, lists and
strings that are added to it to a format that is available through
dot notation. It also provides methods for converting data to and
from JSON.
'''
def _custom_to_obj(self, value):
'''\
Convert ISO-8601 string to datetime objects
'''
if isinstance(value, base_string):
date_str = value.strip()
is_date = re.compile(r'^(\d{4})(\/|\-)(\d{2})(\/|\-)(\d{2})$')
if bool(is_date.match(date_str)):
date = max(date_str.split('-'),
date_str.split('/'), key=len)
return datetime(int(date[0]), int(date[1]), int(date[2]))
return None
class ObjectEncoder(json.JSONEncoder):
'''\
Provides the means to convert datetime objects to ISO-8601 format
during JSON encoding\
'''
def default(self, obj):
return self._to_json(obj)
def _to_json(self, obj):
'''\
Convert object to JSON string\
'''
if isinstance(obj, datetime):
return str(obj.date())
return json.JSONEncoder.default(self, obj)
if __name__ == "__main__":
import doctest
doctest.testmod()
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import requests
import sys
try:
import json
except ImportError:
import simplejson as json
PY2 = sys.version[0] == 2
PY3 = sys.version[0] == 3
if PY2:
base_string = basestring
else:
base_string = str
class RESTClient(object):
'''\
Client for interfacing with REST apis.
'''
# Map of acceptable api request synonmyms
http_map = {'get': 'GET',
'put': 'PUT',
'update': 'PUT',
'patch': 'PATCH',
'partial': 'PATCH',
'post': 'POST',
'create': 'POST',
'delete': 'DELETE'}
# You should override the variables below to suit the API you wish to
# create a client for.
# Map of expected endpoints leave as is if you want to catch and undefined
# functions and send them in to the server anyway in case a new endpoint
# has been added that this file isn't aware of
endpoint_map = None
# api base url
url = ''
# The format that the data is being sent and received from the server as
request_data_format = 'json'
mime_type = 'application/json'
# Add 'request_data_format' to the end of the url
append_request_data_format = False
def __init__(self,
user_id=None, auth_key='',
base_url=None):
'''\
Initialise Rest client with authentication for the server using a
combination of a user_id and auth_key.
You can also specify which base_url you wish to use
:param user_id: Api user's user_id for matching Auth key
:type user_id: str
:param auth_key: Api user API key match user_id, as encoded base64
:type auth_key: str
:param base_url: Base url of the API you would like to connect to
:type base_url: str
:type password: str
:returns: RestClient object\
'''
self.user_id = user_id
self.url = (base_url or self.url).strip()
if self.url.endswith('/'):
self.url = self.url[:-1]
self.auth_key = auth_key
self.request_data_format = self.request_data_format
self.mime_type = self.mime_type
def make_request(self,
resource,
method,
resource_id=None,
sub_resource=None,
sub_resource_id=None,
url_params=None,
request_body=None):
'''\
Make Requests to REST API through a generic interface.
To customise how this function to your needs subclass RESTClient
and override.
_construct_headers
_build_url
_make_response_object
_make_request_object
'''
url = self._build_url(resource, resource_id, sub_resource,
sub_resource_id, url_params)
method = method.upper()
if request_body:
formatted_request_body = self._make_request_body(request_body)
headers = self._construct_headers(method, url, request_body)
request_info = {"method": method, "url": url, "headers": headers}
if request_body:
request_info['data'] = formatted_request_body
response = requests.request(**request_info)
if not 200 <= response.status_code < 350:
raise ValueError("%s %s\n%s" % (method,
url,
response.text))
return self._make_response_object(response.json())
def _construct_headers(self, method, url, request_body):
'''\
Compile necessary headers for communicating with server\
'''
return {'Content-Type': self.mime_type,
'accept': self.mime_type}
def _build_url(self,
resource,
resource_id=None,
sub_resource=None,
sub_resource_id=None,
url_params=None):
'''\
Builds properly formatted request url
Correctly appends appropriate route, id, return format and url params
to base url
:param resource: Which resource endpoint to use
:type resource: str
:param resource_id: Identifier for resource
:type resource_id: str
:param url_params: Key value params to be added as url encoded
parameters after a '?' character
:type url_params: dict
:param sub_resource: Which sub resource endpoint to use
:type sub_resource: str
:param sub_resource_id: Identifier for sub resource
:type sub_resource_id: str\
'''
url = self.url
url += '/' + resource
if resource_id:
url += '/' + resource_id
if sub_resource:
url += '/' + sub_resource
if sub_resource_id:
url += '/' + sub_resource_id
if self.append_request_data_format:
url += '.' + self.request_data_format
if url_params:
url += '?' + self._to_url_params(url_params)
return url
def _to_url_params(self, params):
'''\
Tries to convert dictionary to url params eg. ?per_page=10&page=2
:param params: params that are to be attached to the url
:type params: dict\
'''
return '&'.join(['='.join((str(k), str(v)))
for k, v in params.items()])
def _make_request_body(self, request_body):
'''\
Take dictionary object and convert it to JSON request object\
'''
try:
return json.dumps(self.ResponseDict(request_body))
except TypeError:
raise TypeError('Response Body needs to be a dictionary')
def _make_response_object(self, response_data):
'''\
Take JSON response from the server and change it into a dictionary
'''
return json.loads(response_data)
def __getattr__(self, method, **kwargs):
try:
verb, resource = method.split('_', 1)
if verb.lower() in self.http_map:
method = self.http_map[verb.lower()]
resource = resource.lower()
return self._make_api_call(resource, method)
else:
raise AttributeError
except AttributeError as err:
raise AttributeError('No Method \'%s\' found \n%s' % (method, err))
def _make_api_call(self, resource, method):
'''\
Create a closure around the RESTClient.make_request function with the
resource and method that were passed to it. This is used to catch
non existent functions of the form HTTP-METHOD_RESOURCE and route
them to make_request\
'''
def caller(resource_id=None,
sub_resource=None,
sub_resource_id=None,
url_params=None,
request_body=None):
'''\
Wrap make request so it can be called through a string of the
form HTTP-METHOD_RESOURCE\
'''
return self.make_request(resource,
method,
resource_id,
sub_resource,
sub_resource_id,
url_params,
request_body)
return caller
class ResponseDict(dict):
'''\
This is a dict that automatically converts oter dicts, lists and
strings that are added to it to a format that is available through
dot notation. It also provides methods for converting data to and
from JSON.
'''
def __setattr__(self, name, value):
self[name] = self._to_obj(value)
def __getattr__(self, name):
if name not in self:
self[name] = None
return None
return self[name]
def __str__(self):
return json.dumps(self, indent=2, cls=self.ObjectEncoder)
def __repr__(self):
return json.dumps(self, cls=self.ObjectEncoder)
def _to_obj(self, value):
'''\
Convert variables added to this class to dot notation compatible
objects\
'''
parsed_obj = self._custom_to_obj(value)
if parsed_obj:
return parsed_obj
if isinstance(value, self.__class__):
return value
if isinstance(value, list) or isinstance(value, tuple):
return [self._to_obj(val) for val in value]
if isinstance(value, dict):
sub_obj = type(self)()
for key, value in value.items():
setattr(sub_obj, key, self._to_obj(value))
return sub_obj
return value
def _custom_to_obj(self, value):
'''\
Override this function to provide custom object parsing to the
ResponseDict class
'''
return None
class ObjectEncoder(json.JSONEncoder):
'''\
Provides the means to convert objecs to json in a customised way\
'''
def default(self, obj):
return self._to_json(obj)
def _to_json(self, obj):
'''\
Override this function to provide custom object parsing to the
ResponseDict class\
'''
return json.JSONEncoder.default(self, obj)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment