Created
February 10, 2014 17:38
-
-
Save ruairif/8920570 to your computer and use it in GitHub Desktop.
Interface with OnePageCRM REST API
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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