diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9436b95..d1dd680 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,9 +2,20 @@ version: 2 updates: - package-ecosystem: "gradle" directory: "/client-samples/java/rest" - schedule: - interval: "weekly" - day: "monday" - time: "08:00" commit-message: - prefix: "gradle" \ No newline at end of file + prefix: "gradle" + + - package-ecosystem: "pip" + directory: "/client-samples/python/rest" + commit-message: + prefix: "pip-rest" + + - package-ecosystem: "pip" + directory: "/client-samples/python/websockets" + commit-message: + prefix: "pip-websocket" + +schedule: + interval: "weekly" + day: "monday" + time: "08:00" diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..8289eae --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,28 @@ +name: Python Tests + +on: + pull_request: + branches: + - main +jobs: + test-python-rest: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + defaults: + run: + working-directory: ./client-samples/python/rest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run pytest + run: pytest tests/ diff --git a/client-samples/python/rest/.gitignore b/client-samples/python/rest/.gitignore new file mode 100644 index 0000000..7dbdfee --- /dev/null +++ b/client-samples/python/rest/.gitignore @@ -0,0 +1,171 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# PyPI configuration file +.pypirc \ No newline at end of file diff --git a/client-samples/python/rest/MsRequestsWrapper.py b/client-samples/python/rest/MsRequestsWrapper.py new file mode 100644 index 0000000..308aedb --- /dev/null +++ b/client-samples/python/rest/MsRequestsWrapper.py @@ -0,0 +1,154 @@ +from msal import ConfidentialClientApplication +import json +import logging +import requests +import time +from typing import List, Union + + +class MsRequestsWrapper: + def __init__(self, config_file: str): + """ + Constructor method for the MsRequestsWrapper class. This class is used to make requests to the Morgan Stanley API. + + Parameters + ---------- + config_file: str + The path to the config file to load. + """ + self.config = self.load_config(config_file) + self.url = self.config["url"] + self.proxies = self.get_proxies(self.config) + self.requests_ca_bundle = self.get_requests_ca_bundle(self.config) + self.app = self.get_client_app(self.config) + + def load_config(self, config_file: str): + """ + Load the config map from a JSON file with the given path. + + Parameters + ---------- + config_file: str + The path to the config file to load. + """ + with open(config_file, mode="r") as f: + return json.load(f) + + def load_private_key(self, private_key_file: str): + """ + Load the private key from a PEM file with the given path. + + Parameters + ---------- + private_key_file: str + The path to the private key to load. + """ + with open(private_key_file, mode="r") as f: + return f.read() + + def get_proxies(self, config: dict) -> Union[dict, None]: + """ + Returns proxy config from the config dictionary if the correct config has been provided. + Otherwise returns None. + + Parameters + ---------- + config: dict + The config map to use. + """ + proxy_host = config.get("proxy_host") + proxy_port = config.get("proxy_port") + proxies = None + if proxy_host is not None: + if proxy_port is None: + raise Exception("Missing proxy port.") + proxies = { + "http": f"{proxy_host}:{proxy_port}", + "https": f"{proxy_host}:{proxy_port}", + } + return proxies + + def get_requests_ca_bundle(self, config: dict) -> Union[str, bool]: + """ + Get the system CA bundle, if it's set. This is only necessary if your environment uses a proxy, since the bundled certificates will not work. + This returns True if no CA bundle is set; this tells requests to use the default, bundled certificates. + + Parameters + ---------- + config: dict + The config map to use. + + Returns + ------- + If SSL has been explicitly disabled: False + If SSL is enabled and should use the default settings: False + If a custom SSL bundle will be used: a string with an absolute path to a .pem file on the system. The config map to use. + """ + + if config.get("disable_ssl_verification"): + return False + return config.get("requests_ca_bundle") or True + + def get_client_app(self, config: dict): + """ + Configures an MSAL client application, that can later be used to request an access token. + + Parameters + ---------- + config: dict + The config map to use. + """ + client_id = config["client_id"] + thumbprint = config["thumbprint"] + private_key_path = config["private_key_file"] + authority = f"https://login.microsoftonline.com/{config['tenant']}" + proxies = self.get_proxies(config) + + private_key = self.load_private_key(private_key_path) + + requests_ca_bundle = self.get_requests_ca_bundle(config) + + return ConfidentialClientApplication( + client_id=client_id, + authority=authority, + client_credential={"thumbprint": thumbprint, "private_key": private_key}, + proxies=proxies, + verify=requests_ca_bundle, + ) + + def acquire_token(self, app: ConfidentialClientApplication, scopes: List[str]): + """ + Gets an access token against the provided scopes using a pre-configured MSAL app. + + Parameters + ---------- + app: ConfidentialClientApplication + The preconfigured MSAL ConfidentialClientApplication to request a token with. + scopes: List[str] + The list of scopes to request a token against. + """ + + result = app.acquire_token_silent(scopes, account=None) + + if not result: + print( + "No suitable token exists in cache. Retrieving a new token from Azure AD." + ) + result = app.acquire_token_for_client(scopes=scopes) + + if "access_token" not in result: + print("Expected an access token in response. Instead, got the following:") + print(result) + raise Exception("Bad response from Azure AD") + + return result["access_token"] + + def call_api(self): + access_token = self.acquire_token(self.app, self.config["scopes"]) + + return requests.get( # Use token to call downstream service + self.url, + headers={"Authorization": "Bearer " + access_token}, + proxies=self.proxies, + verify=self.requests_ca_bundle, + ) diff --git a/client-samples/python/rest/README.md b/client-samples/python/rest/README.md index 72f0253..13fadee 100644 --- a/client-samples/python/rest/README.md +++ b/client-samples/python/rest/README.md @@ -45,8 +45,8 @@ Once Python is installed you can run following to launch the application. Please Windows: ```cmd -python -m venv virtualenv -.\virtualenv\Scripts\activate +python -m venv .virtualenv +.\.virtualenv\Scripts\activate python -m pip install --upgrade pip python -m pip install -r requirements.txt @@ -56,8 +56,8 @@ python client-application.py Mac/Linux: ```bash -python -m venv virtualenv -. virtualenv/bin/activate +python -m venv .virtualenv +. .virtualenv/bin/activate python -m pip install --upgrade pip pip install -r requirements.txt @@ -66,6 +66,13 @@ python client-application.py The application will launch and connect to the Morgan Stanley API offering and output the result. +## Testing +This project uses `pytest` for unit testing. +Run the following command to run unit tests: +```bash +pytest tests +``` + ## Linting This project uses `black` to lint its source code for readability. To lint the code from inside the virtual env please run the following: diff --git a/client-samples/python/rest/client-application.py b/client-samples/python/rest/client-application.py index 50d3ed2..0c5f701 100755 --- a/client-samples/python/rest/client-application.py +++ b/client-samples/python/rest/client-application.py @@ -1,165 +1,25 @@ -# Morgan Stanley makes this available to you under the Apache License, Version 2.0 (the "License"). You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +# Morgan Stanley makes this available to you under the Apache License, Version 2.0 (the "License"). You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. # See the NOTICE file distributed with this work for additional information regarding copyright ownership. -# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and limitations under the License. -from msal import ConfidentialClientApplication -import sys import json import logging -import requests -import time -from typing import List + +from MsRequestsWrapper import MsRequestsWrapper # uncomment this line for DEBUG level logging in case of errors # logging.basicConfig(level=logging.DEBUG) -def load_config(config_file: str): - """ - Load the config map from a JSON file with the given path. - - Parameters - ---------- - config_file: str - The path to the config file to load. - """ - with open(config_file, mode="r") as f: - return json.load(f) - - -def load_private_key(private_key_file: str): - """ - Load the private key from a PEM file with the given path. - - Parameters - ---------- - private_key_file: str - The path to the private key to load. - """ - with open(private_key_file, mode="r") as f: - return f.read() - - -def get_proxies(config: dict) -> dict | None: - """ - Returns proxy config from the config dictionary if the correct config has been provided. - Otherwise returns None. - - Parameters - ---------- - config: dict - The config map to use. - """ - proxy_host = config.get("proxy_host") - proxy_port = config.get("proxy_port") - proxies = None - if proxy_host is not None: - if proxy_port is None: - raise Exception("Missing proxy port.") - proxies = { - "http": f"{proxy_host}:{proxy_port}", - "https": f"{proxy_host}:{proxy_port}", - } - return proxies - - -def get_requests_ca_bundle(config: dict) -> str | bool: - """ - Get the system CA bundle, if it's set. This is only necessary if your environment uses a proxy, since the bundled certificates will not work. - This returns True if no CA bundle is set; this tells requests to use the default, bundled certificates. - - Parameters - ---------- - config: dict - The config map to use. - - Returns - ------- - If SSL has been explicitly disabled: False - If SSL is enabled and should use the default settings: False - If a custom SSL bundle will be used: a string with an absolute path to a .pem file on the system. The config map to use. - """ - - if config.get("disable_ssl_verification"): - return False - return config.get("requests_ca_bundle") or True - -def get_client_app(config: dict): - """ - Configures an MSAL client application, that can later be used to request an access token. +def main(): + ms_requests = MsRequestsWrapper("./config-example.json") - Parameters - ---------- - config: dict - The config map to use. - """ - client_id = config["client_id"] - thumbprint = config["thumbprint"] - private_key_path = config["private_key_file"] - authority = f"https://login.microsoftonline.com/{config['tenant']}" - proxies = get_proxies(config) + print("Calling API...") + response = ms_requests.call_api() - private_key = load_private_key(private_key_path) - - requests_ca_bundle = get_requests_ca_bundle(config) - - return ConfidentialClientApplication( - client_id=client_id, - authority=authority, - client_credential={"thumbprint": thumbprint, "private_key": private_key}, - proxies=proxies, - verify=requests_ca_bundle - ) - - -def acquire_token(app: ConfidentialClientApplication, scopes: List[str]): - """ - Gets an access token against the provided scopes using a pre-configured MSAL app. - - Parameters - ---------- - app: ConfidentialClientApplication - The preconfigured MSAL ConfidentialClientApplication to request a token with. - scopes: List[str] - The list of scopes to request a token against. - """ - - result = app.acquire_token_silent(scopes, account=None) - - if not result: - print( - "No suitable token exists in cache. Retrieving a new token from Azure AD." - ) - result = app.acquire_token_for_client(scopes=scopes) - - if "access_token" not in result: - print("Expected an access token in response. Instead, got the following:") - print(result) - raise Exception("Bad response from Azure AD") - - return result["access_token"] + print(f"API call result: {json.dumps(response.json(), indent=2)}") if __name__ == "__main__": - print("Starting Client application") - config = load_config("config.json") - - app = get_client_app(config) - access_token = acquire_token(app, config["scopes"]) - - proxies = get_proxies(config) - url = config["url"] - - requests_ca_bundle = get_requests_ca_bundle(config) - - print("Calling API.") - # Call API using the access token - response = requests.get( # Use token to call downstream service - url, - headers={"Authorization": "Bearer " + access_token}, - proxies=proxies, - verify=requests_ca_bundle - ).json() - - print("API call result: %s" % json.dumps(response, indent=2)) + main() diff --git a/client-samples/python/rest/requirements.txt b/client-samples/python/rest/requirements.txt index bf0b632..5a08f51 100644 Binary files a/client-samples/python/rest/requirements.txt and b/client-samples/python/rest/requirements.txt differ diff --git a/client-samples/python/rest/tests/test-config.json b/client-samples/python/rest/tests/test-config.json new file mode 100644 index 0000000..36bbfe1 --- /dev/null +++ b/client-samples/python/rest/tests/test-config.json @@ -0,0 +1,12 @@ +{ + "client_id": "CLIENT-ID", + "scopes": [ + "API-SCOPE" + ], + "thumbprint": "CERT-THUMBPRINT", + "private_key_file": "PRIVATE-KEY-PATH", + "tenant": "TENANT", + "proxy_host": "PROXY-HOST", + "proxy_port": "PROXY-PORT", + "url": "HTTP://URL/" +} \ No newline at end of file diff --git a/client-samples/python/rest/tests/test_ms_requests_wrapper_config.py b/client-samples/python/rest/tests/test_ms_requests_wrapper_config.py new file mode 100644 index 0000000..54a0655 --- /dev/null +++ b/client-samples/python/rest/tests/test_ms_requests_wrapper_config.py @@ -0,0 +1,76 @@ +import os +import sys +import unittest +from unittest.mock import patch + + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from MsRequestsWrapper import MsRequestsWrapper + +MOCK_CONFIG = { + "client_id": "CLIENT-ID", + "scopes": ["API-SCOPE"], + "thumbprint": "CERT-THUMBPRINT", + "private_key_file": "PRIVATE-KEY-PATH", + "tenant": "TENANT", + "proxy_host": "PROXY-HOST", + "proxy_port": "PROXY-PORT", + "url": "HTTP://URL/", +} + +TEST_PROXY_HOST = MOCK_CONFIG["proxy_host"] +TEST_PROXY_PORT = MOCK_CONFIG["proxy_port"] +TEST_PROXY_CONFIG = { + "http": f"{TEST_PROXY_HOST}:{TEST_PROXY_PORT}", + "https": f"{TEST_PROXY_HOST}:{TEST_PROXY_PORT}", +} + + +class TestConfigSetup(unittest.TestCase): + @patch.object(MsRequestsWrapper, "get_client_app") + def setUp(self, get_client_app_mock): + """ + setUp function runs before each unit test. + + Parameters + ---------- + load_private_key_mock: Mock + Mock object for the load_private_key method. + """ + get_client_app_mock.return_value = None + self.ms_request_wrapper = MsRequestsWrapper("./tests/test-config.json") + + def tearDown(self): + """ + tearDown function runs after each unit test. + """ + return super().tearDown() + + def test_config(self): + """ + Test that the correct config is loaded. + """ + config = self.ms_request_wrapper.load_config("./tests/test-config.json") + assert config == MOCK_CONFIG, "Config loaded correctly" + + def test_proxies(self): + """ + Test that the correct proxies are loaded. + """ + proxies = self.ms_request_wrapper.get_proxies(MOCK_CONFIG) + assert proxies == TEST_PROXY_CONFIG, "Proxies loaded correctly" + + def test_null_proxy_config(self): + """ + Test that no proxies are loaded when the proxy_host and proxy_port are not provided. + """ + config = MOCK_CONFIG.copy() + config.pop("proxy_host") + config.pop("proxy_port") + proxies = self.ms_request_wrapper.get_proxies(config) + assert proxies == None, "Proxies not loaded" + + +if __name__ == "__main__": + unittest.main() diff --git a/client-samples/python/rest/tests/test_ms_requests_wrapper_request.py b/client-samples/python/rest/tests/test_ms_requests_wrapper_request.py new file mode 100644 index 0000000..b037e48 --- /dev/null +++ b/client-samples/python/rest/tests/test_ms_requests_wrapper_request.py @@ -0,0 +1,71 @@ +import sys +import os +import unittest +from unittest.mock import patch, Mock +import requests_mock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from MsRequestsWrapper import MsRequestsWrapper + +URL = "HTTP://URL" +MOCK_TOKEN = "MOCK_TOKEN" +MOCK_BODY_RESPONSE = {"status": "success", "response": "Hello World!"} + + +class TestApiCall(unittest.TestCase): + @patch.object(MsRequestsWrapper, "get_client_app") + def setUp(self, get_client_app_mock): + """ + setUp function runs before each unit test. + + Parameters + ---------- + get_client_app_mock: Mock + Mock return for the get_client_app method. + """ + get_client_app_mock.return_value = None + self.ms_request_wrapper_mock = MsRequestsWrapper("./tests/test-config.json") + self.ms_request_wrapper_mock.acquire_token = Mock(return_value=MOCK_TOKEN) + + def tearDown(self): + """ + tearDown function runs after each unit test. + """ + return super().tearDown() + + @requests_mock.Mocker() + def test_call_api_success(self, mock_request): + """ + Mock calling API with a successful response. + + Parameters + ---------- + mock_request: requests_mock.Mocker + Mock return for the requests get function. + """ + mock_request.get(URL, json=MOCK_BODY_RESPONSE, status_code=200) + + response = self.ms_request_wrapper_mock.call_api() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), MOCK_BODY_RESPONSE) + + @requests_mock.Mocker() + def test_call_api_failure(self, mock_request): + """ + Mock calling API with a failed response. + + Parameters + ---------- + mock_request: requests_mock.Mocker + Mock return for the requests get function. + """ + mock_request.get(URL, json={}, status_code=401) + + response = self.ms_request_wrapper_mock.call_api() + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {}) + + +if __name__ == "__main__": + unittest.main()