Source code for signupto.client

#!/usr/bin/env python
# -*- coding: utf-8 -*-


from collections import namedtuple
from datetime import datetime
from time import mktime
from wsgiref.handlers import format_date_time
import json
import random
import string

try:
    from sha import new as sha1
except ImportError:
    from hashlib import sha1
from six.moves.urllib import parse as urllib_parse

import requests

# By explicitly listing endpoints, we can get tab completion and help etc. when
# using Client interactively or in an IDE.
ENDPOINTS = [
    'token',

    'automation',
    'clickAutomation',
    'dateAutomation',
    'openAutomation',
    'smsAutomation',
    'subscriptionAutomation',
    'bounce',
    'contact',
    'doNotContact',
    'emailOpen',
    'emailLinkClick',
    'folder',
    'form',
    'import',
    'list',
    'message',
    'emailMessage',
    'emailMessageLink',
    'smsMessage',
    'sms',
    'smsDestination',
    'preview',
    'splitTest',
    'subscriber',
    'subscriberProfileData',
    'subscriberProfileField',
    'subscription',
    'task',
    'user',
]

SignuptoResponse = namedtuple('SignuptoResponse', 'data next count')


class ServerError(ValueError):
    """
    Indicates HTTP error code.
    """
    def __init__(self, message, status_code):
        super(ServerError, self).__init__(message)
        self.status_code = status_code


class ClientError(ValueError):
    """
    Indicates error made by programmer using this library.
    Used when the server returns a JSON document indicating the error.
    """
    def __init__(self, message, error_info, status_code):
        super(ClientError, self).__init__(message)
        self.error_info = error_info
        self.status_code = status_code


class ObjectNotFound(ClientError):
    pass


class NoAuthorization(object):
    def make_authorized_request(self, handler, method, url, data=None, params=None, headers=None):
        return handler(method, url, data=data, params=params, headers=headers)


def make_hash_authorization_signature(method, url, date_string, company_id, user_id, nonce, api_key):
    s = ("%(method)s %(path)s\r\n"
         "Date: %(date_string)s\r\n"
         "X-SuT-CID: %(company_id)s\r\n"
         "X-SuT-UID: %(user_id)s\r\n"
         "X-SuT-Nonce: %(nonce)s\r\n"
         "%(api_key)s"

         % dict(method=method,
                path=urllib_parse.urlparse(url).path.rstrip('/'),
                date_string=date_string,
                company_id=company_id,
                user_id=user_id,
                nonce=nonce,
                api_key=api_key)
         )
    return sha1(s.encode('utf-8')).hexdigest()


class HashAuthorization(object):
    def __init__(self, company_id=None, user_id=None, api_key=None):
        self.company_id = company_id
        self.user_id = user_id
        self.api_key = api_key

    def make_nonce(self):
        return ''.join(random.choice(string.ascii_lowercase + string.digits) for x in range(40))

    def make_authorized_request(self, handler, method, url, data=None, params=None, headers=None):
        if headers is None:
            headers = {}
        nonce = self.make_nonce()
        headers['X-SuT-Nonce'] = nonce
        headers['X-SuT-CID'] = str(self.company_id)
        headers['X-SuT-UID'] = str(self.user_id)
        date_string = format_date_time(mktime(datetime.now().timetuple()))
        headers['Date'] = date_string

        signature = make_hash_authorization_signature(method, url, date_string, self.company_id, self.user_id, nonce, self.api_key)
        headers['Authorization'] = 'SuTHash signature="%s"' % signature
        return handler(method, url, data=data, params=params, headers=headers)


class TokenAuthorization(object):

    def __init__(self, username=None, password=None):
        self.username = username
        self.password = password
        self.initialized = False

    def initialize(self, version=None):
        # We have to do an unauthenticated request to initialize
        temp_client = Client(version=version,
                             auth=None)
        r = temp_client.token.post(username=self.username, password=self.password)
        self.token = r.data['token']
        self.expiry = r.data['expiry']
        self.initialized = True

    def make_authorized_request(self, handler, method, url, data=None, params=None, headers=None):
        if headers is None:
            headers = {}
        headers['Authorization'] = "SuTToken %s" % self.token
        return handler(method, url, params=params, headers=headers)


