Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,6 @@ dmypy.json

# Pyre type checker
.pyre/

# VSCode
.vscode/
6 changes: 4 additions & 2 deletions .semaphore/semaphore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ blocks:
- checkout
- sem-version python 2.7
- python -m pip install --upgrade pip
- pip install flake8 pytest
- pip install flake8
- pip install -r test_requirements.txt
- sem-version python 3.7
- python -m pip install --upgrade pip
- pip install flake8 pytest pytest-cov
- pip install flake8
- pip install -r test_requirements.txt
jobs:
- name: python
commands:
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,43 @@
# workos-python
API Client for WorkOS

Pyhon SDK to conveniently access the [WorkOS API](https://workos.com).

## Installation

To install from PyPi, run the following:
```
pip install workos
```

To install from source, clone the repo and run the following:
```
python setup.py install
```

## Getting Started

The package will need to be configured with your [api key](https://dashboard.workos.com/api-keys) at a minimum and [project id](https://dashboard.workos.com/sso/configuration) if you plan on utilizing SSO:
```python
import workos

workos.api_key = sk_abdsomecharactersm284
workos.project_id = project_b27needthisforssotemxo
```

For your convenience, a client is available as an entry point for accessing the WorkOS feature set:
```python
from workos import client

# URL to redirect a User to to initiate the WorkOS OAuth 2.0 workflow
client.sso.get_authorization_url(
'customer-domain.com',
'my-domain.com/auth/callback',
state={
'stuff': 'from_the_original_request',
'more_things': 'ill_get_it_all_back_when_oauth_is_complete',
}
)

# Get the WorkOSProfile for an authenticated User
client.get_profile(oauth_code)
```
25 changes: 16 additions & 9 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import os
from setuptools import setup, find_packages

base_dir = os.path.dirname(__file__)

about = {}
with open(os.path.join(base_dir, "workos", "__about__.py")) as f:
exec(f.read(), about)

setup(
name='workos',
version='0.0.0a1',
author='WorkOS',
author_email='team@workos.com',
url='https://github.com/workos-inc/workos-python',
description='WorkOS Python Client',
packages=find_packages(),
name=about['__package_name__'],
version=about['__version__'],
author=about['__author__'],
author_email=about['__author_email__'],
url=about['__package_url__'],
description=about['__description__'],
packages=find_packages(exclude=['tests*', ]),
zip_safe=False,
license='MIT',
install_requires=[],
license=about['__license__'],
install_requires=["requests>=2.22.0"],
classifiers=[
'Development Status :: 1 - Planning',
'Intended Audience :: Developers',
Expand Down
7 changes: 7 additions & 0 deletions test_requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Install package deps
-e .

# Test deps
pytest==4.6.9
pytest-cov==2.8.1
six==1.13.0
Empty file added tests/__init__.py
Empty file.
51 changes: 51 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import pytest
import requests

import workos

class MockResponse(object):
def __init__(self, response_dict, status_code, headers=None):
self.response_dict = response_dict
self.status_code = status_code
self.headers = {} if headers is None else headers

def json(self):
return self.response_dict

@pytest.fixture
def set_api_key(monkeypatch):
monkeypatch.setattr(workos, 'api_key', 'sk_abdsomecharactersm284')

@pytest.fixture
def set_project_id(monkeypatch):
monkeypatch.setattr(workos, 'project_id', 'project_b27needthisforssotemxo')

@pytest.fixture
def set_api_key_and_project_id(set_api_key, set_project_id):
pass

@pytest.fixture
def mock_request_method(monkeypatch):
def inner(method, response_dict, status_code, headers=None):
def mock(*args, **kwargs):
return MockResponse(response_dict, status_code, headers=headers)

monkeypatch.setattr(requests, method, mock)

return inner

@pytest.fixture
def capture_and_mock_requests(monkeypatch):
def inner():
captured_requests = []

def capture(*args, **kwargs):
captured_requests.append((args, kwargs))
return MockResponse({}, 200)

monkeypatch.setattr(requests, 'get', capture)
monkeypatch.setattr(requests, 'post', capture)

return captured_requests

return inner
38 changes: 38 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import pytest

from workos import client
from workos.exceptions import ConfigurationException

class TestClient(object):
@pytest.fixture(autouse=True)
def setup(self):
client._sso = None

def test_initialize_sso(self, set_api_key_and_project_id):
assert bool(client.sso)

def test_initialize_sso_missing_api_key(self, set_project_id):
with pytest.raises(ConfigurationException) as ex:
client.sso
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: don't have to, but can be more explicit if you want to case on the setting that's missing

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea


message = str(ex)

assert 'api_key' in message
assert 'project_id' not in message

def test_initialize_sso_missing_project_id(self, set_api_key):
with pytest.raises(ConfigurationException) as ex:
client.sso

message = str(ex)

assert 'project_id' in message
assert 'api_key' not in message

def test_initialize_sso_missing_api_key_and_project_id(self):
with pytest.raises(ConfigurationException) as ex:
client.sso

message = str(ex)

assert all(setting in message for setting in ('api_key', 'project_id',))
68 changes: 68 additions & 0 deletions tests/test_sso.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import json
from six.moves.urllib.parse import parse_qsl, urlparse

import pytest

import workos
from workos.sso import SSO
from workos.utils.request import RESPONSE_TYPE_CODE

class TestSSO(object):
@pytest.fixture(autouse=True)
def setup(self, set_api_key_and_project_id):
self.customer_domain = 'workos.com'
self.redirect_uri = 'https://localhost/auth/callback'
self.state = { 'things': 'with_stuff', }

self.sso = SSO()

@pytest.fixture
def mock_profile(self):
return {
'id': 'prof_01DWAS7ZQWM70PV93BFV1V78QV',
'email': 'demo@workos-okta.com',
'first_name': 'WorkOS',
'last_name': 'Demo',
'connection_type': 'OktaSAML',
'idp_id': '00u1klkowm8EGah2H357'
}

def test_authorization_url_has_expected_query_params(self):
authorization_url = self.sso.get_authorization_url(
self.customer_domain,
self.redirect_uri,
state=self.state
)

parsed_url = urlparse(authorization_url)

assert dict(parse_qsl(parsed_url.query)) == {
'domain': self.customer_domain,
'client_id': workos.project_id,
'redirect_uri': self.redirect_uri,
'response_type': RESPONSE_TYPE_CODE,
'state': json.dumps(self.state),
}


def test_get_profile_returns_expected_workosprofile_object(
self, mock_profile, mock_request_method
):
response_dict = {
'profile': {
'object': 'profile',
'id': mock_profile['id'],
'email': mock_profile['email'],
'first_name': mock_profile['first_name'],
'connection_type': mock_profile['connection_type'],
'last_name': mock_profile['last_name'],
'idp_id': mock_profile['idp_id'],
},
'access_token': '01DY34ACQTM3B1CSX1YSZ8Z00D',
}

mock_request_method('post', response_dict, 200)

profile = self.sso.get_profile(123)

assert profile.to_dict() == mock_profile
Empty file added tests/utils/__init__.py
Empty file.
87 changes: 87 additions & 0 deletions tests/utils/test_requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import pytest

from workos.exceptions import (
AuthenticationException, AuthorizationException, BadRequestException,
ServerException,
)
from workos.utils.request import RequestHelper, BASE_HEADERS

STATUS_CODE_TO_EXCEPTION_MAPPING = {
400: BadRequestException,
401: AuthenticationException,
403: AuthorizationException,
500: ServerException,
}

class TestRequestHelper(object):
def test_set_base_api_url(self):
pass

def test_request_raises_expected_exception_for_status_code(
self, mock_request_method
):
request_helper = RequestHelper()

for status_code, exception in STATUS_CODE_TO_EXCEPTION_MAPPING.items():
mock_request_method('get', {}, status_code)

with pytest.raises(exception):
request_helper.request('bad_place')

def test_request_exceptions_include_expected_request_data(
self, mock_request_method
):
request_helper = RequestHelper()

request_id = 'request-123'
response_message = 'stuff happened'

for status_code, exception in STATUS_CODE_TO_EXCEPTION_MAPPING.items():
mock_request_method(
'get',
{'message': response_message, },
status_code,
headers={'X-Request-ID': request_id}
)

try:
request_helper.request('bad_place')
except exception as ex:
assert ex.message == response_message
assert ex.request_id == request_id
except Exception as ex:
# This'll fail for sure here but... just using the nice error that'd come up
assert ex.__class__ == exception

def test_request_bad_body_raises_expected_exception_with_request_data(
self, mock_request_method
):
request_id = 'request-123'

mock_request_method(
'get',
'this_isnt_json',
200,
headers={'X-Request-ID': request_id}
)

try:
RequestHelper().request('bad_place')
except ServerException as ex:
assert ex.message == None
assert ex.request_id == request_id
except Exception as ex:
# This'll fail for sure here but... just using the nice error that'd come up
assert ex.__class__ == ServerException

def test_request_includes_base_headers(self, capture_and_mock_requests):
requests = capture_and_mock_requests()

RequestHelper().request('ok_place')

assert len(requests) == 1

base_headers = set(BASE_HEADERS.items())
headers = set(requests[0][1]['headers'].items())

assert base_headers.issubset(headers)
18 changes: 18 additions & 0 deletions workos/__about__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
__all__ = [
'__package_name__', '__package_url__', '__version__', '__author__',
'__author_email__', '__description__', '__license__',
]

__package_name__ = 'workos'

__package_url__ = 'https://github.com/workos-inc/workos-python'

__version__ = '0.0.1'

__author__ = 'WorkOS'

__author_email__ = 'team@workos.com'

__description__ = 'WorkOS Python Client'

__license__ = 'MIT'
6 changes: 6 additions & 0 deletions workos/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from workos.__about__ import __version__
from workos.client import client

api_key = None
project_id = None
base_api_url = 'https://api.workos.com/'
Loading