diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..73104f2 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[report] +omit = + */python?.?/* + */site-packages/nose/* diff --git a/.gitignore b/.gitignore index ed2ad30..74daafc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ +*.pyc +/dist/ +/*.egg-info *.sublime-workspace env .Python .env +.coverage +htmlcov diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6ee4ec6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: python + +python: + - 3.5 + +install: + - pip install -r requirements.txt + - pip install coveralls + +script: + coverage run setup.py test + +after_success: + coveralls diff --git a/README.md b/README.md index 9f20b5e..69079d6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,57 @@ # Mifiel Python Library -## Install it +[![Coverage Status][coveralls-image]][coveralls-url] +[![Build Status][travis-image]][travis-url] + +Pyton library for [Mifiel](https://www.mifiel.com) API. +Please read our [documentation](https://www.mifiel.com/api-docs/) for instructions on how to start using the API. + +## Installation ```bash pip install mifiel ``` +## Usage + +To start using the API you will need an APP_ID and a APP_SECRET which will be provided upon request (contact us at hola@mifiel.com). + +You will first need to create an account in [mifiel.com](https://www.mifiel.com) since the APP_ID and APP_SECRET will be linked to your account. + +### Document methods: + +For now, the only methods available are **find** and **create**. Contributions are greatly appreciated. + +- Find: + +```python +from mifiel import Document, Client +client = Client(app_id='APP_ID', secret_key='APP_SECRET') + +doc = Document.find(client, 'id') +document.original_hash +document.file +document.file_signed +# ... +``` + +- Create: + +```python +from mifiel import Document, Client +client = Client(app_id='APP_ID', secret_key='APP_SECRET') + +signatories = [ + { name: 'Signer 1', email: 'signer1@email.com', tax_id: 'AAA010101AAA' }, + { name: 'Signer 2', email: 'signer2@email.com', tax_id: 'AAA010102AAA' } +] +# Providde the SHA256 hash of the file you want to sign. +doc = Document.create(client, signatories, dhash='some-sha256-hash') +# Or just send the file and we'll take care of everything. +# We will store the file for you. +doc = Document.create(client, signatories, file='path/to/my/file.pdf') +``` + ## Development ### Install dependencies @@ -13,3 +59,21 @@ pip install mifiel ```bash pip install -r requirements.txt ``` + +## Test + +Just clone the repo, install dependencies as you would in development and run `nose2` or `python setup.py test` + +## Contributing + +1. Fork it ( https://github.com/Mifiel/python-api-client/fork ) +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + +[coveralls-image]: https://coveralls.io/repos/github/Mifiel/python-api-client/badge.svg?branch=master +[coveralls-url]: https://coveralls.io/github/Mifiel/python-api-client?branch=master + +[travis-image]: https://travis-ci.org/Mifiel/python-api-client.svg?branch=master +[travis-url]: https://travis-ci.org/Mifiel/python-api-client diff --git a/mifiel-python-api-client.sublime-project b/mifiel-python-api-client.sublime-project index 0f47d0d..b38d1f9 100644 --- a/mifiel-python-api-client.sublime-project +++ b/mifiel-python-api-client.sublime-project @@ -8,7 +8,12 @@ "*.sublime-workspace" ], "folder_exclude_patterns": [ - "env" + "env", + "build", + "dist", + "*.egg-info", + "__pycache__", + "htmlcov" ], "path": ".", "follow_symlinks": true, diff --git a/mifiel/__init__.py b/mifiel/__init__.py new file mode 100644 index 0000000..f592a6e --- /dev/null +++ b/mifiel/__init__.py @@ -0,0 +1,6 @@ +from .api_auth import ApiAuth + +from .client import Client +from .response import Response +from .base import Base +from .document import Document diff --git a/mifiel/api_auth.py b/mifiel/api_auth.py new file mode 100644 index 0000000..69db4ff --- /dev/null +++ b/mifiel/api_auth.py @@ -0,0 +1,55 @@ +""" +[ApiAuth](https://github.com/mgomes/api_auth) for python +Based on https://github.com/pd/httpie-api-auth by Kyle Hargraves +Usage: +import requests +requests.get(url, auth=ApiAuth(app_id, secret_key)) + +""" +import hmac, base64, hashlib, datetime +from requests.auth import AuthBase +from urllib.parse import urlparse + +class ApiAuth(AuthBase): + def __init__(self, access_id, secret_key): + self.access_id = access_id + self.secret_key = secret_key.encode('ascii') + + def __call__(self, request): + method = request.method.upper() + + content_type = request.headers.get('content-type') + if not content_type: + content_type = '' + + content_md5 = request.headers.get('content-md5') + if not content_md5: + m = hashlib.md5() + body = request.body + if not body: body = '' + m.update(body.encode('ascii')) + content_md5 = base64.b64encode(m.digest()).decode() + request.headers['content-md5'] = content_md5 + + httpdate = request.headers.get('date') + if not httpdate: + now = datetime.datetime.utcnow() + httpdate = now.strftime('%a, %d %b %Y %H:%M:%S GMT') + request.headers['Date'] = httpdate + + url = urlparse(request.url) + path = url.path + if url.query: + path = path + '?' + url.query + + canonical_string = '%s,%s,%s,%s,%s' % (method, content_type, content_md5, path, httpdate) + + digest = hmac.new( + self.secret_key, + canonical_string.encode('ascii'), + hashlib.sha1 + ).digest() + signature = base64.encodebytes(digest).rstrip().decode() + + request.headers['Authorization'] = 'APIAuth %s:%s' % (self.access_id, signature) + return request diff --git a/mifiel/base.py b/mifiel/base.py new file mode 100644 index 0000000..aedb834 --- /dev/null +++ b/mifiel/base.py @@ -0,0 +1,49 @@ +from mifiel import Response +import requests + +class Base(object): + def __init__(self, mifiel, path): + object.__setattr__(self, 'sandbox', False) + object.__setattr__(self, 'path', path) + object.__setattr__(self, 'mifiel', mifiel) + object.__setattr__(self, 'response', Response()) + # initialize id + self.id = None + + def save(self): + if self.id is None: return False + self.process_request('put', url=self.url(self.id), data=self.get_data()) + + def url(self, path=None): + p = self.path + if path: + p = '{}/{}'.format(p, path) + + return self.mifiel.url().format(path=p) + + def process_request(self, method, url=None, data=None): + if not url: + url = self.url() + + if method == 'post': + response = requests.post(url, auth=self.mifiel.auth, json=data) + elif method == 'put': + response = requests.put(url, auth=self.mifiel.auth, json=data) + elif method == 'get': + response = requests.get(url, auth=self.mifiel.auth, json=data) + elif method == 'delete': + response = requests.delete(url, auth=self.mifiel.auth, json=data) + + self.set_data(response) + + def set_data(self, response): + self.response.set_response(response) + + def get_data(self): + return self.response.get_response() + + def __setattr__(self, name, value): + self.response.set(name, value) + + def __getattr__(self, name): + return self.response.get(name) diff --git a/mifiel/certificate.py b/mifiel/certificate.py new file mode 100644 index 0000000..9042519 --- /dev/null +++ b/mifiel/certificate.py @@ -0,0 +1,6 @@ +from mifiel import Base +import requests + +class Document(Base): + def __init__(self, client): + Base.__init__(self, client, 'keys') diff --git a/mifiel/client.py b/mifiel/client.py new file mode 100644 index 0000000..24092d3 --- /dev/null +++ b/mifiel/client.py @@ -0,0 +1,17 @@ +from mifiel import ApiAuth + +class Client: + def __init__(self, app_id, secret_key): + self.sandbox = False + self.auth = ApiAuth(app_id, secret_key) + self.base_url = 'https://www.mifiel.com' + + def use_sandbox(self): + self.sandbox = True + self.base_url = 'https://sandbox.mifiel.com' + + def set_base_url(self, base_url): + self.base_url = base_url + + def url(self): + return self.base_url + '/api/v1/{path}' diff --git a/mifiel/document.py b/mifiel/document.py new file mode 100644 index 0000000..e2d0d02 --- /dev/null +++ b/mifiel/document.py @@ -0,0 +1,30 @@ +from mifiel import Base + +class Document(Base): + def __init__(self, client): + Base.__init__(self, client, 'documents') + + @staticmethod + def find(client, doc_id): + doc = Document(client) + doc.process_request('get', url=doc.url(doc_id)) + return doc + + @staticmethod + def create(client, signatories, file=None, dhash=None, callback_url=None): + if not file and not dhash: + raise ValueError('Either file or hash must be provided') + if file and dhash: + raise ValueError('Only one of file or hash must be provided') + + data = { 'signatories': signatories } + if callback_url: + data['callback_url'] = callback_url + if file: + data['file'] = open(file) + if dhash: + data['original_hash'] = dhash + + doc = Document(client) + doc.process_request('post', data=data) + return doc diff --git a/mifiel/response.py b/mifiel/response.py new file mode 100644 index 0000000..f5cbed9 --- /dev/null +++ b/mifiel/response.py @@ -0,0 +1,22 @@ +class Response(object): + def __init__(self): + object.__setattr__(self, 'datastore', {}) + + def set_response(self, response): + response.raise_for_status() + object.__setattr__(self, 'datastore', response.json()) + + def get_response(self): + return self.datastore + + def __setattr__(self, name, value): + self.set(name, value) + + def __getattr__(self, name): + return self.get(name) + + def set(self, name, value): + self.datastore[name] = value + + def get(self, name): + return self.datastore[name] diff --git a/nose2.cfg b/nose2.cfg new file mode 100644 index 0000000..94acf2d --- /dev/null +++ b/nose2.cfg @@ -0,0 +1,10 @@ +[unittest] +code-directories = mifiel +start-dir = test + +[coverage] +coverage = mifiel +always-on = True +coverage-report = + term-missing + html diff --git a/requirements.txt b/requirements.txt index 8b13789..f001a23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,7 @@ - +cookies==2.2.1 +cov-core==1.15.0 +coverage==4.1 +nose2==0.6.4 +requests==2.10.0 +responses==0.5.1 +six==1.10.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9c19ff6 --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +from setuptools import setup + +def readme(): + with open('README.md') as f: + return f.read() + +setup(name='mifiel', + version='0.0.1', + description='The funniest joke in the world', + long_description=readme(), + url='http://github.com/mifiel/python-api-client', + author='Genaro Madrid', + author_email='genmadrid@gmail.com', + license='MIT', + test_suite='nose2.collector.collector', + packages=['mifiel'], + install_requires=[ + 'requests' + ], + include_package_data=True, + zip_safe=False) diff --git a/test/api_auth/__init__.py b/test/api_auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/api_auth/test_api_auth.py b/test/api_auth/test_api_auth.py new file mode 100644 index 0000000..56ae6ad --- /dev/null +++ b/test/api_auth/test_api_auth.py @@ -0,0 +1,44 @@ +from mifiel import ApiAuth +from testlib import BaseTestCase + +import base64, hashlib +import responses +import requests + +class TestApiAuth(BaseTestCase): + def setUp(self): + self.access_id = 'access_id' + self.secret_key = 'secret_key' + self.api_auth = ApiAuth(self.access_id, self.secret_key) + + def make_request(self, path, query=None): + url = 'http://example.com/api/v1/{path}'.format(path=path) + if query: url += '?{query}'.format(query=query) + responses.add(**{ + 'method' : responses.GET, + 'url' : url, + 'body' : '{"ok": "all ok"}', + 'status' : 200, + 'content_type' : 'application/json', + 'match_querystring': True + }) + requests.get(url, auth=self.api_auth, data=query) + + def get_last_headers(self): + return responses.calls[0].request.headers + + @responses.activate + def test_request(self): + self.make_request('blah') + m = hashlib.md5() + m.update(''.encode('ascii')) + empty_md5 = base64.b64encode(m.digest()).decode() + headers = self.get_last_headers() + self.assertEqual(headers['content-md5'], empty_md5) + assert headers['Authorization'] is not None + + @responses.activate + def test_request_with_query(self): + self.make_request('blah', 'some=query') + headers = self.get_last_headers() + assert headers['Authorization'] is not None diff --git a/test/mifiellib/__init__.py b/test/mifiellib/__init__.py new file mode 100644 index 0000000..d67083e --- /dev/null +++ b/test/mifiellib/__init__.py @@ -0,0 +1 @@ +from .base_mifiel_case import BaseMifielCase diff --git a/test/mifiellib/base_mifiel_case.py b/test/mifiellib/base_mifiel_case.py new file mode 100644 index 0000000..efd2180 --- /dev/null +++ b/test/mifiellib/base_mifiel_case.py @@ -0,0 +1,24 @@ +from testlib import BaseTestCase + +from mifiel import Client + +import responses + +class BaseMifielCase(BaseTestCase): + """ + from http://blog.aaronboman.com/programming/testing/2016/02/11/how-to-write-tests-in-python-project-structure/ + All test cases should inherit from this class as any common + functionality that is added here will then be available to all + subclasses. This facilitates the ability to update in one spot + and allow all tests to get the update for easy maintenance. + """ + + def setUp(self): + app_id = '836dd40b613ffb1bb06585bdc57638ff0ff2dbc0' + secret_key = 'rkV4tj1sHxUM26OJpr2MK5U2re4luERo/SmXB1s6o/l9G/Ei4rFKrrArhEKMIufQgndZXaIywX05tPN2OvPt7w==' + self.client = Client(app_id, secret_key) + # Ensure no requests to mifiel servers + self.client.set_base_url('http://localhost:3000') + + def get_last_request(self): + return responses.calls[0].request diff --git a/test/mifiellib/test_client.py b/test/mifiellib/test_client.py new file mode 100644 index 0000000..9eed7b2 --- /dev/null +++ b/test/mifiellib/test_client.py @@ -0,0 +1,17 @@ +from mifiel import Client +from mifiellib import BaseMifielCase + +class TestClient(BaseMifielCase): + def setUp(self): + self.client = Client('app_id', 'secret') + + def test_url(self): + self.assertRegex(self.client.url(), 'www.mifiel') + + def test_sandbox(self): + self.client.use_sandbox() + self.assertRegex(self.client.url(), 'sandbox.mifiel') + + def test_base_url(self): + self.client.set_base_url('http://example.com') + self.assertRegex(self.client.url(), 'example.com') diff --git a/test/mifiellib/test_document.py b/test/mifiellib/test_document.py new file mode 100644 index 0000000..305d253 --- /dev/null +++ b/test/mifiellib/test_document.py @@ -0,0 +1,85 @@ +from mifiel import Document +from mifiellib import BaseMifielCase + +import json +import responses + +class TestDocument(BaseMifielCase): + def setUp(self): + super().setUp() + self.doc = Document(self.client) + + def mock_doc_response(self, merhod, url, doc_id): + responses.add(merhod, url, + body=json.dumps({ + 'id': doc_id, + 'callback_url': 'some' + }), + status=200, + content_type='application/json', + ) + + @responses.activate + def test_get(self): + doc_id = 'some-doc-id' + url = self.client.url().format(path='documents/'+doc_id) + self.mock_doc_response(responses.GET, url, doc_id) + + doc = Document.find(self.client, doc_id) + + req = self.get_last_request() + self.assertEqual(req.body, None) + self.assertEqual(req.method, 'GET') + self.assertEqual(req.url, url) + self.assertEqual(doc.id, doc_id) + self.assertEqual(doc.callback_url, 'some') + assert req.headers['Authorization'] is not None + + @responses.activate + def test_update(self): + doc_id = 'some-doc-id' + url = self.client.url().format(path='documents/'+doc_id) + self.mock_doc_response(responses.PUT, url, doc_id) + + doc = Document(self.client) + doc.id = doc_id + doc.callback_url = 'some-callback' + doc.random_param = 'random-param' + doc.other_param = 'other-param' + doc.save() + + req = self.get_last_request() + self.assertEqual(req.method, 'PUT') + self.assertEqual(req.url, url) + self.assertEqual(doc.id, doc_id) + self.assertEqual(doc.callback_url, 'some') + assert req.headers['Authorization'] is not None + + def test_update_without_id(self): + doc = Document(self.client) + doc.callback_url = 'some-callback' + self.assertFalse(doc.save()) + + @responses.activate + def test_create(self): + doc_id = 'some-doc-id' + url = self.client.url().format(path='documents') + self.mock_doc_response(responses.POST, url, doc_id) + + signatories = [{ 'email': 'some@email.com' }] + doc = Document.create(self.client, signatories, dhash='some-sha256-hash') + + req = self.get_last_request() + self.assertEqual(req.method, 'POST') + self.assertEqual(req.url, url) + self.assertEqual(doc.id, doc_id) + self.assertEqual(doc.callback_url, 'some') + assert req.headers['Authorization'] is not None + + def test_create_without_file_or_hash(self): + with self.assertRaises(ValueError): + Document.create(self.client, []) + + def test_create_with_file_and_hash(self): + with self.assertRaises(ValueError): + Document.create(self.client, [], dhash='dhash', file='file') diff --git a/test/testlib/__init__.py b/test/testlib/__init__.py new file mode 100644 index 0000000..29aa3d7 --- /dev/null +++ b/test/testlib/__init__.py @@ -0,0 +1 @@ +from .base_test_case import BaseTestCase diff --git a/test/testlib/base_test_case.py b/test/testlib/base_test_case.py new file mode 100644 index 0000000..6330af4 --- /dev/null +++ b/test/testlib/base_test_case.py @@ -0,0 +1,10 @@ +from unittest import TestCase + +class BaseTestCase(TestCase): + """ + from http://blog.aaronboman.com/programming/testing/2016/02/11/how-to-write-tests-in-python-project-structure/ + All test cases should inherit from this class as any common + functionality that is added here will then be available to all + subclasses. This facilitates the ability to update in one spot + and allow all tests to get the update for easy maintenance. + """