class Client(object):
    """
    Main entry point.

    Client() should be instantiated with 'auth' keyword argument,
    which should be an instance of HashAuthorization or TokenAuthorization.

    sign-up.to endpoints are available as resources on the Client instance.
    Resources have 'get', 'put', 'post', 'head' and 'delete' methods, which
    take the documented sign-up.to parameters as keyword arguments. e.g.:

    >>> c = Client(auth=HashAuthorization(...))
    >>> c.list.get(id="mylist").data
    """
    extra_headers = {'Accept': 'application/json',
                     'Content-Type': 'application/json',
                     }


    def __init__(self, version="0", auth=None):
        if hasattr(auth, 'initialize') and not getattr(auth, 'initialized', False):
            auth.initialize(version=version)
        self._baseurl = 'https://api.sign-up.to/v%s/' % version
        if auth is None:
            auth = NoAuthorization()
        self._auth = auth

    def make_request_raw(self, method, url, data='', params=None, headers=None):
        return requests.request(method, url, data=data, params=params, headers=headers)

    def make_request(self, method, resource_name, data=None, params=None, headers=None):
        url = self._baseurl + resource_name
        if headers is None:
            headers = {}
        h2 = {}
        h2.update(self.extra_headers)
        h2.update(headers)
        response = self._auth.make_authorized_request(self.make_request_raw,
                                                      method,
                                                      url,
                                                      data=json.dumps(data),
                                                      params=params,
                                                      headers=h2)
        return self.handle_response(response)

    def handle_response(self, response):
        code = response.status_code
        if 500 <= code:
            raise ServerError(response.content, code)
        else:
            assert code < 300 or code >= 400 # Redirections should have been handled
            return self.deserialize(response)

    def deserialize(self, response):
        error_cls = None
        if 400 <= response.status_code < 500:
            if response.status_code == 404:
                error_cls = ObjectNotFound
            else:
                error_cls = ClientError

        if response.request.method == 'HEAD':
            # There is no body, can only return None or raise exception
            if error_cls is not None:
                return error_cls("URL: %s" % response.request.url,
                                 {}, response.status_code)
            else:
                return None

        content = response.content
        if type(content) == bytes: # Python 3 fix
            content = content.decode('utf-8')
        d = json.loads(content)
        assert "status" in d, "Server response (%s) did not contain 'status' key, aborting" % serialized_data

        status = d["status"].lower()
        if status != "ok":
            assert status == 'error'
            assert 'response' in d
            response_dict = d['response']
            raise error_cls("URL: %s %r" % (response.request.url, response_dict),
                            response_dict, response.status_code)
        r = d['response']
        return SignuptoResponse(r['data'], r['next'], r['count'])


[docs]class Endpoint(object): def __init__(self, client, resource_name): self.client = client self.resource_name = resource_name
[docs] def get(self, **kwargs): return self.client.make_request('GET', self.resource_name, params=kwargs)
[docs] def post(self, **kwargs): return self.client.make_request('POST', self.resource_name, data=kwargs)
[docs] def put(self, **kwargs): return self.client.make_request('PUT', self.resource_name, data=kwargs)
[docs] def delete(self, **kwargs): return self.client.make_request('DELETE', self.resource_name, params=kwargs)
[docs] def head(self, **kwargs): return self.client.make_request('HEAD', self.resource_name, params=kwargs) # Convenience method
[docs] def get_all(self, **kwargs): """ For requests that return lists in the 'data' attribute, and apply paging, this method will repeatedly follow the 'next' attribute to build up a full list, which is returned. 404's are converted to empty lists. """ retval = [] start = None kwargs = kwargs.copy() while True: if start is not None: kwargs['start'] = start try: response = self.get(**kwargs) except ObjectNotFound: # No more return retval retval.extend(response.data) if response.next is None: return retval else: start = response.next
[docs] def get_list(self, **kwargs): """ Like 'get', but returns just the list of items in data (assuming it is a list), and returns an empty list if a 404 is raised. """ try: return self.get(**kwargs).data except ObjectNotFound: return []
[docs] def delete_any(self, **kwargs): try: return self.delete(**kwargs).data except ObjectNotFound: return []
def __repr__(self): return "Endpoint(%r)" % self.resource_name
for resource_name in ENDPOINTS: # Add the endpoints to Client as properties def a_property(self, resource_name=resource_name): return Endpoint(self, resource_name) a_property.__name__ = resource_name setattr(Client, resource_name, property(a_property, doc="Access /%s endpoint" % resource_name))