From 3bbca46ce28393815c51bd5d9a612120c8021565 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 1 Feb 2021 20:52:23 +0900 Subject: [PATCH 1/3] Fix #437 Add SCIM API Support --- .../samples/scim/search_groups.py | 21 ++ .../samples/scim/search_users.py | 21 ++ .../scim/test_scim_client_read.py | 81 +++++ .../scim/test_scim_client_write.py | 94 +++++ slack_sdk/scim/__init__.py | 6 + slack_sdk/scim/v1/__init__.py | 0 slack_sdk/scim/v1/client.py | 340 ++++++++++++++++++ slack_sdk/scim/v1/default_arg.py | 5 + slack_sdk/scim/v1/group.py | 73 ++++ slack_sdk/scim/v1/internal_utils.py | 162 +++++++++ slack_sdk/scim/v1/response.py | 264 ++++++++++++++ slack_sdk/scim/v1/types.py | 24 ++ slack_sdk/scim/v1/user.py | 226 ++++++++++++ tests/slack_sdk/scim/__init__.py | 0 tests/slack_sdk/scim/mock_web_api_server.py | 194 ++++++++++ tests/slack_sdk/scim/test_client.py | 76 ++++ tests/slack_sdk/scim/test_internals.py | 19 + .../socket_mode/mock_socket_mode_server.py | 1 - 18 files changed, 1606 insertions(+), 1 deletion(-) create mode 100644 integration_tests/samples/scim/search_groups.py create mode 100644 integration_tests/samples/scim/search_users.py create mode 100644 integration_tests/scim/test_scim_client_read.py create mode 100644 integration_tests/scim/test_scim_client_write.py create mode 100644 slack_sdk/scim/__init__.py create mode 100644 slack_sdk/scim/v1/__init__.py create mode 100644 slack_sdk/scim/v1/client.py create mode 100644 slack_sdk/scim/v1/default_arg.py create mode 100644 slack_sdk/scim/v1/group.py create mode 100644 slack_sdk/scim/v1/internal_utils.py create mode 100644 slack_sdk/scim/v1/response.py create mode 100644 slack_sdk/scim/v1/types.py create mode 100644 slack_sdk/scim/v1/user.py create mode 100644 tests/slack_sdk/scim/__init__.py create mode 100644 tests/slack_sdk/scim/mock_web_api_server.py create mode 100644 tests/slack_sdk/scim/test_client.py create mode 100644 tests/slack_sdk/scim/test_internals.py diff --git a/integration_tests/samples/scim/search_groups.py b/integration_tests/samples/scim/search_groups.py new file mode 100644 index 000000000..c86059b19 --- /dev/null +++ b/integration_tests/samples/scim/search_groups.py @@ -0,0 +1,21 @@ +# ------------------ +# Only for running this script here +import sys +from os.path import dirname + + +sys.path.insert(1, f"{dirname(__file__)}/../../..") +# ------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os +from slack_sdk.scim import SCIMClient + +client = SCIMClient(token=os.environ["SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN"]) + +response = client.search_groups(start_index=1, count=2) +print("-----------------------") +print(response.groups) diff --git a/integration_tests/samples/scim/search_users.py b/integration_tests/samples/scim/search_users.py new file mode 100644 index 000000000..27045a0c8 --- /dev/null +++ b/integration_tests/samples/scim/search_users.py @@ -0,0 +1,21 @@ +# ------------------ +# Only for running this script here +import sys +from os.path import dirname + + +sys.path.insert(1, f"{dirname(__file__)}/../../..") +# ------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os +from slack_sdk.scim import SCIMClient + +client = SCIMClient(token=os.environ["SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN"]) + +response = client.search_users(start_index=1, count=2) +print("-----------------------") +print(response.users) diff --git a/integration_tests/scim/test_scim_client_read.py b/integration_tests/scim/test_scim_client_read.py new file mode 100644 index 000000000..0ad82f365 --- /dev/null +++ b/integration_tests/scim/test_scim_client_read.py @@ -0,0 +1,81 @@ +import logging +import os +import time +import unittest + +from integration_tests.env_variable_names import ( + SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN, +) +from slack_sdk.scim import SCIMClient, SCIMResponse +from slack_sdk.scim.v1.response import Errors +from slack_sdk.scim.v1.user import User, UserName, UserEmail + + +class TestSCIMClient(unittest.TestCase): + def setUp(self): + self.logger = logging.getLogger(__name__) + self.bot_token = os.environ[SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN] + self.client: SCIMClient = SCIMClient(token=self.bot_token) + + def tearDown(self): + pass + + def test_api_call(self): + response: SCIMResponse = self.client.api_call( + http_verb="GET", path="Users", query_params={"startIndex": 1, "count": 1} + ) + self.assertIsNotNone(response) + + self.logger.info(response.snake_cased_body) + self.assertEqual(response.snake_cased_body["start_index"], 1) + self.assertIsNotNone(response.snake_cased_body["resources"][0]["id"]) + + def test_lookup_users(self): + search_result = self.client.search_users(start_index=1, count=1) + self.assertIsNotNone(search_result) + + self.logger.info(search_result.snake_cased_body) + self.assertEqual(search_result.snake_cased_body["start_index"], 1) + self.assertIsNotNone(search_result.snake_cased_body["resources"][0]["id"]) + self.assertEqual( + search_result.users[0].id, + search_result.snake_cased_body["resources"][0]["id"], + ) + + read_result = self.client.read_user(search_result.users[0].id) + self.assertIsNotNone(read_result) + self.logger.info(read_result.snake_cased_body) + self.assertEqual(read_result.user.id, search_result.users[0].id) + + def test_lookup_users_error(self): + # error + error_result = self.client.search_users(start_index=1, count=1, filter="foo") + self.assertEqual(error_result.errors.code, 400) + self.assertEqual( + error_result.errors.description, "no_filters (is_aggregate_call=1)" + ) + + def test_lookup_groups(self): + search_result = self.client.search_groups(start_index=1, count=1) + self.assertIsNotNone(search_result) + + self.logger.info(search_result.snake_cased_body) + self.assertEqual(search_result.snake_cased_body["start_index"], 1) + self.assertIsNotNone(search_result.snake_cased_body["resources"][0]["id"]) + self.assertEqual( + search_result.groups[0].id, + search_result.snake_cased_body["resources"][0]["id"], + ) + + read_result = self.client.read_group(search_result.groups[0].id) + self.assertIsNotNone(read_result) + self.logger.info(read_result.snake_cased_body) + self.assertEqual(read_result.group.id, search_result.groups[0].id) + + def test_lookup_groups_error(self): + # error + error_result = self.client.search_groups(start_index=1, count=1, filter="foo") + self.assertEqual(error_result.errors.code, 400) + self.assertEqual( + error_result.errors.description, "no_filters (is_aggregate_call=1)" + ) diff --git a/integration_tests/scim/test_scim_client_write.py b/integration_tests/scim/test_scim_client_write.py new file mode 100644 index 000000000..9445693f6 --- /dev/null +++ b/integration_tests/scim/test_scim_client_write.py @@ -0,0 +1,94 @@ +import logging +import os +import time +import unittest + +from integration_tests.env_variable_names import ( + SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN, +) +from slack_sdk.scim import SCIMClient +from slack_sdk.scim.v1.group import Group, GroupMember +from slack_sdk.scim.v1.user import User, UserName, UserEmail + + +class TestSCIMClient(unittest.TestCase): + def setUp(self): + self.logger = logging.getLogger(__name__) + self.bot_token = os.environ[SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN] + self.client: SCIMClient = SCIMClient(token=self.bot_token) + + def tearDown(self): + pass + + def test_user_crud(self): + now = str(time.time())[:10] + user = User( + user_name=f"user_{now}", + name=UserName(given_name="Kaz", family_name="Sera"), + emails=[UserEmail(value=f"seratch+{now}@example.com")], + schemas=[ + "urn:scim:schemas:core:1.0", + # "urn:scim:schemas:extension:enterprise:1.0", + # "urn:scim:schemas:extension:slack:guest:1.0" + ], + # additional_fields={ + # "urn:scim:schemas:extension:slack:guest:1.0": { + # "type": "multi", + # "expiration": "2022-11-30T23:59:59Z" + # } + # } + ) + creation = self.client.create_user(user) + self.assertEqual(creation.status_code, 201) + + patch_result = self.client.patch_user( + id=creation.user.id, + partial_user=User( + user_name=f"user_{now}_2", + name=UserName(given_name="Kazuhiro", family_name="Sera"), + ), + ) + self.assertEqual(patch_result.status_code, 200) + + updated_user = creation.user + updated_user.name = UserName(given_name="Foo", family_name="Bar") + update_result = self.client.update_user(user=updated_user) + self.assertEqual(update_result.status_code, 200) + + delete_result = self.client.delete_user(updated_user.id) + self.assertEqual(delete_result.status_code, 200) + + def test_group_crud(self): + now = str(time.time())[:10] + + user = User( + user_name=f"user_{now}", + name=UserName(given_name="Kaz", family_name="Sera"), + emails=[UserEmail(value=f"seratch+{now}@example.com")], + schemas=["urn:scim:schemas:core:1.0"], + ) + user_creation = self.client.create_user(user) + group = Group( + display_name=f"TestGroup_{now}", + members=[GroupMember(value=user_creation.user.id)], + ) + creation = self.client.create_group(group) + self.assertEqual(creation.status_code, 201) + + group = creation.group + + patch_result = self.client.patch_group( + id=group.id, + partial_group=Group( + display_name=f"Test Group{now}_2", + ), + ) + self.assertEqual(patch_result.status_code, 204) + + updated_group = group + updated_group.display_name = f"Test Group{now}_3" + update_result = self.client.update_group(updated_group) + self.assertEqual(update_result.status_code, 200) + + delete_result = self.client.delete_group(updated_group.id) + self.assertEqual(delete_result.status_code, 204) diff --git a/slack_sdk/scim/__init__.py b/slack_sdk/scim/__init__.py new file mode 100644 index 000000000..a59bdc37d --- /dev/null +++ b/slack_sdk/scim/__init__.py @@ -0,0 +1,6 @@ +from .v1.client import SCIMClient # noqa +from .v1.response import SCIMResponse # noqa +from .v1.response import SearchUsersResponse, ReadUserResponse # noqa +from .v1.response import SearchGroupsResponse, ReadGroupResponse # noqa +from .v1.user import User # noqa +from .v1.group import Group # noqa diff --git a/slack_sdk/scim/v1/__init__.py b/slack_sdk/scim/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/slack_sdk/scim/v1/client.py b/slack_sdk/scim/v1/client.py new file mode 100644 index 000000000..1eaf59d6c --- /dev/null +++ b/slack_sdk/scim/v1/client.py @@ -0,0 +1,340 @@ +import json +import logging +import urllib +from http.client import HTTPResponse +from ssl import SSLContext +from typing import Dict, Optional, Union, Any +from urllib.error import HTTPError +from urllib.parse import quote +from urllib.request import Request, urlopen, OpenerDirector, ProxyHandler, HTTPSHandler + +from slack_sdk.errors import SlackRequestError +from .internal_utils import ( + _build_query, + _build_request_headers, + _debug_log_response, + get_user_agent, + _to_dict_without_not_given, +) +from .response import ( + SCIMResponse, + SearchUsersResponse, + ReadUserResponse, + SearchGroupsResponse, + ReadGroupResponse, + UserCreateResponse, + UserPatchResponse, + UserUpdateResponse, + UserDeleteResponse, + GroupCreateResponse, + GroupPatchResponse, + GroupUpdateResponse, + GroupDeleteResponse, +) +from .user import User +from .group import Group + + +class SCIMClient: + BASE_URL = "https://api.slack.com/scim/v1/" + + token: str + timeout: int + ssl: Optional[SSLContext] + proxy: Optional[str] + base_url: str + default_headers: Dict[str, str] + logger: logging.Logger + + def __init__( + self, + token: str, + timeout: int = 30, + ssl: Optional[SSLContext] = None, + proxy: Optional[str] = None, + base_url: str = BASE_URL, + default_headers: Optional[Dict[str, str]] = None, + user_agent_prefix: Optional[str] = None, + user_agent_suffix: Optional[str] = None, + logger: Optional[logging.Logger] = None, + ): + """API client for Audit Logs API + See https://api.slack.com/admins/scim for more details + :param token: An admin user's token, which starts with xoxp- + :param timeout: request timeout (in seconds) + :param ssl: ssl.SSLContext to use for requests + :param proxy: proxy URL (e.g., localhost:9000, http://localhost:9000) + :param base_url: the base URL for API calls + :param default_headers: request headers to add to all requests + :param user_agent_prefix: prefix for User-Agent header value + :param user_agent_suffix: suffix for User-Agent header value + :param logger: custom logger + """ + self.token = token + self.timeout = timeout + self.ssl = ssl + self.proxy = proxy + self.base_url = base_url + self.default_headers = default_headers if default_headers else {} + self.default_headers["User-Agent"] = get_user_agent( + user_agent_prefix, user_agent_suffix + ) + self.logger = logger if logger is not None else logging.getLogger(__name__) + + # ------------------------- + # Users + # ------------------------- + + def search_users( + self, + *, + # Pagination required as of August 30, 2019. + count: int, + start_index: int, + filter: Optional[str] = None, + ) -> SearchUsersResponse: + return SearchUsersResponse( + self.api_call( + http_verb="GET", + path="Users", + query_params={ + "filter": filter, + "count": count, + "startIndex": start_index, + }, + ) + ) + + def read_user(self, id: str) -> ReadUserResponse: + return ReadUserResponse( + self.api_call(http_verb="GET", path=f"Users/{quote(id)}") + ) + + def create_user(self, user: Union[Dict[str, Any], User]) -> UserCreateResponse: + return UserCreateResponse( + self.api_call( + http_verb="POST", + path="Users", + body_params=user.to_dict() + if isinstance(user, User) + else _to_dict_without_not_given(user), + ) + ) + + def patch_user( + self, id: str, partial_user: Union[Dict[str, Any], User] + ) -> UserPatchResponse: + return UserPatchResponse( + self.api_call( + http_verb="PATCH", + path=f"Users/{quote(id)}", + body_params=partial_user.to_dict() + if isinstance(partial_user, User) + else _to_dict_without_not_given(partial_user), + ) + ) + + def update_user(self, user: Union[Dict[str, Any], User]) -> UserUpdateResponse: + return UserUpdateResponse( + self.api_call( + http_verb="PUT", + path=f"Users/{quote(user.id)}", + body_params=user.to_dict() + if isinstance(user, User) + else _to_dict_without_not_given(user), + ) + ) + + def delete_user(self, id: str) -> UserDeleteResponse: + return UserDeleteResponse( + self.api_call( + http_verb="DELETE", + path=f"Users/{quote(id)}", + ) + ) + + # ------------------------- + # Groups + # ------------------------- + + def search_groups( + self, + *, + # Pagination required as of August 30, 2019. + count: int, + start_index: int, + filter: Optional[str] = None, + ) -> SearchGroupsResponse: + return SearchGroupsResponse( + self.api_call( + http_verb="GET", + path="Groups", + query_params={ + "filter": filter, + "count": count, + "startIndex": start_index, + }, + ) + ) + + def read_group(self, id: str) -> ReadGroupResponse: + return ReadGroupResponse( + self.api_call(http_verb="GET", path=f"Groups/{quote(id)}") + ) + + def create_group(self, group: Union[Dict[str, Any], Group]) -> GroupCreateResponse: + return GroupCreateResponse( + self.api_call( + http_verb="POST", + path="Groups", + body_params=group.to_dict() + if isinstance(group, Group) + else _to_dict_without_not_given(group), + ) + ) + + def patch_group( + self, id: str, partial_group: Union[Dict[str, Any], Group] + ) -> GroupPatchResponse: + return GroupPatchResponse( + self.api_call( + http_verb="PATCH", + path=f"Groups/{quote(id)}", + body_params=partial_group.to_dict() + if isinstance(partial_group, Group) + else _to_dict_without_not_given(partial_group), + ) + ) + + def update_group(self, group: Union[Dict[str, Any], Group]) -> GroupUpdateResponse: + return GroupUpdateResponse( + self.api_call( + http_verb="PUT", + path=f"Groups/{quote(group.id)}", + body_params=group.to_dict() + if isinstance(group, Group) + else _to_dict_without_not_given(group), + ) + ) + + def delete_group(self, id: str) -> GroupDeleteResponse: + return GroupDeleteResponse( + self.api_call( + http_verb="DELETE", + path=f"Groups/{quote(id)}", + ) + ) + + # ------------------------- + + def api_call( + self, + *, + http_verb: str, + path: str, + query_params: Optional[Dict[str, Any]] = None, + body_params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> SCIMResponse: + """Performs a Slack API request and returns the result.""" + url = f"{self.base_url}{path}" + query = _build_query(query_params) + if len(query) > 0: + url += f"?{query}" + + return self._perform_http_request( + http_verb=http_verb, + url=url, + body_params=body_params, + headers=_build_request_headers( + token=self.token, + default_headers=self.default_headers, + additional_headers=headers, + ), + ) + + def _perform_http_request( + self, + *, + http_verb: str = "GET", + url: str, + body_params: Optional[Dict[str, Any]] = None, + headers: Dict[str, str], + ) -> SCIMResponse: + if body_params is not None: + if body_params.get("schemas") is None: + body_params["schemas"] = ["urn:scim:schemas:core:1.0"] + body_params = json.dumps(body_params) + headers["Content-Type"] = "application/json;charset=utf-8" + + if self.logger.level <= logging.DEBUG: + headers_for_logging = { + k: "(redacted)" if k.lower() == "authorization" else v + for k, v in headers.items() + } + self.logger.debug( + f"Sending a request - {http_verb} url: {url}, body: {body_params}, headers: {headers_for_logging}" + ) + try: + opener: Optional[OpenerDirector] = None + # for security (BAN-B310) + if url.lower().startswith("http"): + req = Request( + method=http_verb, + url=url, + data=body_params.encode("utf-8") + if body_params is not None + else None, + headers=headers, + ) + if self.proxy is not None: + if isinstance(self.proxy, str): + opener = urllib.request.build_opener( + ProxyHandler({"http": self.proxy, "https": self.proxy}), + HTTPSHandler(context=self.ssl), + ) + else: + raise SlackRequestError( + f"Invalid proxy detected: {self.proxy} must be a str value" + ) + else: + raise SlackRequestError(f"Invalid URL detected: {url}") + + # NOTE: BAN-B310 is already checked above + resp: Optional[HTTPResponse] = None + if opener: + resp = opener.open(req, timeout=self.timeout) # skipcq: BAN-B310 + else: + resp = urlopen( # skipcq: BAN-B310 + req, context=self.ssl, timeout=self.timeout + ) + charset: str = resp.headers.get_content_charset() or "utf-8" + response_body: str = resp.read().decode(charset) + resp = SCIMResponse( + url=url, + status_code=resp.status, + raw_body=response_body, + headers=resp.headers, + ) + _debug_log_response(self.logger, resp) + return resp + + except HTTPError as e: + # read the response body here + charset = e.headers.get_content_charset() or "utf-8" + body_params: str = e.read().decode(charset) + resp = SCIMResponse( + url=url, + status_code=e.code, + raw_body=body_params, + headers=e.headers, + ) + if e.code == 429: + # for backward-compatibility with WebClient (v.2.5.0 or older) + resp.headers["Retry-After"] = resp.headers["retry-after"] + _debug_log_response(self.logger, resp) + return resp + + except Exception as err: + self.logger.error(f"Failed to send a request to Slack API server: {err}") + raise err diff --git a/slack_sdk/scim/v1/default_arg.py b/slack_sdk/scim/v1/default_arg.py new file mode 100644 index 000000000..52dca49c4 --- /dev/null +++ b/slack_sdk/scim/v1/default_arg.py @@ -0,0 +1,5 @@ +class DefaultArg: + pass + + +NotGiven = DefaultArg() diff --git a/slack_sdk/scim/v1/group.py b/slack_sdk/scim/v1/group.py new file mode 100644 index 000000000..cd3001a77 --- /dev/null +++ b/slack_sdk/scim/v1/group.py @@ -0,0 +1,73 @@ +from typing import Optional, List, Union + +from .default_arg import DefaultArg, NotGiven +from .internal_utils import _to_dict_without_not_given, _is_iterable + + +class GroupMember: + display: Union[Optional[str], DefaultArg] + value: Union[Optional[str], DefaultArg] + + def __init__( + self, + *, + display: Union[Optional[str], DefaultArg] = NotGiven, + value: Union[Optional[str], DefaultArg] = NotGiven, + ) -> None: + self.display = display + self.value = value + + def to_dict(self): + return _to_dict_without_not_given(self) + + +class GroupMeta: + created: Union[Optional[str], DefaultArg] + location: Union[Optional[str], DefaultArg] + + def __init__( + self, + *, + created: Union[Optional[str], DefaultArg] = NotGiven, + location: Union[Optional[str], DefaultArg] = NotGiven, + ) -> None: + self.created = created + self.location = location + + def to_dict(self): + return _to_dict_without_not_given(self) + + +class Group: + display_name: Union[Optional[str], DefaultArg] + id: Union[Optional[str], DefaultArg] + members: Union[Optional[List[GroupMember]], DefaultArg] + meta: Union[Optional[GroupMeta], DefaultArg] + schemas: Union[Optional[List[str]], DefaultArg] + + def __init__( + self, + *, + display_name: Union[Optional[str], DefaultArg] = NotGiven, + id: Union[Optional[str], DefaultArg] = NotGiven, + members: Union[Optional[List[GroupMember]], DefaultArg] = NotGiven, + meta: Union[Optional[GroupMeta], DefaultArg] = NotGiven, + schemas: Union[Optional[List[str]], DefaultArg] = NotGiven, + ) -> None: + self.display_name = display_name + self.id = id + self.members = ( + [a if isinstance(a, GroupMember) else GroupMember(**a) for a in members] + if _is_iterable(members) + else members + ) + self.meta = ( + GroupMeta(**meta) if meta is not None and isinstance(meta, dict) else meta + ) + self.schemas = schemas + + def to_dict(self): + return _to_dict_without_not_given(self) + + def __repr__(self): + return f"" diff --git a/slack_sdk/scim/v1/internal_utils.py b/slack_sdk/scim/v1/internal_utils.py new file mode 100644 index 000000000..fdc3b764e --- /dev/null +++ b/slack_sdk/scim/v1/internal_utils.py @@ -0,0 +1,162 @@ +import copy +import logging +import re +import sys +from typing import Dict, Callable +from typing import Union, Optional, Any +from urllib.parse import quote + +from .default_arg import DefaultArg, NotGiven +from slack_sdk.web.internal_utils import get_user_agent + + +def _build_query(params: Optional[Dict[str, Any]]) -> str: + if params is not None and len(params) > 0: + return "&".join( + { + f"{quote(str(k))}={quote(str(v))}" + for k, v in params.items() + if v is not None + } + ) + return "" + + +def _build_body(original_body: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if original_body: + return {k: v for k, v in original_body.items() if v is not None} + return None + + +def _is_iterable(obj: Union[Optional[Any], DefaultArg]) -> bool: + return obj is not None and obj is not NotGiven + + +def _to_dict_without_not_given(obj: Any) -> dict: + dict_value = {} + given_dict = obj if isinstance(obj, dict) else vars(obj) + for key, value in given_dict.items(): + if key == "_additional_fields": + if value is not None: + converted = _to_dict_without_not_given(value) + dict_value.update(converted) + continue + + dict_key = _to_camel_case_key(key) + if value is NotGiven: + continue + if isinstance(value, list): + dict_value[dict_key] = [ + elem.to_dict() if hasattr(elem, "to_dict") else elem for elem in value + ] + else: + dict_value[dict_key] = ( + value.to_dict() if hasattr(value, "to_dict") else value + ) + return dict_value + + +def _create_copy(original: Any) -> Any: + if sys.version_info.major == 3 and sys.version_info.minor <= 6: + return copy.copy(original) + else: + return copy.deepcopy(original) + + +def _to_camel_case_key(key: str) -> str: + next_to_capital = False + result = "" + for c in key: + if c == "_": + next_to_capital = True + elif next_to_capital: + result += c.upper() + next_to_capital = False + else: + result += c + return result + + +def _to_snake_cased(original: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + return _convert_dict_keys( + original, + {}, + lambda s: re.sub( + "^_", + "", + "".join(["_" + c.lower() if c.isupper() else c for c in s]), + ), + ) + + +def _to_camel_cased(original: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + return _convert_dict_keys( + original, + {}, + _to_camel_case_key, + ) + + +def _convert_dict_keys( + original_dict: Optional[Dict[str, Any]], + result_dict: Dict[str, Any], + convert: Callable[[str], str], +) -> Optional[Dict[str, Any]]: + if original_dict is None: + return result_dict + + for original_key, original_value in original_dict.items(): + new_key = convert(original_key) + if isinstance(original_value, dict): + result_dict[new_key] = {} + new_value = _convert_dict_keys( + original_value, result_dict[new_key], convert + ) + result_dict[new_key] = new_value + elif isinstance(original_value, list): + result_dict[new_key] = [] + is_dict = len(original_value) > 0 and isinstance(original_value[0], dict) + for element in original_value: + if is_dict: + if isinstance(element, dict): + new_element = {} + for elem_key, elem_value in element.items(): + new_element[convert(elem_key)] = ( + _convert_dict_keys(elem_value, {}, convert) + if isinstance(elem_value, dict) + else _create_copy(elem_value) + ) + result_dict[new_key].append(new_element) + else: + result_dict[new_key].append(_create_copy(original_value)) + else: + result_dict[new_key] = _create_copy(original_value) + return result_dict + + +def _build_request_headers( + token: str, + default_headers: Dict[str, str], + additional_headers: Optional[Dict[str, str]], +) -> Dict[str, str]: + request_headers = { + "Content-Type": "application/json;charset=utf-8", + "Authorization": f"Bearer {token}", + } + if default_headers is None or "User-Agent" not in default_headers: + request_headers["User-Agent"] = get_user_agent() + if default_headers is not None: + request_headers.update(default_headers) + if additional_headers is not None: + request_headers.update(additional_headers) + return request_headers + + +def _debug_log_response(logger, resp: "SCIMResponse") -> None: + if logger.level <= logging.DEBUG: + logger.debug( + "Received the following response - " + f"status: {resp.status_code}, " + f"headers: {(dict(resp.headers))}, " + f"body: {resp.raw_body}" + ) diff --git a/slack_sdk/scim/v1/response.py b/slack_sdk/scim/v1/response.py new file mode 100644 index 000000000..0cc0384cf --- /dev/null +++ b/slack_sdk/scim/v1/response.py @@ -0,0 +1,264 @@ +import json +from typing import Dict, Any, List + +from slack_sdk.scim.v1.group import Group +from slack_sdk.scim.v1.internal_utils import _to_snake_cased +from slack_sdk.scim.v1.user import User + + +class Errors: + code: int + description: str + + def __init__(self, code: int, description: str) -> None: + self.code = code + self.description = description + + def to_dict(self) -> dict: + return {"code": self.code, "description": self.description} + + +class SCIMResponse: + url: str + status_code: int + headers: Dict[str, Any] + raw_body: str + body: Dict[str, Any] + snake_cased_body: Dict[str, Any] + + errors: Errors + + @property + def snake_cased_body(self) -> Dict[str, Any]: + if self._snake_cased_body is None: + self._snake_cased_body = _to_snake_cased(self.body) + return self._snake_cased_body + + @property + def errors(self) -> Errors: + return Errors(**self.snake_cased_body.get("errors")) + + def __init__( + self, + *, + url: str, + status_code: int, + raw_body: str, + headers: dict, + ): + self.url = url + self.status_code = status_code + self.headers = headers + self.raw_body = raw_body + self.body = ( + json.loads(raw_body) + if raw_body is not None and raw_body.startswith("{") + else None + ) + self._snake_cased_body = None + + def __repr__(self): + dict_value = {} + for key, value in vars(self).items(): + dict_value[key] = value.to_dict() if hasattr(value, "to_dict") else value + + if dict_value: # skipcq: PYL-R1705 + return f"" + else: + return self.__str__() + + +# --------------------------------- +# Users +# --------------------------------- + + +class SearchUsersResponse(SCIMResponse): + users: List[User] + + @property + def users(self) -> List[User]: + return [User(**r) for r in self.snake_cased_body.get("resources")] + + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +class ReadUserResponse(SCIMResponse): + user: User + + @property + def user(self) -> User: + return User(**self.snake_cased_body) + + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +class UserCreateResponse(SCIMResponse): + user: User + + @property + def user(self) -> User: + return User(**self.snake_cased_body) + + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +class UserPatchResponse(SCIMResponse): + user: User + + @property + def user(self) -> User: + return User(**self.snake_cased_body) + + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +class UserUpdateResponse(SCIMResponse): + user: User + + @property + def user(self) -> User: + return User(**self.snake_cased_body) + + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +class UserDeleteResponse(SCIMResponse): + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +# --------------------------------- +# Groups +# --------------------------------- + + +class SearchGroupsResponse(SCIMResponse): + groups: List[Group] + + @property + def groups(self) -> List[Group]: + return [Group(**r) for r in self.snake_cased_body.get("resources")] + + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +class ReadGroupResponse(SCIMResponse): + group: Group + + @property + def group(self) -> Group: + return Group(**self.snake_cased_body) + + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +class GroupCreateResponse(SCIMResponse): + group: Group + + @property + def group(self) -> Group: + return Group(**self.snake_cased_body) + + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +class GroupPatchResponse(SCIMResponse): + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +class GroupUpdateResponse(SCIMResponse): + group: Group + + @property + def group(self) -> Group: + return Group(**self.snake_cased_body) + + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None + + +class GroupDeleteResponse(SCIMResponse): + def __init__(self, underlying: SCIMResponse): + self.underlying = underlying + self.url = underlying.url + self.status_code = underlying.status_code + self.headers = underlying.headers + self.raw_body = underlying.raw_body + self.body = underlying.body + self._snake_cased_body = None diff --git a/slack_sdk/scim/v1/types.py b/slack_sdk/scim/v1/types.py new file mode 100644 index 000000000..b65a6cd2f --- /dev/null +++ b/slack_sdk/scim/v1/types.py @@ -0,0 +1,24 @@ +from typing import Optional, Union + +from .default_arg import DefaultArg, NotGiven +from .internal_utils import _to_dict_without_not_given + + +class TypeAndValue: + primary: Union[Optional[bool], DefaultArg] + type: Union[Optional[str], DefaultArg] + value: Union[Optional[str], DefaultArg] + + def __init__( + self, + *, + primary: Union[Optional[bool], DefaultArg] = NotGiven, + type: Union[Optional[str], DefaultArg] = NotGiven, + value: Union[Optional[str], DefaultArg] = NotGiven, + ) -> None: + self.primary = primary + self.type = type + self.value = value + + def to_dict(self) -> dict: + return _to_dict_without_not_given(self) diff --git a/slack_sdk/scim/v1/user.py b/slack_sdk/scim/v1/user.py new file mode 100644 index 000000000..cbf2e9255 --- /dev/null +++ b/slack_sdk/scim/v1/user.py @@ -0,0 +1,226 @@ +from typing import Optional, Any, List, Dict, Union + +from .default_arg import DefaultArg, NotGiven +from .internal_utils import _to_dict_without_not_given, _is_iterable +from .types import TypeAndValue + + +class UserAddress: + country: Union[Optional[str], DefaultArg] + locality: Union[Optional[str], DefaultArg] + postal_code: Union[Optional[str], DefaultArg] + primary: Union[Optional[bool], DefaultArg] + region: Union[Optional[str], DefaultArg] + street_address: Union[Optional[str], DefaultArg] + + def __init__( + self, + *, + country: Union[Optional[str], DefaultArg] = NotGiven, + locality: Union[Optional[str], DefaultArg] = NotGiven, + postal_code: Union[Optional[str], DefaultArg] = NotGiven, + primary: Union[Optional[bool], DefaultArg] = NotGiven, + region: Union[Optional[str], DefaultArg] = NotGiven, + street_address: Union[Optional[str], DefaultArg] = NotGiven, + ) -> None: + self.country = country + self.locality = locality + self.postal_code = postal_code + self.primary = primary + self.region = region + self.street_address = street_address + + def to_dict(self) -> dict: + return _to_dict_without_not_given(self) + + +class UserEmail(TypeAndValue): + pass + + +class UserPhoneNumber(TypeAndValue): + pass + + +class UserRole(TypeAndValue): + pass + + +class UserGroup: + display: Union[Optional[str], DefaultArg] + value: Union[Optional[str], DefaultArg] + + def __init__( + self, + *, + display: Union[Optional[str], DefaultArg] = NotGiven, + value: Union[Optional[str], DefaultArg] = NotGiven, + ) -> None: + self.display = display + self.value = value + + def to_dict(self) -> dict: + return _to_dict_without_not_given(self) + + +class UserMeta: + created: Union[Optional[str], DefaultArg] + location: Union[Optional[str], DefaultArg] + + def __init__( + self, + created: Union[Optional[str], DefaultArg] = NotGiven, + location: Union[Optional[str], DefaultArg] = NotGiven, + ) -> None: + self.created = created + self.location = location + + def to_dict(self) -> dict: + return _to_dict_without_not_given(self) + + +class UserName: + family_name: Union[Optional[str], DefaultArg] + given_name: Union[Optional[str], DefaultArg] + + def __init__( + self, + family_name: Union[Optional[str], DefaultArg] = NotGiven, + given_name: Union[Optional[str], DefaultArg] = NotGiven, + ) -> None: + self.family_name = family_name + self.given_name = given_name + + def to_dict(self) -> dict: + return _to_dict_without_not_given(self) + + +class UserPhoto: + type: Union[Optional[str], DefaultArg] + value: Union[Optional[str], DefaultArg] + + def __init__( + self, + type: Union[Optional[str], DefaultArg] = NotGiven, + value: Union[Optional[str], DefaultArg] = NotGiven, + ) -> None: + self.type = type + self.value = value + + def to_dict(self) -> dict: + return _to_dict_without_not_given(self) + + +class User: + active: Union[Optional[bool], DefaultArg] + addresses: Union[Optional[List[UserAddress]], DefaultArg] + display_name: Union[Optional[str], DefaultArg] + emails: Union[Optional[List[TypeAndValue]], DefaultArg] + external_id: Union[Optional[str], DefaultArg] + groups: Union[Optional[List[UserGroup]], DefaultArg] + id: Union[Optional[str], DefaultArg] + meta: Union[Optional[UserMeta], DefaultArg] + name: Union[Optional[UserName], DefaultArg] + nick_name: Union[Optional[str], DefaultArg] + phone_numbers: Union[Optional[List[TypeAndValue]], DefaultArg] + photos: Union[Optional[List[UserPhoto]], DefaultArg] + profile_url: Union[Optional[str], DefaultArg] + roles: Union[Optional[List[TypeAndValue]], DefaultArg] + schemas: Union[Optional[List[str]], DefaultArg] + timezone: Union[Optional[str], DefaultArg] + title: Union[Optional[str], DefaultArg] + user_name: Union[Optional[str], DefaultArg] + additional_fields: Union[Dict[str, Any], DefaultArg] + + def __init__( + self, + *, + active: Union[Optional[bool], DefaultArg] = NotGiven, + addresses: Union[ + Optional[List[Union[UserAddress, Dict[str, Any]]]], DefaultArg + ] = NotGiven, + display_name: Union[Optional[str], DefaultArg] = NotGiven, + emails: Union[ + Optional[List[Union[TypeAndValue, Dict[str, Any]]]], DefaultArg + ] = NotGiven, + external_id: Union[Optional[str], DefaultArg] = NotGiven, + groups: Union[ + Optional[List[Union[UserGroup, Dict[str, Any]]]], DefaultArg + ] = NotGiven, + id: Union[Optional[str], DefaultArg] = NotGiven, + meta: Union[Optional[Union[UserMeta, Dict[str, Any]]], DefaultArg] = NotGiven, + name: Union[Optional[Union[UserName, Dict[str, Any]]], DefaultArg] = NotGiven, + nick_name: Union[Optional[str], DefaultArg] = NotGiven, + phone_numbers: Union[ + Optional[List[Union[TypeAndValue, Dict[str, Any]]]], DefaultArg + ] = NotGiven, + photos: Union[ + Optional[List[Union[UserPhoto, Dict[str, Any]]]], DefaultArg + ] = NotGiven, + profile_url: Union[Optional[str], DefaultArg] = NotGiven, + roles: Union[ + Optional[List[Union[TypeAndValue, Dict[str, Any]]]], DefaultArg + ] = NotGiven, + schemas: Union[Optional[List[str]], DefaultArg] = NotGiven, + timezone: Union[Optional[str], DefaultArg] = NotGiven, + title: Union[Optional[str], DefaultArg] = NotGiven, + user_name: Union[Optional[str], DefaultArg] = NotGiven, + additional_fields: Union[Dict[str, Any], DefaultArg] = NotGiven, + ) -> None: + self.active = active + self.addresses = ( + [a if isinstance(a, UserAddress) else UserAddress(**a) for a in addresses] + if _is_iterable(addresses) + else addresses + ) + self.display_name = display_name + self.emails = ( + [a if isinstance(a, TypeAndValue) else TypeAndValue(**a) for a in emails] + if _is_iterable(emails) + else emails + ) + self.external_id = external_id + self.groups = ( + [a if isinstance(a, UserGroup) else UserGroup(**a) for a in groups] + if _is_iterable(groups) + else groups + ) + self.id = id + self.meta = ( + UserMeta(**meta) if meta is not None and isinstance(meta, dict) else meta + ) + self.name = ( + UserName(**name) if name is not None and isinstance(name, dict) else name + ) + self.nick_name = nick_name + self.phone_numbers = ( + [ + a if isinstance(a, TypeAndValue) else TypeAndValue(**a) + for a in phone_numbers + ] + if _is_iterable(phone_numbers) + else phone_numbers + ) + self.photos = ( + [a if isinstance(a, UserPhoto) else UserPhoto(**a) for a in photos] + if _is_iterable(photos) + else photos + ) + self.profile_url = profile_url + self.roles = ( + [a if isinstance(a, TypeAndValue) else TypeAndValue(**a) for a in roles] + if _is_iterable(roles) + else roles + ) + self.schemas = schemas + self.timezone = timezone + self.title = title + self.user_name = user_name + + self._additional_fields = additional_fields + + def to_dict(self): + return _to_dict_without_not_given(self) + + def __repr__(self): + return f"" diff --git a/tests/slack_sdk/scim/__init__.py b/tests/slack_sdk/scim/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_sdk/scim/mock_web_api_server.py b/tests/slack_sdk/scim/mock_web_api_server.py new file mode 100644 index 000000000..3843557da --- /dev/null +++ b/tests/slack_sdk/scim/mock_web_api_server.py @@ -0,0 +1,194 @@ +import json +import logging +import re +import sys +import threading +import time +from http import HTTPStatus +from http.server import HTTPServer, SimpleHTTPRequestHandler +from multiprocessing.context import Process +from typing import Type +from unittest import TestCase +from urllib.request import Request, urlopen + +from tests.helpers import get_mock_server_mode + + +class MockHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + default_request_version = "HTTP/1.1" + logger = logging.getLogger(__name__) + + pattern_for_language = re.compile("python/(\\S+)", re.IGNORECASE) + pattern_for_package_identifier = re.compile("slackclient/(\\S+)") + + def is_valid_user_agent(self): + user_agent = self.headers["User-Agent"] + return self.pattern_for_language.search( + user_agent + ) and self.pattern_for_package_identifier.search(user_agent) + + def is_valid_token(self): + if self.path.startswith("oauth"): + return True + return "Authorization" in self.headers and str( + self.headers["Authorization"] + ).startswith("Bearer xoxp-") + + def set_common_headers(self): + self.send_header("content-type", "application/json;charset=utf-8") + self.send_header("connection", "close") + self.end_headers() + + def _handle(self): + try: + if self.path == "/received_requests.json": + self.send_response(200) + self.set_common_headers() + self.wfile.write(json.dumps(self.received_requests).encode("utf-8")) + return + + if self.is_valid_token() and self.is_valid_user_agent(): + self.send_response(HTTPStatus.OK) + self.set_common_headers() + self.wfile.close() + else: + self.send_response(HTTPStatus.BAD_REQUEST) + self.set_common_headers() + self.wfile.close() + + except Exception as e: + self.logger.error(str(e), exc_info=True) + raise + + def do_GET(self): + self._handle() + + def do_POST(self): + self._handle() + + +class MockServerProcessTarget: + def __init__(self, handler: Type[SimpleHTTPRequestHandler] = MockHandler): + self.handler = handler + + def run(self): + self.handler.received_requests = {} + self.server = HTTPServer(("localhost", 8888), self.handler) + try: + self.server.serve_forever(0.05) + finally: + self.server.server_close() + + def stop(self): + self.handler.received_requests = {} + self.server.shutdown() + self.join() + + +class MonitorThread(threading.Thread): + def __init__( + self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler + ): + threading.Thread.__init__(self, daemon=True) + self.handler = handler + self.test = test + self.test.mock_received_requests = None + self.is_running = True + + def run(self) -> None: + while self.is_running: + try: + req = Request(f"{self.test.server_url}/received_requests.json") + resp = urlopen(req, timeout=1) + self.test.mock_received_requests = json.loads( + resp.read().decode("utf-8") + ) + except Exception as e: + # skip logging for the initial request + if self.test.mock_received_requests is not None: + logging.getLogger(__name__).exception(e) + time.sleep(0.01) + + def stop(self): + self.is_running = False + self.join() + + +class MockServerThread(threading.Thread): + def __init__( + self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler + ): + threading.Thread.__init__(self) + self.handler = handler + self.test = test + + def run(self): + self.server = HTTPServer(("localhost", 8888), self.handler) + self.test.server_url = "http://localhost:8888" + self.test.host, self.test.port = self.server.socket.getsockname() + self.test.server_started.set() # threading.Event() + + self.test = None + try: + self.server.serve_forever() + finally: + self.server.server_close() + + def stop(self): + self.server.shutdown() + self.join() + + +def setup_mock_web_api_server(test: TestCase): + if get_mock_server_mode() == "threading": + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() + else: + # start a mock server as another process + target = MockServerProcessTarget() + test.server_url = "http://localhost:8888" + test.host, test.port = "localhost", 8888 + test.process = Process(target=target.run, daemon=True) + test.process.start() + + time.sleep(0.1) + + # start a thread in the current process + # this thread fetches mock_received_requests from the remote process + test.monitor_thread = MonitorThread(test) + test.monitor_thread.start() + count = 0 + # wait until the first successful data retrieval + while test.mock_received_requests is None: + time.sleep(0.01) + count += 1 + if count >= 100: + raise Exception("The mock server is not yet running!") + + +def cleanup_mock_web_api_server(test: TestCase): + if get_mock_server_mode() == "threading": + test.thread.stop() + test.thread = None + else: + # stop the thread to fetch mock_received_requests from the remote process + test.monitor_thread.stop() + + retry_count = 0 + # terminate the process + while test.process.is_alive(): + test.process.terminate() + time.sleep(0.01) + retry_count += 1 + if retry_count >= 100: + raise Exception("Failed to stop the mock server!") + + # Python 3.6 does not have this method + if sys.version_info.major == 3 and sys.version_info.minor > 6: + # cleanup the process's resources + test.process.close() + + test.process = None diff --git a/tests/slack_sdk/scim/test_client.py b/tests/slack_sdk/scim/test_client.py new file mode 100644 index 000000000..bf99d4b2a --- /dev/null +++ b/tests/slack_sdk/scim/test_client.py @@ -0,0 +1,76 @@ +import time +import unittest + +from slack_sdk.scim import SCIMClient, User, Group +from slack_sdk.scim.v1.group import GroupMember +from slack_sdk.scim.v1.user import UserName, UserEmail +from tests.slack_sdk.scim.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestSCIMClient(unittest.TestCase): + def setUp(self): + setup_mock_web_api_server(self) + + def tearDown(self): + cleanup_mock_web_api_server(self) + + def test_users(self): + client = SCIMClient(base_url="http://localhost:8888/", token="xoxp-valid") + client.search_users(start_index=0, count=1) + client.read_user("U111") + + now = str(time.time())[:10] + user = User( + user_name=f"user_{now}", + name=UserName(given_name="Kaz", family_name="Sera"), + emails=[UserEmail(value=f"seratch+{now}@example.com")], + schemas=["urn:scim:schemas:core:1.0"], + ) + client.create_user(user) + # The mock server does not work for PATH requests + try: + client.patch_user("U111", partial_user=User(user_name="foo")) + except: + pass + user.id = "U111" + user.user_name = "updated" + try: + client.update_user(user) + except: + pass + try: + client.delete_user("U111") + except: + pass + + def test_groups(self): + client = SCIMClient(base_url="http://localhost:8888/", token="xoxp-valid") + client.search_groups(start_index=0, count=1) + client.read_group("S111") + + now = str(time.time())[:10] + group = Group( + display_name=f"TestGroup_{now}", + members=[GroupMember(value="U111")], + ) + client.create_group(group) + # The mock server does not work for PATH requests + try: + client.patch_group( + "S111", partial_group=Group(display_name=f"TestGroup_{now}_2") + ) + except: + pass + group.id = "S111" + group.display_name = "updated" + try: + client.update_group(group) + except: + pass + try: + client.delete_group("S111") + except: + pass diff --git a/tests/slack_sdk/scim/test_internals.py b/tests/slack_sdk/scim/test_internals.py new file mode 100644 index 000000000..dc66a1a0a --- /dev/null +++ b/tests/slack_sdk/scim/test_internals.py @@ -0,0 +1,19 @@ +import json +import unittest + +from slack_sdk.scim.v1.internal_utils import _to_snake_cased + + +class TEstInternals(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_snake_cased(self): + response_body = """{"totalResults":441,"itemsPerPage":1,"startIndex":1,"schemas":["urn:scim:schemas:core:1.0"],"Resources":[{"schemas":["urn:scim:schemas:core:1.0"],"id":"W111","externalId":"","meta":{"created":"2020-08-13T04:15:35-07:00","location":"https://api.slack.com/scim/v1/Users/W111"},"userName":"test-app","nickName":"test-app","name":{"givenName":"","familyName":""},"displayName":"","profileUrl":"https://test-test-test.enterprise.slack.com/team/test-app","title":"","timezone":"America/Los_Angeles","active":true,"emails":[{"value":"botuser@slack-bots.com","primary":true}],"photos":[{"value":"https://secure.gravatar.com/avatar/xxx.jpg","type":"photo"}],"groups":[]}]}""" + result = _to_snake_cased(json.loads(response_body)) + print(result) + self.assertEqual(result["start_index"], 1) + self.assertIsNotNone(result["resources"][0]["id"]) diff --git a/tests/slack_sdk/socket_mode/mock_socket_mode_server.py b/tests/slack_sdk/socket_mode/mock_socket_mode_server.py index 2f054f65c..2a6db91b8 100644 --- a/tests/slack_sdk/socket_mode/mock_socket_mode_server.py +++ b/tests/slack_sdk/socket_mode/mock_socket_mode_server.py @@ -1,5 +1,4 @@ import logging -from typing import List socket_mode_envelopes = [ """{"envelope_id":"1d3c79ab-0ffb-41f3-a080-d19e85f53649","payload":{"token":"verification-token","team_id":"T111","team_domain":"xxx","channel_id":"C111","channel_name":"random","user_id":"U111","user_name":"seratch","command":"/hello-socket-mode","text":"","api_app_id":"A111","response_url":"https://hooks.slack.com/commands/T111/111/xxx","trigger_id":"111.222.xxx"},"type":"slash_commands","accepts_response_payload":true}""", From 1418d939b607f6ce2c7a11325448149cddda84d0 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 1 Feb 2021 21:33:14 +0900 Subject: [PATCH 2/3] Add async client / bugfixes --- .../samples/scim/search_groups_async.py | 25 ++ .../scim/test_scim_client_read.py | 104 +++--- slack_sdk/scim/async_client.py | 1 + slack_sdk/scim/v1/async_client.py | 338 ++++++++++++++++++ slack_sdk/scim/v1/client.py | 4 +- slack_sdk/scim/v1/internal_utils.py | 8 +- slack_sdk/scim/v1/response.py | 11 +- tests/slack_sdk_async/scim/__init__.py | 0 .../slack_sdk_async/scim/test_async_client.py | 80 +++++ 9 files changed, 506 insertions(+), 65 deletions(-) create mode 100644 integration_tests/samples/scim/search_groups_async.py create mode 100644 slack_sdk/scim/async_client.py create mode 100644 slack_sdk/scim/v1/async_client.py create mode 100644 tests/slack_sdk_async/scim/__init__.py create mode 100644 tests/slack_sdk_async/scim/test_async_client.py diff --git a/integration_tests/samples/scim/search_groups_async.py b/integration_tests/samples/scim/search_groups_async.py new file mode 100644 index 000000000..92b2e1074 --- /dev/null +++ b/integration_tests/samples/scim/search_groups_async.py @@ -0,0 +1,25 @@ +# ------------------ +# Only for running this script here +import sys +from os.path import dirname + + +sys.path.insert(1, f"{dirname(__file__)}/../../..") +# ------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +import asyncio +import os +from slack_sdk.scim.async_client import AsyncSCIMClient + +client = AsyncSCIMClient(token=os.environ["SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN"]) + +async def main(): + response = await client.search_groups(start_index=1, count=2) + print("-----------------------") + print(response.groups) + +asyncio.run(main()) \ No newline at end of file diff --git a/integration_tests/scim/test_scim_client_read.py b/integration_tests/scim/test_scim_client_read.py index 0ad82f365..c98564f3b 100644 --- a/integration_tests/scim/test_scim_client_read.py +++ b/integration_tests/scim/test_scim_client_read.py @@ -20,61 +20,61 @@ def setUp(self): def tearDown(self): pass - def test_api_call(self): - response: SCIMResponse = self.client.api_call( - http_verb="GET", path="Users", query_params={"startIndex": 1, "count": 1} - ) - self.assertIsNotNone(response) - - self.logger.info(response.snake_cased_body) - self.assertEqual(response.snake_cased_body["start_index"], 1) - self.assertIsNotNone(response.snake_cased_body["resources"][0]["id"]) - - def test_lookup_users(self): - search_result = self.client.search_users(start_index=1, count=1) - self.assertIsNotNone(search_result) - - self.logger.info(search_result.snake_cased_body) - self.assertEqual(search_result.snake_cased_body["start_index"], 1) - self.assertIsNotNone(search_result.snake_cased_body["resources"][0]["id"]) - self.assertEqual( - search_result.users[0].id, - search_result.snake_cased_body["resources"][0]["id"], - ) - - read_result = self.client.read_user(search_result.users[0].id) - self.assertIsNotNone(read_result) - self.logger.info(read_result.snake_cased_body) - self.assertEqual(read_result.user.id, search_result.users[0].id) - - def test_lookup_users_error(self): - # error - error_result = self.client.search_users(start_index=1, count=1, filter="foo") - self.assertEqual(error_result.errors.code, 400) - self.assertEqual( - error_result.errors.description, "no_filters (is_aggregate_call=1)" - ) - - def test_lookup_groups(self): - search_result = self.client.search_groups(start_index=1, count=1) - self.assertIsNotNone(search_result) - - self.logger.info(search_result.snake_cased_body) - self.assertEqual(search_result.snake_cased_body["start_index"], 1) - self.assertIsNotNone(search_result.snake_cased_body["resources"][0]["id"]) - self.assertEqual( - search_result.groups[0].id, - search_result.snake_cased_body["resources"][0]["id"], - ) - - read_result = self.client.read_group(search_result.groups[0].id) - self.assertIsNotNone(read_result) - self.logger.info(read_result.snake_cased_body) - self.assertEqual(read_result.group.id, search_result.groups[0].id) + # def test_api_call(self): + # response: SCIMResponse = self.client.api_call( + # http_verb="GET", path="Users", query_params={"startIndex": 1, "count": 1} + # ) + # self.assertIsNotNone(response) + # + # self.logger.info(response.snake_cased_body) + # self.assertEqual(response.snake_cased_body["start_index"], 1) + # self.assertIsNotNone(response.snake_cased_body["resources"][0]["id"]) + # + # def test_lookup_users(self): + # search_result = self.client.search_users(start_index=1, count=1) + # self.assertIsNotNone(search_result) + # + # self.logger.info(search_result.snake_cased_body) + # self.assertEqual(search_result.snake_cased_body["start_index"], 1) + # self.assertIsNotNone(search_result.snake_cased_body["resources"][0]["id"]) + # self.assertEqual( + # search_result.users[0].id, + # search_result.snake_cased_body["resources"][0]["id"], + # ) + # + # read_result = self.client.read_user(search_result.users[0].id) + # self.assertIsNotNone(read_result) + # self.logger.info(read_result.snake_cased_body) + # self.assertEqual(read_result.user.id, search_result.users[0].id) + # + # def test_lookup_users_error(self): + # # error + # error_result = self.client.search_users(start_index=1, count=1, filter="foo") + # self.assertEqual(error_result.errors.code, 400) + # self.assertEqual( + # error_result.errors.description, "no_filters (is_aggregate_call=1)" + # ) + # + # def test_lookup_groups(self): + # search_result = self.client.search_groups(start_index=1, count=1) + # self.assertIsNotNone(search_result) + # + # self.logger.info(search_result.snake_cased_body) + # self.assertEqual(search_result.snake_cased_body["start_index"], 1) + # self.assertIsNotNone(search_result.snake_cased_body["resources"][0]["id"]) + # self.assertEqual( + # search_result.groups[0].id, + # search_result.snake_cased_body["resources"][0]["id"], + # ) + # + # read_result = self.client.read_group(search_result.groups[0].id) + # self.assertIsNotNone(read_result) + # self.logger.info(read_result.snake_cased_body) + # self.assertEqual(read_result.group.id, search_result.groups[0].id) def test_lookup_groups_error(self): # error - error_result = self.client.search_groups(start_index=1, count=1, filter="foo") + error_result = self.client.search_groups(start_index=1, count=-1, filter="foo") self.assertEqual(error_result.errors.code, 400) self.assertEqual( error_result.errors.description, "no_filters (is_aggregate_call=1)" diff --git a/slack_sdk/scim/async_client.py b/slack_sdk/scim/async_client.py new file mode 100644 index 000000000..d25505f37 --- /dev/null +++ b/slack_sdk/scim/async_client.py @@ -0,0 +1 @@ +from .v1.async_client import AsyncSCIMClient # noqa diff --git a/slack_sdk/scim/v1/async_client.py b/slack_sdk/scim/v1/async_client.py new file mode 100644 index 000000000..05c850f71 --- /dev/null +++ b/slack_sdk/scim/v1/async_client.py @@ -0,0 +1,338 @@ +import json +import logging +from ssl import SSLContext +from typing import Any, Union +from typing import Dict, Optional +from urllib.parse import quote + +import aiohttp +from aiohttp import BasicAuth, ClientSession + +from slack_sdk.errors import SlackApiError +from .internal_utils import ( + _build_request_headers, + _debug_log_response, + get_user_agent, + _to_dict_without_not_given, + _build_query, +) +from .response import ( + SCIMResponse, + SearchUsersResponse, + ReadUserResponse, + SearchGroupsResponse, + ReadGroupResponse, + UserCreateResponse, + UserPatchResponse, + UserUpdateResponse, + UserDeleteResponse, + GroupCreateResponse, + GroupPatchResponse, + GroupUpdateResponse, + GroupDeleteResponse, +) +from .user import User +from .group import Group + + +class AsyncSCIMClient: + BASE_URL = "https://api.slack.com/scim/v1/" + + token: str + timeout: int + ssl: Optional[SSLContext] + proxy: Optional[str] + base_url: str + session: Optional[ClientSession] + trust_env_in_session: bool + auth: Optional[BasicAuth] + default_headers: Dict[str, str] + logger: logging.Logger + + def __init__( + self, + token: str, + timeout: int = 30, + ssl: Optional[SSLContext] = None, + proxy: Optional[str] = None, + base_url: str = BASE_URL, + session: Optional[ClientSession] = None, + trust_env_in_session: bool = False, + auth: Optional[BasicAuth] = None, + default_headers: Optional[Dict[str, str]] = None, + user_agent_prefix: Optional[str] = None, + user_agent_suffix: Optional[str] = None, + logger: Optional[logging.Logger] = None, + ): + """API client for SCIM API + See https://api.slack.com/scim for more details + :param token: An admin user's token, which starts with xoxp- + :param timeout: request timeout (in seconds) + :param ssl: ssl.SSLContext to use for requests + :param proxy: proxy URL (e.g., localhost:9000, http://localhost:9000) + :param base_url: the base URL for API calls + :param session: a complete aiohttp.ClientSession + :param trust_env_in_session: True/False for aiohttp.ClientSession + :param auth: Basic auth info for aiohttp.ClientSession + :param default_headers: request headers to add to all requests + :param user_agent_prefix: prefix for User-Agent header value + :param user_agent_suffix: suffix for User-Agent header value + :param logger: custom logger + """ + self.token = token + self.timeout = timeout + self.ssl = ssl + self.proxy = proxy + self.base_url = base_url + self.session = session + self.trust_env_in_session = trust_env_in_session + self.auth = auth + self.default_headers = default_headers if default_headers else {} + self.default_headers["User-Agent"] = get_user_agent( + user_agent_prefix, user_agent_suffix + ) + self.logger = logger if logger is not None else logging.getLogger(__name__) + + # ------------------------- + # Users + # ------------------------- + + async def search_users( + self, + *, + # Pagination required as of August 30, 2019. + count: int, + start_index: int, + filter: Optional[str] = None, + ) -> SearchUsersResponse: + return SearchUsersResponse( + await self.api_call( + http_verb="GET", + path="Users", + query_params={ + "filter": filter, + "count": count, + "startIndex": start_index, + }, + ) + ) + + async def read_user(self, id: str) -> ReadUserResponse: + return ReadUserResponse( + await self.api_call(http_verb="GET", path=f"Users/{quote(id)}") + ) + + async def create_user( + self, user: Union[Dict[str, Any], User] + ) -> UserCreateResponse: + return UserCreateResponse( + await self.api_call( + http_verb="POST", + path="Users", + body_params=user.to_dict() + if isinstance(user, User) + else _to_dict_without_not_given(user), + ) + ) + + async def patch_user( + self, id: str, partial_user: Union[Dict[str, Any], User] + ) -> UserPatchResponse: + return UserPatchResponse( + await self.api_call( + http_verb="PATCH", + path=f"Users/{quote(id)}", + body_params=partial_user.to_dict() + if isinstance(partial_user, User) + else _to_dict_without_not_given(partial_user), + ) + ) + + async def update_user( + self, user: Union[Dict[str, Any], User] + ) -> UserUpdateResponse: + return UserUpdateResponse( + await self.api_call( + http_verb="PUT", + path=f"Users/{quote(user.id)}", + body_params=user.to_dict() + if isinstance(user, User) + else _to_dict_without_not_given(user), + ) + ) + + async def delete_user(self, id: str) -> UserDeleteResponse: + return UserDeleteResponse( + await self.api_call( + http_verb="DELETE", + path=f"Users/{quote(id)}", + ) + ) + + # ------------------------- + # Groups + # ------------------------- + + async def search_groups( + self, + *, + # Pagination required as of August 30, 2019. + count: int, + start_index: int, + filter: Optional[str] = None, + ) -> SearchGroupsResponse: + return SearchGroupsResponse( + await self.api_call( + http_verb="GET", + path="Groups", + query_params={ + "filter": filter, + "count": count, + "startIndex": start_index, + }, + ) + ) + + async def read_group(self, id: str) -> ReadGroupResponse: + return ReadGroupResponse( + await self.api_call(http_verb="GET", path=f"Groups/{quote(id)}") + ) + + async def create_group( + self, group: Union[Dict[str, Any], Group] + ) -> GroupCreateResponse: + return GroupCreateResponse( + await self.api_call( + http_verb="POST", + path="Groups", + body_params=group.to_dict() + if isinstance(group, Group) + else _to_dict_without_not_given(group), + ) + ) + + async def patch_group( + self, id: str, partial_group: Union[Dict[str, Any], Group] + ) -> GroupPatchResponse: + return GroupPatchResponse( + await self.api_call( + http_verb="PATCH", + path=f"Groups/{quote(id)}", + body_params=partial_group.to_dict() + if isinstance(partial_group, Group) + else _to_dict_without_not_given(partial_group), + ) + ) + + async def update_group( + self, group: Union[Dict[str, Any], Group] + ) -> GroupUpdateResponse: + return GroupUpdateResponse( + await self.api_call( + http_verb="PUT", + path=f"Groups/{quote(group.id)}", + body_params=group.to_dict() + if isinstance(group, Group) + else _to_dict_without_not_given(group), + ) + ) + + async def delete_group(self, id: str) -> GroupDeleteResponse: + return GroupDeleteResponse( + await self.api_call( + http_verb="DELETE", + path=f"Groups/{quote(id)}", + ) + ) + + # ------------------------- + + async def api_call( + self, + *, + http_verb: str, + path: str, + query_params: Optional[Dict[str, any]] = None, + body_params: Optional[Dict[str, any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> SCIMResponse: + url = f"{self.base_url}{path}" + query = _build_query(query_params) + if len(query) > 0: + url += f"?{query}" + return await self._perform_http_request( + http_verb=http_verb, + url=url, + body_params=body_params, + headers=_build_request_headers( + token=self.token, + default_headers=self.default_headers, + additional_headers=headers, + ), + ) + + async def _perform_http_request( + self, + *, + http_verb: str, + url: str, + body_params: Optional[Dict[str, Any]], + headers: Dict[str, str], + ) -> SCIMResponse: + if body_params is not None: + if body_params.get("schemas") is None: + body_params["schemas"] = ["urn:scim:schemas:core:1.0"] + body_params = json.dumps(body_params) + headers["Content-Type"] = "application/json;charset=utf-8" + + if self.logger.level <= logging.DEBUG: + headers_for_logging = { + k: "(redacted)" if k.lower() == "authorization" else v + for k, v in headers.items() + } + self.logger.debug( + f"Sending a request - url: {url}, params: {body_params}, headers: {headers_for_logging}" + ) + session: Optional[ClientSession] = None + use_running_session = self.session and not self.session.closed + if use_running_session: + session = self.session + else: + session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=self.timeout), + auth=self.auth, + trust_env=self.trust_env_in_session, + ) + + resp: SCIMResponse + try: + request_kwargs = { + "headers": headers, + "data": body_params, + "ssl": self.ssl, + "proxy": self.proxy, + } + async with session.request(http_verb, url, **request_kwargs) as res: + response_body = {} + try: + response_body = await res.text() + except aiohttp.ContentTypeError: + self.logger.debug( + f"No response data returned from the following API call: {url}." + ) + except json.decoder.JSONDecodeError as e: + message = f"Failed to parse the response body: {str(e)}" + raise SlackApiError(message, res) + + resp = SCIMResponse( + url=url, + status_code=res.status, + raw_body=response_body, + headers=res.headers, + ) + _debug_log_response(self.logger, resp) + finally: + if not use_running_session: + await session.close() + + return resp diff --git a/slack_sdk/scim/v1/client.py b/slack_sdk/scim/v1/client.py index 1eaf59d6c..e73dc8aa9 100644 --- a/slack_sdk/scim/v1/client.py +++ b/slack_sdk/scim/v1/client.py @@ -58,8 +58,8 @@ def __init__( user_agent_suffix: Optional[str] = None, logger: Optional[logging.Logger] = None, ): - """API client for Audit Logs API - See https://api.slack.com/admins/scim for more details + """API client for SCIM API + See https://api.slack.com/scim for more details :param token: An admin user's token, which starts with xoxp- :param timeout: request timeout (in seconds) :param ssl: ssl.SSLContext to use for requests diff --git a/slack_sdk/scim/v1/internal_utils.py b/slack_sdk/scim/v1/internal_utils.py index fdc3b764e..d508446ad 100644 --- a/slack_sdk/scim/v1/internal_utils.py +++ b/slack_sdk/scim/v1/internal_utils.py @@ -22,12 +22,6 @@ def _build_query(params: Optional[Dict[str, Any]]) -> str: return "" -def _build_body(original_body: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - if original_body: - return {k: v for k, v in original_body.items() if v is not None} - return None - - def _is_iterable(obj: Union[Optional[Any], DefaultArg]) -> bool: return obj is not None and obj is not NotGiven @@ -152,7 +146,7 @@ def _build_request_headers( return request_headers -def _debug_log_response(logger, resp: "SCIMResponse") -> None: +def _debug_log_response(logger, resp: "SCIMResponse") -> None: # noqa: F821 if logger.level <= logging.DEBUG: logger.debug( "Received the following response - " diff --git a/slack_sdk/scim/v1/response.py b/slack_sdk/scim/v1/response.py index 0cc0384cf..f3ea27fa2 100644 --- a/slack_sdk/scim/v1/response.py +++ b/slack_sdk/scim/v1/response.py @@ -1,5 +1,5 @@ import json -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional from slack_sdk.scim.v1.group import Group from slack_sdk.scim.v1.internal_utils import _to_snake_cased @@ -26,7 +26,7 @@ class SCIMResponse: body: Dict[str, Any] snake_cased_body: Dict[str, Any] - errors: Errors + errors: Optional[Errors] @property def snake_cased_body(self) -> Dict[str, Any]: @@ -35,8 +35,11 @@ def snake_cased_body(self) -> Dict[str, Any]: return self._snake_cased_body @property - def errors(self) -> Errors: - return Errors(**self.snake_cased_body.get("errors")) + def errors(self) -> Optional[Errors]: + errors = self.snake_cased_body.get("errors") + if errors is None: + return None + return Errors(**errors) def __init__( self, diff --git a/tests/slack_sdk_async/scim/__init__.py b/tests/slack_sdk_async/scim/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_sdk_async/scim/test_async_client.py b/tests/slack_sdk_async/scim/test_async_client.py new file mode 100644 index 000000000..80b0e124e --- /dev/null +++ b/tests/slack_sdk_async/scim/test_async_client.py @@ -0,0 +1,80 @@ +import time +import unittest + +from slack_sdk.scim import User, Group +from slack_sdk.scim.v1.async_client import AsyncSCIMClient +from slack_sdk.scim.v1.group import GroupMember +from slack_sdk.scim.v1.user import UserName, UserEmail +from tests.helpers import async_test +from tests.slack_sdk.scim.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestSCIMClient(unittest.TestCase): + def setUp(self): + setup_mock_web_api_server(self) + + def tearDown(self): + cleanup_mock_web_api_server(self) + + @async_test + async def test_users(self): + client = AsyncSCIMClient(base_url="http://localhost:8888/", token="xoxp-valid") + await client.search_users(start_index=0, count=1) + await client.read_user("U111") + + now = str(time.time())[:10] + user = User( + user_name=f"user_{now}", + name=UserName(given_name="Kaz", family_name="Sera"), + emails=[UserEmail(value=f"seratch+{now}@example.com")], + schemas=["urn:scim:schemas:core:1.0"], + ) + await client.create_user(user) + # The mock server does not work for PATH requests + try: + await client.patch_user("U111", partial_user=User(user_name="foo")) + except: + pass + user.id = "U111" + user.user_name = "updated" + try: + await client.update_user(user) + except: + pass + try: + await client.delete_user("U111") + except: + pass + + @async_test + async def test_groups(self): + client = AsyncSCIMClient(base_url="http://localhost:8888/", token="xoxp-valid") + await client.search_groups(start_index=0, count=1) + await client.read_group("S111") + + now = str(time.time())[:10] + group = Group( + display_name=f"TestGroup_{now}", + members=[GroupMember(value="U111")], + ) + await client.create_group(group) + # The mock server does not work for PATH requests + try: + await client.patch_group( + "S111", partial_group=Group(display_name=f"TestGroup_{now}_2") + ) + except: + pass + group.id = "S111" + group.display_name = "updated" + try: + await client.update_group(group) + except: + pass + try: + await client.delete_group("S111") + except: + pass From 1645981a773e48cabff639919fc3646861ee0b3e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 2 Feb 2021 08:34:43 +0900 Subject: [PATCH 3/3] Update integration tests --- .../scim/test_scim_client_read.py | 105 +++++++++--------- 1 file changed, 51 insertions(+), 54 deletions(-) diff --git a/integration_tests/scim/test_scim_client_read.py b/integration_tests/scim/test_scim_client_read.py index c98564f3b..f506b8b26 100644 --- a/integration_tests/scim/test_scim_client_read.py +++ b/integration_tests/scim/test_scim_client_read.py @@ -1,14 +1,11 @@ import logging import os -import time import unittest from integration_tests.env_variable_names import ( SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN, ) from slack_sdk.scim import SCIMClient, SCIMResponse -from slack_sdk.scim.v1.response import Errors -from slack_sdk.scim.v1.user import User, UserName, UserEmail class TestSCIMClient(unittest.TestCase): @@ -20,57 +17,57 @@ def setUp(self): def tearDown(self): pass - # def test_api_call(self): - # response: SCIMResponse = self.client.api_call( - # http_verb="GET", path="Users", query_params={"startIndex": 1, "count": 1} - # ) - # self.assertIsNotNone(response) - # - # self.logger.info(response.snake_cased_body) - # self.assertEqual(response.snake_cased_body["start_index"], 1) - # self.assertIsNotNone(response.snake_cased_body["resources"][0]["id"]) - # - # def test_lookup_users(self): - # search_result = self.client.search_users(start_index=1, count=1) - # self.assertIsNotNone(search_result) - # - # self.logger.info(search_result.snake_cased_body) - # self.assertEqual(search_result.snake_cased_body["start_index"], 1) - # self.assertIsNotNone(search_result.snake_cased_body["resources"][0]["id"]) - # self.assertEqual( - # search_result.users[0].id, - # search_result.snake_cased_body["resources"][0]["id"], - # ) - # - # read_result = self.client.read_user(search_result.users[0].id) - # self.assertIsNotNone(read_result) - # self.logger.info(read_result.snake_cased_body) - # self.assertEqual(read_result.user.id, search_result.users[0].id) - # - # def test_lookup_users_error(self): - # # error - # error_result = self.client.search_users(start_index=1, count=1, filter="foo") - # self.assertEqual(error_result.errors.code, 400) - # self.assertEqual( - # error_result.errors.description, "no_filters (is_aggregate_call=1)" - # ) - # - # def test_lookup_groups(self): - # search_result = self.client.search_groups(start_index=1, count=1) - # self.assertIsNotNone(search_result) - # - # self.logger.info(search_result.snake_cased_body) - # self.assertEqual(search_result.snake_cased_body["start_index"], 1) - # self.assertIsNotNone(search_result.snake_cased_body["resources"][0]["id"]) - # self.assertEqual( - # search_result.groups[0].id, - # search_result.snake_cased_body["resources"][0]["id"], - # ) - # - # read_result = self.client.read_group(search_result.groups[0].id) - # self.assertIsNotNone(read_result) - # self.logger.info(read_result.snake_cased_body) - # self.assertEqual(read_result.group.id, search_result.groups[0].id) + def test_api_call(self): + response: SCIMResponse = self.client.api_call( + http_verb="GET", path="Users", query_params={"startIndex": 1, "count": 1} + ) + self.assertIsNotNone(response) + + self.logger.info(response.snake_cased_body) + self.assertEqual(response.snake_cased_body["start_index"], 1) + self.assertIsNotNone(response.snake_cased_body["resources"][0]["id"]) + + def test_lookup_users(self): + search_result = self.client.search_users(start_index=1, count=1) + self.assertIsNotNone(search_result) + + self.logger.info(search_result.snake_cased_body) + self.assertEqual(search_result.snake_cased_body["start_index"], 1) + self.assertIsNotNone(search_result.snake_cased_body["resources"][0]["id"]) + self.assertEqual( + search_result.users[0].id, + search_result.snake_cased_body["resources"][0]["id"], + ) + + read_result = self.client.read_user(search_result.users[0].id) + self.assertIsNotNone(read_result) + self.logger.info(read_result.snake_cased_body) + self.assertEqual(read_result.user.id, search_result.users[0].id) + + def test_lookup_users_error(self): + # error + error_result = self.client.search_users(start_index=1, count=1, filter="foo") + self.assertEqual(error_result.errors.code, 400) + self.assertEqual( + error_result.errors.description, "no_filters (is_aggregate_call=1)" + ) + + def test_lookup_groups(self): + search_result = self.client.search_groups(start_index=1, count=1) + self.assertIsNotNone(search_result) + + self.logger.info(search_result.snake_cased_body) + self.assertEqual(search_result.snake_cased_body["start_index"], 1) + self.assertIsNotNone(search_result.snake_cased_body["resources"][0]["id"]) + self.assertEqual( + search_result.groups[0].id, + search_result.snake_cased_body["resources"][0]["id"], + ) + + read_result = self.client.read_group(search_result.groups[0].id) + self.assertIsNotNone(read_result) + self.logger.info(read_result.snake_cased_body) + self.assertEqual(read_result.group.id, search_result.groups[0].id) def test_lookup_groups_error(self): # error